ドラッグ&ドロップ用のデータソースオブジェクトを作る【実践的Macintoshプログラミング解説】

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

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

LinkIconホーム

更新日 2009-05-24

ドラッグ&ドロップでリサージュ図形の並べ替え、複製ができる様にする

ドラッグ&ドロップ用のデータソースオブジェクトを作る

 カット&ペーストができる様になったので、見た目が派手なカット&ペーストであるドラッグ&ドロップにも対応したいと思います。

 ドラッグ&ドロップに対応するために、テーブルビューのデータソースとなるオブジェクトを用意します。このオブジェクトにはNSTableDataSourceプロトコルで規定されているメソッドを実装します。

 完成したと思ったら不具合が見つかる、という事が何度もあってかなり手こずっています。いや作るの面倒くさいですね、ドラッグ&ドロップって。

ドラッグ&ドロップ検証用アプリケーション

 実際に操作しながらドラッグ&ドロップの説明を読んだほうがわかりやすいのではないかと思って、テーブルビューにorderを表示した検証用アプリケーションを作成しました。下記リンクでダウンロードできます。
 サンプルの書類も一緒に入れてあります。

LinkIconドラッグ&ドロップ検証用アプリケーション

新規ファイル

 Xcodeのファイルメニューから「新規ファイル…」を実行します。CocoaグループのObjective-C classを選んで「次へ」をクリックします。

newFileAssistant31.png

 DragDropDataSourceと入力して完了です。

dragDropDataSource.png

インターフェイスファイルを編集する

 選択されているオブジェクトを知る必要があるので、配列コントローラのアウトレットを追加します。またリサージュ図形がドラッグコピーされたとき、コピーされたオブジェクトが挿入ポイントに正しく入る様にするために挿入される行を覚えておく必要がでてきます。そのためのインスタンス変数insertionRowを追加します。

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

#import <Cocoa/Cocoa.h>


@interface DragDropDataSource : NSObject
{
    IBOutlet    NSArrayController   *arrayController;

    int     insertionRow;
}

@end

マクロ定義と文字列定数の宣言

 RMGRoot.hにUNDO_MANAGERマクロが登録してあるのですが、DragDropDataSourceは管理対象オブジェクトではないので、このマクロを使ってアンドゥマネージャにアクセスする事はできません。そこでundefでUNDO_MANAGERマクロの定義を取り消して、定義し直しています。DragDropDataSourceからは配列コントローラ経由でアンドゥマネージャにアクセスする事ができます。

 またドラッグされたオブジェクトの行番号情報をペーストボードに書き込む際のデータタイプを決めておく必要があります。これを文字列定数として定義します。文字列定数をそれぞれの場所で直接使ってしまうと、タイプミスした場合に発見が困難なバグとなってしまうので、このようにグローバル変数を使うのがよく使われるテクニックの様です。変数ならタイプミスした時にコンパイラが教えてくれます。

//
//  DragDropDataSource.m
//  RepeatingMotifGenerator
//
//  Copyright NovemberKou 2008 . All rights reserved.
//

#import "DragDropDataSource.h"

#import "Lissajous.h"


#undef      UNDO_MANAGER
#define     UNDO_MANAGER    [[arrayController managedObjectContext] undoManager]

// Pboard type
NSString    *RMGRowsType = @"RMGRowIndexesPboardType";

@implementation DragDropDataSource

管理対象オブジェクトが追加、削除された時に順番を振り直す

 RMGRootエンティティのorder属性はデフォルト値が-1になっているので、追加されたLissajousクラスのオブジェクトは皆orderが-1になっています。これを追加された時に適切に書き換えたいのですが、さてどうしたらよいでしょうか?

 素直に考えると、管理対象オブジェクトのawakeFromInsertメソッドで現在のオブジェクト数を数え、それプラス1にorderを設定するとなると思うのですが、これをやると問題が発生します。これは下の失敗例を見て下さい。

 問題がないのは管理対象オブジェクトコンテキストが発行する通知を受け取って処理する方法です。NSManagedObjectContextObjectsDidChangeNotificationという恐ろしく長い名前の通知を受け取ります。Didと名前がついている通り、変更処理が終わってから送られてくるので、awakeFromInsertの様な問題が発生しません。

 別のドキュメントの管理対象オブジェクトコンテキストが発行する通知を受け取ってしまわない様に、通知を受け取る登録をする際に、objectとして配列コントローラと結びつけられた管理対象オブジェクトコンテキストを指定します。配列コントローラのアウトレットが有効である必要があるので、initメソッドではなくawakeFromNibメソッドで登録を実行します。

 登録したら解除が必要です。deallocメソッドで登録解除をします。

//
//  DragDropDataSource.m
//

- (void)awakeFromNib
{
    NSNotificationCenter    *nc = [NSNotificationCenter defaultCenter];

    insertionRow = -1;
    [nc addObserver:self
           selector:@selector(managedObjectChanged:)
               name:NSManagedObjectContextObjectsDidChangeNotification
             object:[arrayController managedObjectContext]];
}

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [super dealloc];
}

【失敗例】awakeFromInsertでfetchを実行すると同じオブジェクトが二つ現れる


 別のソフトウェアを開発している時に経験したのですが、awakeFromInsertでfetchを実行すると、そのオブジェクトが二つ現れるという問題が発生します。同じ問題に直面した人は他にもいるようで、検索したら以下のページが見つかりました。

Core Data : calling validation in awakeFromInsert problem ?LinkIcon

 Insert処理が完了する前にfetchを実行するので、こんな不具合が発生するのだそうです。遅延実行すれば解決するそうですが、これだと二回アンドゥしないと元に戻らない不具合が新たに発生するような気もします。

通知を受け取って順番を振り直す

 managedObjectChanged:で追加された管理対象オブジェクトのorderを適切に設定します。これは説明が長くなるので、別のページで改めて説明します。

データソースとして必要なメソッドを実装する

 ここからはNSTableDataSourceプロトコルで規定されているメソッドを実装していきます。まずはnumberOfRowsInTableView:ですが、これはゼロを返すとバインディングを使ってくれるそうです。AppleのサンプルプログラムDragAppにそう書いてありました。DragAppは/Developer/Examples/CoreDataの中に入っています。

 同様にtableView:objectValueForTableColumn:row:もnilを返すとバインディングを使ってくれるそうです。

//
//  DragDropDataSource.m
//

#pragma mark -
#pragma mark Data Source method

//  ゼロを返すとテーブルビューは後戻りしてバインディングからデータを得る
- (int)numberOfRowsInTableView:(NSTableView *)tableView
{
    return 0;
}

//  nilを返すとテーブルビューは後戻りしてバインディングからデータを得る
- (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)tableColumn
            row:(int)rowIndex
{
    return nil;  
}

オブジェクトをペーストボードへ書き出す

 旧バージョンではtableView:writeRows:toPasteboard:メソッドを使っていたのですが、これはMacOS X 10.4以降では非推奨だそうです。代わりにtableView:writeRowsWithIndexes:toPasteboard:を使えとあるので、こちらを実装する事にします。

 このメソッドはドラッグを開始すべき時にテーブルビューから呼び出されます。ただしまだ実際にドラッグは開始されていませんので、このメソッドでNOを返せばドラッグは中止されます。YESを返してペーストボードにデータを置けばドラッグが始まります。ドラッグに使われる画像や、その他必要な情報は、YESを返せばテーブルビューが用意してくれます。

 ペーストボードに置くデータは二つあります。ドラッグされたリサージュ図形が自分のテーブルビューにドロップされたときのためのデータと、他のドキュメントウィンドウのテーブルビューにドロップされた時のためのデータの二種類です。

 他のドキュメントウィンドウのテーブルビューにドロップする時のために、ペーストボードにドラッグされているリサージュ図形をコピーします。ここで一つ失敗しているのですが、これは下の失敗例を見て下さい。

 続いて自分自身にドロップされた時のために行番号情報をペーストボードにセットします。これは単に引数のrowIndexesをアーカイブしたデータです。

//
//  DragDropDataSource.m
//

- (BOOL)tableView:(NSTableView *)tableView writeRowsWithIndexes:(NSIndexSet *)rowIndexes
     toPasteboard:(NSPasteboard *)pboard
{
    [Lissajous copyObjects:[[arrayController arrangedObjects] objectsAtIndexes:rowIndexes]
              toPasteboard:pboard];
    [pboard addTypes:[NSArray arrayWithObject:RMGRowsType] owner:nil];
    [pboard setData:[NSArchiver archivedDataWithRootObject:rowIndexes]
            forType:RMGRowsType];

    return  YES;
}

【失敗例】コピーするオブジェクトを間違えた


 オブジェクトをペーストボードにコピーするコードですが、最初は

    [Lissajous copyObjects:[arrayController selectedObjects]
              toPasteboard:pboard];


と書いていました。選択されているリサージュ図形をコピーしていたわけです。
 しばらく不具合に気づかなかったのですが、たまたま選択されているのと違うオブジェクトをドラッグしてコピーしてみた時に間違いに気づきました。ドラッグしたリサージュ図形ではなく、そのとき選択されていたリサージュ図形がコピーされてしまったからです。

 あらゆる操作を想定して試験するというのは難しいですね。

ドラッグ操作を検証する

 テーブルビューがtableView:validateDrop:proposedRow:proposedDropOperation:メッセージを送ってきたら、データソースはその内容をチェックしてNSDragOperationを返します。NSDragOperationを決定するロジックは検証の時とドロップを受け入れる時とで同じにしたいので、tableView:validateDrop:という下請けのメソッドを作りました。

 tableView:validateDrop:ではドラッグ元とドラッグ先が同じ場合はNSDragOperationMove、違う場合はNSDragOperationCopyを返しています。ただしオプションキーが押されていたらNSDragOperationCopyを返します。

 最後にドラッグ元のマスクと論理積を取って返り値とします。もしもドラッグ元がコピーを禁止していれば、dragOperationがNSDragOperationCopyであったとしても、ここでNSDragOperationNoneになるわけです。

 tableView:validateDrop:proposedRow:proposedDropOperation:メソッドは基本的にtableView:validateDrop:の値を返すだけなのですが、引数のoperationを使って一仕事させています。
 引数のoperationは行の上にドロップしようとしているのか(NSTableViewDropOn)、行の間にドロップしようとしているのか(NSTableViewDropAbove)を表します。行の上にドロップしようとしている場合は、setDropRow:dropOperation:を使って行の間にドロップする動作に変更するという事をやっています。

//
//  DragDropDataSource.m
//

- (NSDragOperation)tableView:(NSTableView *)tableView
                validateDrop:(id <NSDraggingInfo>)info
{
    NSDragOperation dragOperation;

    dragOperation = ([info draggingSource] != tableView) ? NSDragOperationCopy : NSDragOperationMove;

    if([[NSApp currentEvent] modifierFlags] & NSAlternateKeyMask)
        dragOperation = NSDragOperationCopy;

    return  dragOperation & [info draggingSourceOperationMask];
}

- (NSDragOperation)tableView:(NSTableView *)tableView
                validateDrop:(id <NSDraggingInfo>)info
                 proposedRow:(int)row
       proposedDropOperation:(NSTableViewDropOperation)operation
{
    if(operation == NSTableViewDropOn)
        [tableView setDropRow:row dropOperation:NSTableViewDropAbove];

    return  [self tableView:tableView validateDrop:info];
}

ドロップ操作を受け入れる

 最後にtableView:acceptDrop:row:dropOperation:メソッドですが、これは説明が長くなりますので、次のページにまわします。とりあえずリストだけ掲載しておきます。

//
//  DragDropDataSource.m
//

- (void)rearrangeObjects
{
    [[UNDO_MANAGER prepareWithInvocationTarget:self] rearrangeObjects];
    [arrayController rearrangeObjects];
}

- (BOOL)tableView:(NSTableView*)tableView 
       acceptDrop:(id <NSDraggingInfo>)info 
              row:(int)row 
    dropOperation:(NSTableViewDropOperation)operation
{
    NSPasteboard    *pboard = [info draggingPasteboard];
    NSDragOperation dragOperation = [self tableView:tableView validateDrop:info];

    if(dragOperation == NSDragOperationCopy)
    {
        insertionRow = row;
        [Lissajous pasteFromPasteboard:pboard controller:arrayController];
        [UNDO_MANAGER setActionName:NSLocalizedString(@"dragCopyLissajous",nil)];
        return  YES;
    }
    else if(dragOperation == NSDragOperationMove)
    {
        int             i,order,index,first,last;
        NSIndexSet      *rowIndexes;
        RMGRoot         *object;
        NSArray         *arrangedObjects = [arrayController arrangedObjects];
        NSEnumerator    *enumerator = [arrangedObjects objectEnumerator];
        NSMutableArray  *orders = [NSMutableArray arrayWithCapacity:[arrangedObjects count]];

        while(object = [enumerator nextObject])
            [orders addObject:[object order]];

        rowIndexes = [NSUnarchiver unarchiveObjectWithData:[pboard dataForType:RMGRowsType]];

        //  挿入された行までのドラッグされていないオブジェクトの番号を振り直す
        first = row < [rowIndexes firstIndex] ? row : [rowIndexes firstIndex];
        if(first == 0)
            order = 0;
        else
            order = [[[arrangedObjects objectAtIndex:first-1] order] intValue] + 1;
        for(i=first;i<row;i++)
        {
            if([rowIndexes containsIndex:i])    continue;
            object = [arrangedObjects objectAtIndex:i];
            [object setValue:[orders objectAtIndex:order++] forKey:@"order"];
        }
        //  ドラッグされているオブジェクトの番号を振り直す
        index = [rowIndexes firstIndex];
        while(index != NSNotFound)
        {
            object = [arrangedObjects objectAtIndex:index];
            [object setValue:[orders objectAtIndex:order++] forKey:@"order"];
            index = [rowIndexes indexGreaterThanIndex:index];
        }
        //  挿入された行以降のドラッグされていないオブジェクトの番号を振り直す
        last = row > [rowIndexes lastIndex] ? row : [rowIndexes lastIndex];
        for(i=row;i<last;i++)
        {
            if([rowIndexes containsIndex:i])    continue;
            object = [arrangedObjects objectAtIndex:i];
            [object setValue:[orders objectAtIndex:order++] forKey:@"order"];
        }

        [self rearrangeObjects];
        [UNDO_MANAGER setActionName:NSLocalizedString(@"dragOperation",nil)];
        return YES;
    }
    else
        return  NO;
}