[Mac] NSArrayController に Undo/Redo を実装する

2010年12月22日水曜日 | Published in | 0 コメント

このエントリーをはてなブックマークに追加

NSArrayController に Undo/Redo を実装してみた。

サンプル


NSTableView → NSArrayController → NSMutableArray[NSMutableDictionary] と繋いだ簡単なサンプルを作った。

NSTableView上での挿入・削除・値変更について Undo/Redo が利用できる。


アーキテクチャ


Undo/Redo の処理を MVC の C、すなわちコントローラで実装した。

Undo/Redoの機能は本来モデルで実装すべき処理かもしれない。Core Data では実際そうしている。ただコントローラで Undo/Redo 機能があると特別なモデルクラス(例えば NSManagedObject)を用意する必要がないというメリットがある。つまり普通のモデルクラスに後からコントローラ側で Undo/Redo機能を追加できる。

今回は NSArrayController のサブクラスを作り、その内部で独自の NSUndoManager を管理するようにした。


実装


今回作成したクラス CustomArrayController のインターフェイス。
@interface CustomArrayController : NSArrayController {

 NSUndoManager* undoManager_;
 BOOL skipFlag_;
 NSArray* keys_;
}
@property (nonatomic, retain, readonly) NSUndoManager* undoManager;
@property (nonatomic, retain) NSArray* keys;

-(void)undo;
-(void)redo;

-(IBAction)undo:(id)sender;
-(IBAction)redo:(id)sender;

@end
NSUndoManager は他のUndoと統合(グループ化)したり外部から操作できるようにプロパティ定義した。keys は値監視対象のキー名を格納する。

実装のポイントはレコードの操作の Undo/Redo処理と、カラム値の Undo/Redo処理。

レコード操作の Undo/Redo処理


これはNSArrayController の下記のメソッドをオーバライドし、そこへ Undo/Redo処理を実装した。
- (void)insertObject:(id)object atArrangedObjectIndex:(NSUInteger)index
- (void)insertObjects:(NSArray *)objects atArrangedObjectIndexes:(NSIndexSet *)indexes
- (void)removeObjectAtArrangedObjectIndex:(NSUInteger)index
- (void)removeObjectsAtArrangedObjectIndexes:(NSIndexSet *)indexes
挿入された時には削除操作をNSUndoManagerへ登録し、反対に削除された時は挿入操作を登録する処理を書く。例えば -insertObject:atArrangedObjectIndex: の実装はこんな感じ。
if (!skipFlag_) {
  [self _addObserverFor:[NSArray arrayWithObject:object]];

  [[self.undoManager prepareWithInvocationTarget:self]  // Undo登録
   removeObjectAtArrangedObjectIndex:index];  
 }
 [super insertObject:object atArrangedObjectIndex:index];
skipFlag_ は2重処理を防ぐために入れてある。複数のオブジェクト挿入を行う insertObjects:atArrangedObjectIndexes: は insertObject:atArrangedObjectIndex: を内部的に呼び出す。今回は insertObjects:atArrangedObjectIndexes: 側にも Undo登録の処理を書いているので、そちらと処理が重ならないようにこのような判定を行っている。-insertObjects:atArrangedObjectIndexes: の実装はこう。
- (void)insertObjects:(NSArray *)objects atArrangedObjectIndexes:(NSIndexSet *)indexes
{
 [self _addObserverFor:objects];

 [[self.undoManager prepareWithInvocationTarget:self]
  removeObjectsAtArrangedObjectIndexes:indexes];

 skipFlag_ = YES;
 [super insertObjects:objects atArrangedObjectIndexes:indexes];
 skipFlag_ = NO;
}

今回の検証でわかったのだが1件だけ削除するケースでは(複数削除)→(単一削除)という処理が走る。しかし複数件削除するケースでは単一削除が呼ばれない。
a) 1件削除の場合
removeObjectsAtArrangedObjectIndexes:
 ↓
removeObjectAtArrangedObjectIndex:

b) 複数件削除の場合
removeObjectsAtArrangedObjectIndexes:
複数削除のメソッドから必ず単一削除のメソッドを呼び出すのなら Undo登録は単一削除側だけで良いのだが、この変則的な呼び出しルールがある為、skipFlag_ を導入した。挿入処理だとルールがまた違ったのだが削除側と対称性を持たせるために同じ処理を書いた。


カラム値の Undo/Redo処理


カラム値の Undo/Redo処理を実装するには、NSArrayController のバインド先のモデルの値の変化イベントとその値が必要となる。今回はレコード挿入時に各カラム値をKVO登録してその変化を取得するようにした。先ほどの挿入コード内で呼んでいた _addObserverFor: がその処理。
- (void)_addObserverFor:(NSArray*)objects
{
 for (id object in objects) {
  NSArray* keys = self.keys;
  for (NSString* key in keys) {
   [object addObserver:self
      forKeyPath:key
      options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld
      context:nil];   
  }
 }
}
self.keys はサンプルだと {@"title", @"author"} という配列が入っている。この配列は外部から与えている。本来はモデルからこのキー名を取得できれば良いのだが NSMutableDictionary を NSArrayControllerのモデル(レコード)として使っている場合、レコード新規作成時にはこの情報が取れない。カラムの値(例えば title)を登録した後になって -[NSMutableDictionary allKeys] でキー名が取れるようになる。この為、外部から監視対象のキー名を与えるようになっている。
なお -[NSObject addOberser:forKeyPath:options:context:] に forKeyPath:@"self" を指定した場合は変更イベントを受け取れなかった。

一旦 KVO登録できれば監視対象のカラムの変更イベントをうけとれる。実装はこんな感じ。
- (void)observeValueForKeyPath:(NSString *)keyPath
       ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
 if (change) {
  id value = [change objectForKey:NSKeyValueChangeOldKey];
  if (value == [NSNull null]) {
   value = nil;
  }
  [[self.undoManager prepareWithInvocationTarget:self]
   _setArrangedObject:object value:value forKeyPath:keyPath];   
 }
}
監視対象の値の変化があった時に observeValueForKeyPath:ofObject:change:context: が呼ばれる。変化内容は changeに入っていてこんな感じで渡ってくる。
change: {
    kind = 1;
    new = B;
    old = A;
}
値が nil の場合、NSNull のインスタンスが渡ってくるのでその変換を行っておく。NSUndoManager には _setArrangedObject:value:forKeyPath: を登録しておく。
- (void)_setArrangedObject:(id)object value:(id)value forKeyPath:(NSString*)keyPath
{
 id currentValue = [object valueForKey:keyPath];
 
 [[self.undoManager prepareWithInvocationTarget:self]
  _setArrangedObject:object value:currentValue forKeyPath:keyPath]; 
 
 [object setValue:value forKeyPath:keyPath];
}
このメソッドは現在値を取得して、その値を使って自分自身のメソッドを呼び出すように NSUndoManager へ登録する。こうすると Undo時にもこのメソッドが呼ばれ、その時には Redo用の登録が行われることになる(Undo/Redoの判断は NSUndoManagerが把握していてるので気にする必要はない)。その後モデルのカラム値を setValue:forKeyPath: で設定する。

なおレコードが削除される時には KVOを解除しておく。レコード削除コードでは下記のメソッドを呼び出しておく。
- (void)_removeObserverFor:(NSArray*)objects
{
 for (id object in objects) {
  NSArray* keys = self.keys;
  for (NSString* key in keys) {
   [object removeObserver:self
      forKeyPath:key];
  }
 }
 
}
削除後も NSUndoManager によってレコードのインスタンスは retain されていて、Undo時にまた同じインスタンスがモデル配列へ追加される。この時には NSUndoManagerから通常の操作と同じく insertObject:atArrangedObjectIndex: が呼ばれるため、そこでまた KVO登録処理が走る。この為、Undo/Redoを繰り返すと同じ KVO登録が複数行われ、試した時にはアプリの動作が止まったように遅くなってしまった。これを防ぐために(またリソースを無駄にしない為にも)KVO解除を忘れずにやっておく。


[参考]Cocoaの日々: [Mac] NSArrayController の KVO通知の中身が nil になる(現象記録)


課題


カラムのキー名を外部から与えるというのがいかにもカッコ悪いし使いづらい。Interface Builder で NSArrayControllerのインスペクタを見るとキー名の表示があるのでこれが取得できれば良いのだが方法がわからなかった。ここに表示されるということは Nib内にあるハズなのだが。



ソースコード


GitHub からどうぞ。

ArrayControllerUndoSample at 2010-12-22 from xcatsan/MacOSX-Sample-Code - GitHub


参考情報


(旧) Cocoaの日々: rubberBand(その19)Undoの実装 / NSUndoManager
昔書いた NSUndoManager の説明。

NSUndoManager Class Reference

Key-Value Observing Programming Guide: Introduction to Key-Value Observing Programming Guide

http://developer.apple.com/library/mac/#documentation/Cocoa/Conceptual/CocoaBindings/Concepts/HowDoBindingsWork.html%23//apple_ref/doc/uid/20002373-1…

NSArrayController Class Reference

Responses

Leave a Response

人気の投稿(過去 30日間)