OscillatorViewを波形ドラッグに対応させる【実践的Macintoshプログラミング解説】

印刷用表示 |テキストサイズ 小 |中 |大 |

CoreData版 Repeating Motif Generator の開発 Repeating Motif Wonderland CoreData 実践的 Macintosh プログラミング解説

LinkIconホーム

更新日 2009-05-24

波形をドラッグして発振器のパラメータを設定できる様にする

OscillatorViewを波形ドラッグに対応させる

 発振器には振幅(波の高さ)、周波数(波の数)、位相のずれ(波の位置)という三つのパラメータがあります。このパラメータを設定するためにステッパとスライダーを使っていました。スライダーが粗調整、ステッパが微調整を行なうという役割分担です。

 これでも特に問題はないのですが、より直感的に操作できる方法を思いついたので、それを実現してみる事にしました。波形を直接ドラッグして波の高さや位置を変更するという方法です。操作対象に直接働きかけて結果がその場で現れるので、こちらの方が圧倒的にわかりやすいでしょう。波の高さと位置のどちらを変更するのかは、最初にドラッグする向きが縦方向か、横方向かで区別します。

 これだと波の数を変更できませんが、わかりやすい方法を思いつかないので、従来通りステッパで設定する事にします。iPhoneやiPod Touchだったら二本指で拡大・縮小する動きを使えば、直感的に操作できるんですけどね。

OscillatorViewを波形ドラッグに対応させる

 まずOscillatorViewの対応から説明します。OscillatorViewクラスの変更点はmouseUp:メソッドの変更とmouseDown:メソッドの追加、そしてmouseDragged:メソッドの追加です。そして、それらの下請けのメソッドも追加しています。
 またマウスカーソルを変更する機能も追加しています。

OscillatorViewにインスタンス変数を追加する

 最初にクリックされた時のマウス座標や振幅、位相のずれを覚えておく必要があるのでインスタンス変数を追加します。また縦方向か横方向かを判定した結果を保持する必要があるので、そのためのインスタンス変数も追加します。

startPoint
最初にクリックされた時のマウス座標を保持します。ウィンドウ座標系の位置です。
convertedStartPoint
startPointを自分の座標系に変換した値です。
startAmplitude
最初にクリックされた時の振幅を保持します。
startPhaseLag
最初にクリックされた時の位相のずれを保持します。
direction
マウスがどちらの方向にドラッグされたのかを判定した結果を保持します。

横方向の場合は1

縦方向の場合は-1

未判定の場合は0

//
//  OscillatorView.h
//  RepeatingMotifGenerator
//
//  Copyright NovemberKou 2008. All rights reserved.
//

#import <Cocoa/Cocoa.h>
#import "BezierPathView.h"


@interface OscillatorView : BezierPathView
{
    BOOL        selected;
    NSString    *oscillatorKeyPath;
    NSPoint     startPoint,convertedStartPoint;
    int         startAmplitude,startPhaseLag,direction;
}

- (NSString *)oscillatorKeyPath;

- (BOOL)selected;
- (void)setSelected:(BOOL)isSelected;

@end

カーソルの形を変える

 OscillatorViewをドラッグする事ができますよ、という事をアピールするため、マウスポインタがOscillatorViewの中に入ってきたらマウスカーソルの形を変える事にします。マウスカーソルの形を変えるにはNSViewのresetCursorRectsメソッドを使います。デベロッパドキュメントには以下の様に書いてあります。

A subclass’s implementation must invoke addCursorRect:cursor: for each cursor rectangle it wants to establish. The default implementation does nothing.

Application code should never invoke this method directly; it’s invoked automatically as described in "Handling Tracking-Rectangle and Cursor-Update Events in Views." Use the invalidateCursorRectsForView: method instead to explicitly rebuild cursor rectangles.

 大体こんな事が書いてあります。

  • サブクラスはresetCursorRectsメソッドの実装を、設定したいカーソル矩形それぞれに対してaddCursorRect:cursor:メソッドを使って設定するようにしないといけない。
  • NSViewではresetCursorRectsメソッドで何もしない。
  • アプリケーションはresetCursorRectsメソッドを直接呼び出してはいけない。カーソル矩形を再設定したければ、代わりにinvalidateCursorRectsForView:メソッドを使う。

 ここではビュー全体の矩形を指定して、カーソルが開いた手の形になる様に指定しています。

//
//  OscillatorView.m
//

- (void)resetCursorRects
{
    [self addCursorRect:[self bounds] cursor:[NSCursor openHandCursor]];
}

mouseUp:メソッド

切替えタイミングの変更

 以前はマウスボタンが放された時に発振器を切替えていましたが、波形をドラッグできる機能をつけ加えるとなると、マウスボタンが放されてから切替えるのは不自然で、マウスボタンが押された時に切替える方が自然です。そこでEnclosureViewに切替えメッセージを送るコードをmouseUp:メソッドからmouseDown:メソッドに移します。

アンドゥのグループ化

 ドラッグ中に連続的に振幅や位相のずれを変更するわけですが、イベントループをまたぐためアンドゥがグループ化されない問題が発生しました。一回のドラッグで振幅を変更したのに、その途中経過が全部記録されて何度も何度もアンドゥしないと元に戻らないという困った事になります。
 これは明示的にアンドゥをグルーピングすればよいので、mouseDown:でグループ化開始、mouseUp:でグループ化終了とします。配列コントローラで何も選択されていない時はグループ化開始もしないので、その場合は何もしません。

方向判定結果のリセット

 ドラッグ方向が縦か横かを判定した結果をリセットするコードを追加します。

//
//  OscillatorView.m
//

#define     UNDO_MANAGER    [[controller managedObjectContext] undoManager]

- (void)mouseUp:(NSEvent *)theEvent
{
    if(!selected)
        [(EnclosureView *)[self superview] selectOscillator:self];
    if([[controller selectedObjects] count] > 0)
    {
        direction = 0;
        [UNDO_MANAGER endUndoGrouping];
    }
}

mouseDown:メソッド

 発振器を切替えるコードをmouseUp:メソッドからこちらに持ってきました。

 配列コントローラで何も選択されていない時でもOscillatorViewがクリックされる事はあり得ますが、その場合はパラメータを変更すべき発振器がないわけですから、波形のドラッグに関するコードを実行できません。よってその場合は何もしない様にします。

 mouseUp:メソッドで説明した様にアンドゥのグルーピングを開始します。それ以降にやっている事はマウス座標を読み取ってインスタンス変数に保存している事と、発振器の振幅、位相のずれを読み取ってインスタンス変数に保存している事です。OscillatorViewは発振器に対するキーパスを保持しているので、それとvalueForKeyPath:メソッドを使って発振器のパラメータを読み取る事ができます。

//
//  OscillatorView.m
//

- (void)mouseDown:(NSEvent *)theEvent
{
    NSNumber    *amplitude,*phaseLag;
    
    if(!selected)
        [(EnclosureView *)[self superview] selectOscillator:self];

    if([[controller selectedObjects] count] > 0)
    {
        [UNDO_MANAGER beginUndoGrouping];
        
        startPoint = [theEvent locationInWindow];
        convertedStartPoint = [self convertPoint:startPoint fromView:nil];
        
        amplitude = [controller valueForKeyPath:[oscillatorKeyPath stringByAppendingString:@".amplitude"]];
        phaseLag = [controller valueForKeyPath:[oscillatorKeyPath stringByAppendingString:@".phaseLag"]];
        
        startAmplitude = [amplitude intValue];
        startPhaseLag = [phaseLag intValue];
    }
}

振幅の変動を適用する

 ドラッグの方向を判定して、振幅の変更であると判定されるとapplyAmplitudeChange:direction:メソッドが呼ばれます。引数はマウス座標の変化量(移動量)とドラッグの方向です。

 振幅は

  • ビューの中心から離れる方向にドラッグされたときに値を増やす
  • ビューの中心に向かう方向にドラッグされた時に値を減らす

とするのが自然であろうと考えました。こうすると正弦波のピークをドラッグした時に波形がマウスカーソルについてくる感じになります。

 OscillatorViewはX軸データ用のものとY軸データ用のものがあって、X軸データ用のものは90度回転させて表示しています。よって

  • directionが横方向の場合はビューの右半分がクリックされたのか、左半分がクリックされたのかで増減を判定する
  • directionが縦方向の場合はビューの上半分がクリックされたのか、下半分がクリックされたのかで増減を判定する

としています。

 ドラッグされた量と振幅の増減を合わせるために、変化量を操作しています。例えば横方向の場合、ビューの幅で割って振幅の最大値の二倍をかけています。マウス座標の変化量をビューの幅で割ると、マウスカーソルをビューの端から端まで動かした時に1になります。ビューに表示されている波形の端から端までは振幅の最大値の二倍なので、これをかけると一致する事になります。

 最後に振幅がゼロから振幅の最大値の間に収まる様にクリッピングしてから値を発振器に書き込んで終了です。

//
//  OscillatorView.m
//

- (void)applyAmplitudeChange:(float)amount direction:(int)aDirection
{
    if(aDirection > 0)
    {
        if(convertedStartPoint.x > NSMidX([self bounds]))
            amount = startAmplitude + amount*AMPLITUDE_MAX*2/NSWidth([self bounds]);
        else
            amount = startAmplitude - amount*AMPLITUDE_MAX*2/NSWidth([self bounds]);
    }
    else
    {
        if(convertedStartPoint.y > NSMidY([self bounds]))
            amount = startAmplitude - amount*AMPLITUDE_MAX*2/NSHeight([self bounds]);
        else
            amount = startAmplitude + amount*AMPLITUDE_MAX*2/NSHeight([self bounds]);
    }
    amount = amount > AMPLITUDE_MAX ? AMPLITUDE_MAX : amount;
    amount = amount < 0 ? 0 : amount;
    [controller setValue:[NSNumber numberWithInt:(int)amount]
              forKeyPath:[oscillatorKeyPath stringByAppendingString:@".amplitude"]];
}

位相ずれの変動を適用する

 ドラッグの方向を判定して、位相ずれの変更であると判定されるとapplyPhaseLagChange:メソッドが呼ばれます。引数はマウス座標の変化量(移動量)です。

 ドラッグされた量と位相ずれの増減を合わせるために、変化量を操作しています。変化量に360をビューの高さで割って周波数をかけた値をかけています。マウス座標の変化量をビューの高さで割ると、マウスカーソルをビューの端から端まで動かした時に1になります。ビューに表示されている波形の端から端までの位相は360の周波数倍なので、これをかけると一致する事になります。

 最後に位相ずれがゼロから359の間に収まる様にクリッピングしてから値を発振器に書き込んで終了です。

//
//  OscillatorView.m
//

- (void)applyPhaseLagChange:(float)amount
{
    NSNumber    *frequency;

    frequency = [controller valueForKeyPath:[oscillatorKeyPath stringByAppendingString:@".frequency"]];
    amount *= 360/NSHeight([self bounds])*[frequency intValue];
    amount = (int)amount % 360;
    if(amount < 0)  amount += 360;
    [controller setValue:[NSNumber numberWithInt:(startPhaseLag + (int)amount) % 360]
              forKeyPath:[oscillatorKeyPath stringByAppendingString:@".phaseLag"]];
}

mouseDragged:メソッド

 mouseDragged:メソッドではマウス座標を読み取ってその変化量を割り出し、縦方向のドラッグなのか横方向のドラッグなのかを判定して、それに応じて上に用意したメソッドを呼び出しています。

 縦横の判断をする際、敏感すぎると誤判断するので多少の遊びを持たせています。例えば縦方向にドラッグしようとして最初にわずかに横にぶれてしまった場合に「横」と判断してしまう事を避けようという事です。これは3ピクセル動くまでは判断を保留する事で実現しています。

//
//  OscillatorView.m
//

- (void)mouseDragged:(NSEvent *)theEvent
{
    float       dx,dy;
    NSPoint     loc = [theEvent locationInWindow];
    
    if([[controller selectedObjects] count] > 0)
    {
        dx = loc.x - startPoint.x;
        dy = loc.y - startPoint.y;
        if(direction == 0 && ( fabs(dx)>3 || fabs(dy)>3 ))
            direction = fabs(dx) > fabs(dy) ? 1 : -1;
        if(direction == 0)  return;
        
        if(isXView)
        {
            if(direction > 0)
                [self applyAmplitudeChange:dx direction:direction];
            else
                [self applyPhaseLagChange:dy];
        }
        else
        {
            if(direction > 0)
                [self applyPhaseLagChange:-dx];
            else
                [self applyAmplitudeChange:-dy direction:direction];
        }
    }
}