CoreDataプロジェクトでDocument.xcdatamodelの名前を変更するには

Document.xcdatamodelの名前を変更するときは注意が必要。

同じディレクトリにあるファイル”.xccurrentversion”の中に、Document.xcdatamodelのファイル名が書き込まれている。

ファイル名を変更する場合は、キー”_XCCurrentVersionName”の値も変更する必要がある。

$ more .xccurrentversion
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>Document.xcdatamodel</string>  <--- ここ
</dict>
</plist>

上記のリストの”Document.xcdatamodel”部分をファイル名と一致させる事。

Cocoaにおける状態の保存と復元その2

以前に「 Cocoaにおける状態の保存と復元 」にて書いた記述が間違っていた。

splitViewの中のviewをrestoreStateWithCoder:のメソッドの中で生成していたがそれはおそらく間違い。

restore stateの機能は、以下の2点を守れば小細工は必要ない。

  • view階層はrestoreStateWithCoder:が呼ばれる前に構築済みである。
  • restore sateに参加させるobjectはwindow内でユニークなidentifierが設定されている。

この2点を守るのは、通常のアプリケーションでは問題にならない。

Cocoaにおける状態の保存と復元 」で書いた様に、NSTabViewのタブやNSSplitViewのsplitが動的に増える場合にのみ問題になる。

この記事での方法上記の2点を守ることでより簡潔に「状態の保存と復元」を実装する。

実装の概要

nibファイルからのWindowの構築が終わり、メソッドrestoreStateWithCoder:が呼ばれる前のタイミングを得る場所は、以下に示す2つのメソッドになる。

- (void)restoreDocumentWindowWithIdentifier:(NSString *)identifier
                                      state:(NSCoder *)state
                          completionHandler:(void (^)(NSWindow *, NSError *))completionHandler;
- (void) makeWindowControllers;

ただし、makeWindowControllersの方は、引数としてNSCoderが渡されないために、動的にViewを構築するための情報が足らない。

よって、restoreDocumentWindowWithIdentifier:〜をオーバーライドすることになる。

通常、restoreDocumentWindowWithIdentifier:〜をオーバーライドする場合は、以下の様なコードになる。

ググっても”通常”のrestoreDocumentWindowWithIdentifierの使用方法はでてこないので私を信じてほしい。

- (void)restoreDocumentWindowWithIdentifier:(NSString *)identifier
                                      state:(NSCoder *)state
                          completionHandler:(void (^)(NSWindow *, NSError *))completionHandler
{
    NSWindowController* theWindowController;

    if( [identifier isEqualToString:@"window_01"] )
    {
        theWindowController = [self makeWindowController_01];
    }
    else if( [identifier isEqualToString:@"window_02"] )
    {
        theWindowController = [self makeWindowController_02];
    }
    .
    .
    .
    else if( [identifier isEqualToString:@"window_nn"] )
    {
        theWindowController = [self makeWindowController_nn];
    }

    completionHandler([theWindowController window], nil);
}

identifierはクラスではない。同じクラスであっても異なる用途であれば異なるidentifierが設定される。主にnibファイルによって設定され、このなるWindowControllerによって制御される。

このメソッドの中での、completionHandlerによって、restoreStateWithCoder:が呼び出される。

引数identifierとcompletionHandlerは使用されるが、引数stateは使用されていない。

引数stateに保存されている値が保存されているのは、[NSWindowController encodeRestorableStateWithCoder:]で保存できる。

状態の保存時に、動的に生成された部分の情報を[NSWindowController encodeRestorableStateWithCoder:]で保存して、[NSDocument restoreDocumentWindowWithIdentifier]で再生すると実現できる。

サンプルコードの実装

状態の保存に関してみるべきメソッドは以下の3つ。

[TVSDocument restoreDocumentWindowWithIdentifier:state:completionHandler:];
[TVSWindowController prebuildForRestorableStateWithWindowCoder:];
[TVSWindowController encodeRestorableStateWithCoder:];

restoreDocumentWindowWithIdentifierと、prebuildForRestorableStateWithWindowCoderは状態の再生時に使用している。

encodeRestorableStateWithCoderは状態の保存時に使用する。

保存再生する値はsplitViewの分割数で、”numOfSplit”と名前をつけている。

restoreDocumentWindowWithIdentifierの関連箇所は以下の様に、WindowzControllerを作成したら、stateを引数にprebuildForRestorableStateWithWindowCoderを呼び出すだけ。

- (void)restoreDocumentWindowWithIdentifier:(NSString *)identifier
                                      state:(NSCoder *)state
                          completionHandler:(void (^)(NSWindow *, NSError *))completionHandler
{
    TVSWindowController* theWindowController;

    theWindowController = (TVSWindowController*)[self makeWindowController];

    // restore state のコードが走り出す前に、View構造を構築する必要がある。
    // そのため、ここで追加のView構造を構築する機会をwindowに与える。
    [theWindowController prebuildForRestorableStateWithWindowCoder:state];

    completionHandler([theWindowController window], nil);
}

引数stateの中に、”numOfSplit”の値が入っている。ので、prebuildForRestorableStateWithWindowCoderではそれを使ってsplitViewへ分割をお願いする。

- (void) prebuildForRestorableStateWithWindowCoder:(NSCoder*)coder
{
    TVSSplitViewController* theSplitViewController = (TVSSplitViewController*) self.contentViewController;

    // restore state のコードが走り出す前に、View構造を構築する必要がある。
    NSInteger theSplitNumber = [[coder decodeObjectForKey:@"numOfSplit"] integerValue];

    [theSplitViewController createPaneWithNumber:theSplitNumber];
}

状態の保存時に関連するのは、encodeRestorableStateWithCoderだけ。restoreStateWithCoderと対になっていないので気持ち悪いが動的なViewの生成に「状態の保存」が対応していないので仕方がない。 コードとしては以下の様になっている。

- (void) encodeRestorableStateWithCoder:(NSCoder *)coder
{
    TVSSplitViewController* theSplitViewController = (TVSSplitViewController*) self.contentViewController;

    [super encodeRestorableStateWithCoder:coder];

    [coder encodeObject:[NSNumber numberWithInteger:theSplitViewController.splitViewItems.count]
              forKey:@"numOfSplit"];

    [coder encodeObject:self.indexes forKey:@"indexes"];
}

@”indexes”は選択範囲の保存に関係する箇所。画面分割に関係するのは@”numOfSplit”の箇所です。

なお、画面の分割後のsplitViewの中のviewの記述は特に工夫を要する箇所はない。

スクロール位置の保存と再生も”contentView.bounds”を保存して再生時には、reflectScrolledClipView:を使って画面をリフレッシュしているくらいです。

その他

前回のCocoa勉強会で話題になった、「選択位置の情報はどのオブジェクトが保持するべきか?」の問題ですが、WindowControllerに持たせることにしました。

これで、モデルデータは共通だが、選択位置が異なるWindowが実現できています。

なお、ソースコードはここです。

( TableViewSpliter_2.zip )

storyboardの中のNSWindow initialFirstResponderが効かない問題について (2)

表題について、最も簡単な方法は、有効にしたいviewControllerのawakeFromNibの中で、initialFirstResponderの設定をする事です。

awakeFromNibが複数回呼ばれる事にさえ気をつければ最も簡単な解決方法になります。

@interface ViewController : NSViewController

@property (weak) IBOutlet NSTextField* textField;

@end

@implementation ViewController

- (void) awakeFromNib
{
    [super awakeFromNib];

    // 汎用性はないが、もっと簡単な方法
    self.view.window.initialFirstResponder = self.textField;
}

@end

この解決法の問題点は、汎用性がない事です。「storyboardの中のNSWindow initialFirstResponderが効かない」のはnibファイル間でoutletが接続できない事が原因です。

ここでは、このnibファイル間でoutlet接続をする方法を解説します。

nibファイルの読み込み時に何が起こっているのか?

まずは前提条件となる知識として、nibファイルの読み込み時に起こっていることを説明します。

nibファイルの読み込み時には以下のようなことがおこります。

  1. 非カスタムオブジェクトのアンアーカイブ
フラット化されたストリームからNSKeyedArchiverが作成され、そこから初期化メソッドとしてinitWithCoder:が呼ばれ、それぞれのObjectが生成されます。
  1. カスタムオブジェクトのインスタンス化 (サロゲートObject) cutumViewなど
カスタムViewなどのdecodeされたわけではないviewが、初期化メソッドinitWithFrameが呼ばれて、
  1. オブジェクト間のコネクション関連の設定やRuntimeAttibuteの設定
IBOutletConnectionのサブクラスにestablishConectionメッセージが呼ばれ、コネクションを確立します。
  1. nibから再生された全てのオブジェクトにawakeFromNibを呼び出し
objectの生成もコネクションの確立も完了したのちに、awakeFromNibを呼び出してそれぞれのオブジェクトに追加の初期化処理への機会を与えます。

正確ではないかもしれませんが、以上のような手順でnibから読み込まれます。

Note

なお、ストーリーボードが複数のnibに分割される場合には、3と4の間でまた別のnibを読み込むことがあります。そして、この分割によってoutletが接続できないのです。困ったね。

nibファイル間のoutlet接続の実現のアイデア

outlet接続では、objectを直接outletの変数接続している。 これはアーカイブしたときには、objectのハッシュ値になるわけだが、アンアーカイブされたときにobjectのポインター値へ変換される。

同じ、接続(IBConnectors)プロトコルをサポートしているものでも、bindingの様に、ポインターとパスの組み合わせでobjectを指定するものがある。

今回は、このbinding接続の様にパスを使うことで、目的のobjectへ到達できる様にすることを目標にクラスを作成してた。

ただし、現状のIBOutletConnectionのサブクラスを作成してもXCode場では使用できないので、NSObjectのサブクラスを作成することにした。

実際の接続作業は、nibファイル中のobjectが全てインスタンス化した後のawakeFromNibメソッドの中で行う。

要約すると、

  • 起点とするrootとなるobjectへのポインターを設定
  • 起点から接続先を示せるキーパス文字列を設定
  • Outletが設定されているobjectはポインターで設定
  • Outletの変数名は、キー名で設定
  • 上記の値を元に、awakeFromNibでコネクションを確立する。
  • IBOutletConnectionをサブクラス化しない理由は、XCode場で編集できないから

クラスの実装

ヘッダファイルはこんな感じ。

@interface MTLOutletProxy : NSObject

// オブジェクトoutletObjectのアウトレット名outletNameにポインター値を入れる。
@property (weak) IBOutlet      id        outletObject;
@property (copy) IBInspectable NSString* outletName;

// そのポインター値は、objectRootを起点として、キーパスobjectPathで示されるオブジェクトのポインター
@property (weak) IBOutlet      id        objectRoot;
@property (copy) IBInspectable NSString* objectPath;

@end

実装はこれだけ。

@implementation MTLOutletProxy

- (void) awakeFromNib
{
    [self.outletObject setValue:[self.objectRoot valueForKeyPath:self.objectPath]
                     forKeyPath:self.outletName];
}

@end

以上の様に非常にシンプルなクラスになる。

クラスの使用方法

「storyboardの中のNSWindow initialFirstResponderが効かない」の問題を、このクラスを用いて解決する。

  1. ここにMTLOutletProxyを置く
NSObjectを配置して、class名をMTLOutletProxyに変更する。
../../../_images/MTLOutletProxy_1.png
  1. viewControllerにキーパスでタグれる様にプロパティを作っておき、outlet接続しておく。
コードは以下の通りですが、普通はイニシャルレスポンダーにしたいobjectへはすでにoutletがあるはず。
@interface ViewController : NSViewController

@property (weak) IBOutlet NSTextField* textField_1;
@property (weak) IBOutlet NSTextField* textField_2;
@property (weak) IBOutlet NSTextField* textField_3;
@property (weak) IBOutlet NSTextField* textField_4;//<--今回はこれをイニシャルファーストレスポンダーにする

@end
  1. Windowと接続
MTLOutletProxyのoutletObjectアウトレットに、NSWindowを指定する。
../../../_images/MTLOutletProxy_2.png
  1. 起点としてWindowControllerと接続
MTLOutletProxyのobjectRootアウトレットに、NSWindowControllerを指定する。 キーパスの起点になります。
../../../_images/MTLOutletProxy_3.png
  1. Windowの中のinitialFirstResponderプロパティを指定するために文字列を設定
outlet nameにinitialFirstResponderを入力
../../../_images/MTLOutletProxy_4.png
  1. WindowControllerからキーパス文字列を設定して、最初にフォーカスが当たるObjectを指定
object pathにcontentViewController.textField_4を入力
../../../_images/MTLOutletProxy_5.png

実行時に起こること

MTLOutletProxyの以下のメソッドが呼び出される。

- (void) awakeFromNib
{
    [self.outletObject setValue:[self.objectRoot valueForKeyPath:self.objectPath]
                     forKeyPath:self.outletName];
}

先ほどの各種設定をして入れば、

window.initialFirstResponder = windowController.contentViewController.textField_4;

を実行しているのと同じになる。 このawakeFromNibの実行時点ではwindowは表示される前なのでinitialFirstResponderの値は有効になり、 textField_4がfirstResponderになる。

ただし、その後”Restore state”の機能が実行されて、2回目以降は前回のfirestResponderへフォーカスが移動してしまう。

Note

デバッグ時にRestore state機能をOffにするには、”Edit scheme…” > “Run” > “Options” > “Persistant State”のチェックボックスをONにする。 これを行わないと、最後にWindowを閉じた時のfirstResponderにフォーカスが当たってしまう。

以上で終わりです。じゃあね。

storyboardの中のNSWindow initialFirstResponderが効かない問題について (1)

MOSA.swiftでMTLProxyResponderを使えば出来ると嘘をついてしまったのでお詫び。

ソースコードだけ。 ( InitalFirstResponderWB.zip )

サンプルコードの動作は、初回起動時だけ、ViewControllerに結び付けられたtextField_4が最初にアクティブになります。

二回め以降は”restore state”が効くので最後に終了した時のfirstResponderになります。

仕組みの解説や応用は、眠いので明日書きます。

じゃあね。