画面分割その2

第2回 MOSA自習室(http://mosa.connpass.com/event/36300/)の発表用資料です。

説明が足らない部分は当日補足します。

画面分割の種類

画面分割(Splitting a window)には2種類あります。

  1. 一つ目は、同一の書類の異なる部分を同時に見るための機能。プレインストールアプリではターミナルアプリのWindowが典型的な画面になります。
../../../_images/terminal_app.png
  1. 二つ目は、iTunesやFinderのサイドバー等のMaster Detail構造のアプリです。
../../../_images/finder_app.png

ここでは、1の物を作ってみる。なお、NSSpliterViewは、おそらく2の物を作るために設計されたようです。2の型の分割を行うには素直にコーディングを行えば問題ありません。

分割の追加&削除ボタンの作成

スクロールバーにあるボタンの作り方だが、以前の「 画面分割その1 」 でやったので説明はパス。

分割の追加の方法

分割自体は簡単です。NSSplitViewControllerの以下のメソッドを呼んでいるだけです。

- (void)insertSplitViewItem:(NSSplitViewItem *)splitViewItem atIndex:(NSInteger)index;

問題は以下の2点です。

  • 分割の追加後に分割されたviewが再レイアウトされて、分割線の位置がずれる。
  • 新規に分割後のスクロール位置が原点を指している。

分割線の追加後に分割位置がずれる問題は、理想的な分割位置を計算して、以下のメソッドで再設定することで回避しています。

- (void)setPosition:(CGFloat)position ofDividerAtIndex:(NSInteger)dividerIndex;

理想的な分割位置は、「分割ボタンを押したNSScrollViewを2分割する位置」を理想と考えて計算しています。 このようにすることで、ユーザーは「今このScrollViewが分割された」と認識しやすくなります。

同じく、スクロール位置の調整は、分割されたNSScrollViewのコンテンツが連続する位置に移動しています。

何もしないと、以下のようにスクロール位置が原点を示します。

../../../_images/split_scrollpoint_1.png

調整することで、以下のようにコンテンツが連続する位置に調整しています。

../../../_images/split_scrollpoint_2.png

これも、ユーザーが分割を認識しやすくするために行っています。

分割の削除の方法

分割の削除も簡単です。NSSplitViewControllerの以下のメソッドを呼んでいるだけです。

- (void)removeSplitViewItem:(NSSplitViewItem *)splitViewItem;

問題は以下の2点です。

  • 分割の削除後にNSSplitViewが再レイアウトされて、分割線の位置がずれる。
  • フォーカスがある(firstResponder)の分割部分が削除された場合に、フォーカスが未設定になる。

分割線がずれる問題は、分割の追加と同じ手法で解決しています。 ユーザーが分割したことを認識しやすいように、削除した分割部の1つ上の部分だけが広がったように見せています。

フォーカスの未設定への対処は、削除された分割部の1個上の分割部分へフォーカスを移動しています。

コードでは、[TVSSplitViewController makeFirstResponderAtIndex]を参照してください。

分割線の移動方法

NSSplitViewのサブクラスなので何もしないでも分割線( split bar )は移動できます。

が、デフォルトのままだと、分割線の移動時にコンテンツの中身と一緒に移動してしまいます。

これの何処が問題なのかは説明が難しいので、当日現場で説明します。

理想と現実

ここまで作ってみましたが、より良いユーザーインターフェイスとして以下のようなものがあります。

../../../_images/better_splitView.png

Inside Machintoshで紹介されている例です。 ターミナルアプリにあるような分割の追加&削除ボタンは存在しません。 スクロールバーに分轄用の黒い split bar が付いているだけです。 この split bar をドラッグすると、スクロールバー上を split bar が移動し、マウスを離すとその位置で新たに画面が分轄されます。 分轄部分を削除するには、 split bar をスクロールバーの端っこまで移動すると消えます。

この Inside Machintosh で紹介されている方式の方が、以下の2点で優れている。

  • 画面上のコントロールが少ない。「ボタン2個 + split bar1個」から「split bar1個」に減っている。
  • ユーザの作業が少ない。「分割後にsplit barの移動」から「split barの移動」だけに減っている。

この方式が劣っているのは、Appleの標準のアプリで採用している例が無いので、初見では操作方法が解りにくい事くらいと思われる。

あと、ソースコードはここ TableViewSpliter.zip に置いときます。

次回は、こちらの方式を実装してみたいです。じゃあね。

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

単純な例では、カテゴリNSRestorableStateのrestorableStateKeyPathsで保存する変数のパスを返すだけ。 NSViewControllerのサブクラスにでも以下のようなコードを書けば良い。

+ (NSArray *)restorableStateKeyPaths
{
    return [@"color"];// 実際には[]の前に@がつく。コードパーサーがバグってるので@を外した
}

これで、復帰時にプロパティcolorの内容が復元できる。

同じ動作をするものを、少し複雑に書くと。以下のようなコードになる。

@property (atomic, copy) NSColor* color;
.
.
.
- (void) encodeRestorableStateWithCoder:(NSCoder *)coder
{
    [super encodeRestorableStateWithCoder:coder];

    NSData* theData=[NSArchiver archivedDataWithRootObject:self.color];

    [coder encodeObject:theData forKey:@"color"];

}

- (void) restoreStateWithCoder:(NSCoder *)coder
{
    [super restoreStateWithCoder:coder];

    NSData* theData = [coder decodeObjectForKey:@"color"];

    if (theData != nil)
    {
        self.color =(NSColor *)[NSUnarchiver unarchiveObjectWithData:theData];
    }
}

@synthesize color = _color;

- (void) setColor:(NSColor*) inColor
{
    @synchronized(self)
    {
        _color = [inColor copy];
    }

    [self invalidateRestorableState];
}

- (NSColor*) color
{
    @synchronized(self)
    {
        return _color;
    }
}

初期設定Windowやパレットの復帰は、https://www.bignerdranch.com/blog/cocoa-ui-preservation-yall/ のコードが参考になる。

Document-Baseアプリケーションで、1個のドキュメントに複数のウインドウが開けるPhotoShopのようなアプリケーションの場合は以下のようになる。少し違うが、http://mikeabdullah.net/lion-restore-multi-window-document.html も参考になる。

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

    theWindowController = [self makeWindowController];

    completionHandler([theWindowController window], nil);
}


- (NSWindowController*) makeWindowController
{
    NSStoryboard*       theStoryboard;
    NSWindowController* theWindowController;

    theStoryboard       = [NSStoryboard storyboardWithName:@"Document"
                                                    bundle:nil];

    theWindowController = [theStoryboard instantiateInitialController];

    [self addWindowController:theWindowController];

    return theWindowController;
}

ここまでは、アップルのドキュメントなどの通り。

Windowは動的に増える事を前提にしているので、restoreDocumentWindowWithIdentifier:〜なメソッドが用意されていた。 NSTabViewのタブやNSSplitViewのsplitが動的に増える場合にはどうするか?

これは、基本に戻り、encodeRestorableStateWithCoder:やrestoreStateWithCoder:のメソッドをオーバーライドする事になる。

- (void) encodeRestorableStateWithCoder:(NSCoder *)coder
{
    [super encodeRestorableStateWithCoder:coder];

    NSMutableArray* theArray = [NSMutableArray array];

    for(NSInteger i = 0; i <= [self countOfDivider]; i++)
    {
        CGFloat thePosition = [self positionOfDividerAtIndex:i];

        [theArray addObject:@(thePosition)];
    }

    [coder encodeObject:theArray
                 forKey:@"splitSizes"];
}


- (void) restoreStateWithCoder:(NSCoder *)coder
{
    [super restoreStateWithCoder:coder];

    NSArray* theSplitSizes = [coder decodeObjectForKey:@"splitSizes"];

    // 個数分分割して
    NSInteger theSplitCount = theSplitSizes.count;

    while(theSplitCount)
    {
        [self addLastSplitItem:nil];
        theSplitCount--;
    }

    // 幅を揃える
    for(NSInteger i = 0; i < [self countOfDivider]; i++)
    {
        CGFloat thePosition = [[theSplitSizes objectAtIndex:i] floatValue];

        [self.splitView setPosition:thePosition
                   ofDividerAtIndex:i];
    }
}

- (IBAction) addLastSplitItem:(id)sender
{
    NSStoryboard*     theStoryboard;
    NSViewController* theViewController;
    NSSplitViewItem*  theItem;

    theStoryboard     = [NSStoryboard storyboardWithName:@"MainViewController" bundle:nil];
    theViewController = [theStoryboard instantiateInitialController];
    theItem           = [NSSplitViewItem splitViewItemWithViewController:theViewController];

    [self addSplitViewItem:theItem];

    [self invalidateRestorableState];
}

- (NSInteger) countOfDivider
{
    NSInteger theCount = self.splitViewItems.count;

    return (theCount == 0 ) ? 0 : theCount - 1;
}

- (void)setPosition:(CGFloat)position ofDividerAtIndex:(NSInteger)dividerIndex
{
    NSAssert( dividerIndex >= 0, nil);
    NSAssert( dividerIndex < [self countOfDivider], nil);

    [self.splitView setPosition:position
               ofDividerAtIndex:dividerIndex];
}

- (CGFloat)positionOfDividerAtIndex:(NSInteger)dividerIndex
{
    NSAssert( dividerIndex >= 0, nil);
    NSAssert( dividerIndex <= [self countOfDivider], nil);

    CGFloat thePosition = 0;

    for(NSInteger i = 0; i <= dividerIndex; i++)
    {
        NSSplitViewItem* theItem = [self.splitViewItems objectAtIndex:i];
        NSRect theFrame = theItem.viewController.view.frame;

        thePosition += theFrame.size.height;

        // 2個目のitem以降にはdividerThicknessを含める
        if( i != 0)
        {
            thePosition += self.splitView.dividerThickness;
        }
    }

    return thePosition;
}

じゃあね。

キーボードショートカットキーの拡張

MacOSの⌘キーを使ったショートカットシステムに、擬似モードの概念を取り入れる事でショートカットキーの機能を拡張する方法を考えてみる。

擬似モードとは、モディファイアキー(⌘とか)を押している時と離している時をそれぞれ別の動作として考えるUIの手法です。詳細はジェフラスキンのswyftyの記事を参考にしてください。

MacOSに以下の条件で擬似モードを追加する方法を考えました。

  1. 追加のハードウエアが不要
  2. 既存のUIの動作を妨害しない
  3. GUIとして学習が容易。

で、実装として以下のような機能にしました。

  1. ⌘キー+英字キーの組み合わせて、擬似モードに入る。
  2. ⌘キーを離したら、擬似モードを抜ける。
  3. 擬似モード中に、英数キーを押すことで、firstResponderへキーに結び付けられたアクションを発行する。
  4. 擬似モードに入ると、Panelを表示する。Panelには使用可能なアクションの一覧が表示される。

リッチテキストの編集機能を試しに実装してみました。

⌘Tを押すと、以下のようなPanelが表示される。

../../../_images/cmdKeyMode.png

⌘キーを押したまま、L,R,C,Jのアルファベットキーを押すと、選択された行のアライメントが変更される。

⌘を離すと、擬似モードを抜けるのでPanelの表示は消える。

../../../_images/cmdKeyMode_2.png

ムービーで動作確認したほうが早いかも( cmdkeymode.mov )

で、これを実装すると以下のような利点と欠点がある。

  1. ショートカットが多段階になるので使えるキーが事実上増える
  2. emacsスタイルのショートカットと比べてGUIの表示がある事と⌘を使うことで学習コストが低い
  3. Panelの陰にアクションを投げる対象物が隠れてしまう。
  4. 擬似モード中だけに使えるショートカットキーというのは理解しづらいかもしれない

今の所、こんな感じかな。プロジェクトファイルはここに置いてきます。( commandKeyMode.zip )

じゃあね。

xibファイルの中のオブジェクト図

xibファイルの中にある、それぞれのオブジェクトの関係を図示したい。 xibから自動生成させるのが理想だが、実力がないので出来なかった。 テキストから画像を生成させるところまでをメモとしてまとめた。

このようなテキストから

digraph window {

// 定義ファイルをインクルード
include(objectGraph.inc)

// オブジェクトの設定
GenericObject(WinController)
WindowObject(Window)


// コネクションの設定
IBOutlet(Window, WinController, "delegate")
IBOutlet(WinController, Window, "window")

// 表示上の調整
{rank=same; WinController; Window;}

}

以下のような図を生成する。

../../../_images/window.png

材料は、

  • graphviz
  • icon画像
  • m4

の3つ。dot言語をm4マクロで包んで、簡単な表記でオブジェクト図を描画できるようにした。

手順は、

  1. ディレクトリ構造を整え、icon画像を配置。

zipファイルの中身を以下の様に配置する。objectGraph.zip

$ tree .
.
├── icon
│   ├── FirstResponder.png
│   ├── NSApplication.png
│   ├── NSCell.png
│   ├── NSControl.png
│   ├── NSObject.png
│   ├── NSObjectController.png
│   ├── NSView.png
│   ├── NSViewController.png
│   ├── NSWindow.png
│   ├── NSWindowController.png
│   └── Placeholder.png
└── objectGraph.inc
  1. 描画するオブジェクトのdot.m4ファイルを書く。

上記のファイルを展開したディレクトリに、テキストファイルを書く。

  1. コマンド実行。
$ m4 -DshowIBOutlet window.dot.m4 | dot -T png -o window.png

サンプル1 IBOutlet

以下の様なファイルを

digraph objectGraph {

// 定義ファイルをインクルード
include(objectGraph.inc)

// オブジェクトの設定
ApplicationObject(NSApplication)
GenericObject(CCAppDelegate)
WindowObject(NSWindow, , viewGroup)

// View階層の設定
BeginContainerView(contentsView, NSView, , viewGroup)
 ControlObject(NSButton1)
 ControlObject(NSTextField1)
 ControlObject(NSTextField2)
EndContainerView

// コネクションの設定
IBOutlet(NSApplication, CCAppDelegate, " delegate")
IBOutlet(CCAppDelegate, NSWindow, "window")
IBOutlet(CCAppDelegate, NSTextField1, "yen")
IBOutlet(CCAppDelegate, NSTextField2, "doller")
IBOutlet(NSWindow, contentsView, " contentsView")
IBAction(NSButton1, CCAppDelegate, "target/convert:")

// 表示上の調整
{rank=same; CCAppDelegate; NSWindow;}
}

以下のコマンドで画像を生成すると、

$ m4 -DshowIBOutlet CurrencyConverter.dot.m4 | dot -T png -o CurrencyConverter_outlet.png

outletを表示します。

../../../_images/CurrencyConverter_outlet.png

m4に”showIBOutlet”を渡すと、outletを表示します。

サンプル2 IBAction

サンプル1と同じテキストファイルを以下のコマンドで画像生成すると、

以下のコマンドで画像を生成すると、

$ m4 -DshowIBAction CurrencyConverter.dot.m4 | dot -T png -o CurrencyConverter_action.png

actionを表示します。

../../../_images/CurrencyConverter_action.png

m4に”showIBAction”を渡すと、outletを表示します。

IBActionのラベルは”target/セレクタ名”にしています。

サンプル3 View階層

View階層を表現するのには、矢印などのコネクションの表現だけでは無理だと思われるので、”内包”の表現を使用する様にした。

digraph objectGraph {

// 定義ファイルをインクルード
include(objectGraph.inc)

// View階層の設定
BeginContainerView(contentsView, NSView, , viewGroup)
 ControlObject(NSButton1)
 ControlObject(NSTextField1)
 ControlObject(NSTextField2)
EndContainerView

}

は、contentsViewに2つのテキストフィールドと1つのボタンを配置されていることを示しています。

以下のコマンドで画像を生成すると、

$ m4 CurrencyConverter_view.dot.m4 | dot -T png -o CurrencyConverter_view.png

こんな感じになります。グレーの枠で複数のコントロールがcontentsViewに内包されていることを示しています。

../../../_images/CurrencyConverter_view.png

サンプル4 少し大きな画像

ある程度オブジェクトの数が増えると、実用的ではないかもしれない。

ここ( objectGraph.dot.m4 )にあるようなファイルを出力するだけで、

../../../_images/objectGraph.png

こんな煩雑な画像が出てくる。

初心者に説明するための、Object図ならば簡単に書けるけど、実用的なアプリケーションのオブジェクト図は、煩雑すぎて実用にはならないかもしれない。

試作してみての感想

利点としては、

  • 元データがテキストなので各種コマンドラインツールと相性が良い。
  • outletとactionを1つのデータから出力できる様にしたので、画像が煩雑になるのを防いでいる。

欠点は、 - m4のマクロだけでは、テキスト入力の手間はあまり変わらない。 - ある程度大きなオブジェクト図になるとかなり煩雑な画面になってしまう。

今後の展開として

  • xibから出力できる様にしたい。
  • bindingの表記も必要
  • autoLayoutの表記も必要
  • 注目するObjectをフィルタリングして出力可能にすると良いかもしれない