macOS アプリで画面遷移 (View Controller の切り替え)

_hatenablogから2018-12-13の記事を移行しました。
https://bitsandbobs.hatenablog.jp/entry/2018/12/13/145130_

----

macOS アプリ (Cocoa App) で画面遷移させるにはどうすればいいのか調べたことまとめ

## 環境
- macOS High Sierra 10.13.6
- Xcode 10.1


## やりたいこと
- Window 内のすべての UI をまるっと入れ替えたい
- 前提として、ここでは Storyboard を利用するものとする


## 概要
今回目標とする画面遷移処理は、最終的に NSView の入れ替えを行えばよくて
最低限に必要な処理は以下2点

- 表示されている View の superview に、次に表示したい View を追加
- 表示されている View を superview から切り離す

アクションメソッド内に上記処理があれば画面遷移できる
必要に応じてアニメーションをはさめばいい

ちなみに、NSWindow.contentView を入れ替えるのではなく contentView の subview の入れ替えを行う
contentView を入れ替えてもいいと思うけど、次の理由から用途は限られるのではなかろうか
(実機で試してないけど)

- 遷移アニメーションを組み込みにくい？
- 局所的な View の切り替えに流用しにくい？

あと、ほとんどの場合 frame の引き継ぎをすることになると思うので忘れずに

## 実装方法
諸々考えると Container View を利用して遷移元 View と親子関係を構築し、遷移処理は Segue を利用するのがよさそう

### Container View?
実態はただの NSView で、Storyboard が embed された View Controller に対して View Controller と View の親子関係を構築するっぽい

### Segue?
Storyboard で View Controller 間の遷移、表示方法などを指定するやつ
用意されているものは別ウィンドウやダイアログ用途のものしかなく View を入れ替えるものはなかった

- Show: 別ウィンドウで表示
- Modal: 別ウィンドウ(モーダル)で表示
- Sheet: 上からスライドインして表示
- Popover: 吹き出しで表示


## サンプル
[参考サイト](https://qiita.com/masakihori/items/d9bb915ca2a0918375ae)のソースを Swift で書き直してみた

```swift
class SlideSegue: NSStoryboardSegue {
  override func perform() {
    // NSViewControllerの親子関係を設定
    guard
      let s = self.sourceController as? NSViewController,
      let d = self.destinationController as? NSViewController,
      let p = s.parent
    else {
      print("downcasting or unwrapping error")
      return
    }

    if (!p.children.contains(d)) {
      p.addChild(d)
    }

    // 遷移アニメーション
    NSAnimationContext.runAnimationGroup(
      { context in
        context.duration = 0.5

        var frame = s.view.frame
        frame.origin.x = frame.size.width;
        d.view.frame = frame;
        s.view.superview?.addSubview(d.view)

        let newDFrame = s.view.frame

        var newSFrame = s.view.frame
        newSFrame.origin.x = -newSFrame.size.width

        s.view.animator().frame = newSFrame
        d.view.animator().frame = newDFrame

        d.view.autoresizingMask = s.view.autoresizingMask // Storyboard で適切に設定されているなら不要かも

      }, completionHandler: {
        s.removeFromParent() // 戻る可能性があるなら不要かも
        s.view.removeFromSuperview()
      }
    )
  }
}
```

アニメーションを抜いてみた
(autoresizingMask の引き継ぎ処理と removeFromParent は割愛)

```swift
  override func perform() {
    // (省略)

    if (!p.children.contains(d)) {
      p.addChild(d)
    }

    // (ここから下を修正)

    d.view.frame = s.view.frame;
    s.view.superview?.addSubview(d.view)
    s.view.removeFromSuperview()
  }
```

NSViewController#transition で書き直してみた

```swift
  override func perform() {
    // (省略)
  
    if (!p.children.contains(d)) {
      p.addChild(d)
    }

    // (ここから下を修正)

    // 試してみたところ
    // frame の引き継ぎも transition でしてくれるっぽい
    // d.view.frame = s.view.frame;
    p.transition(from: s, to: d)
  }
```

superview.addSubview や removeFromSuperview は transition でやってくれる

> This method adds the view in the toViewController view controller to the superview of the view in the fromViewController view controller. Likewise, this method removes the fromViewController view from the parent view controller’s view hierarchy at the appropriate time. It is important to allow this method to add and remove these views.

(引用) [Apple Developer Documentation](https://developer.apple.com/documentation/appkit/nsviewcontroller/1434407-transition) 

基本的なアニメーションは options で指定できる

```swift
p.transition(from: s, to: d, options: .slideForward)
```

NSViewController#removeFromParent などの完了処理が必要な場合は completionHandler で処理を追加すればいい

```swift
p.transition(from: s, to: d, completionHandler: { s.removeFromParent() } )
```

NSViewController#transition で次のエラーが発生する場合は

```text
[General] fromView.superview must be layer-backed for animations to take effect (setWantsLayer:YES on it or a parent of it)
```

wantsLayer = true をコード追加するか

```swift
s.view.superview?.wantsLayer = true // (追加)
p.transition(from: s, to: d, options: .slideForward)
```

または、Storyboard で Core Animation Layer を指定する
![20181213141442.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341937/1e8c00fc-5e98-6e88-636f-640b87a9a123.png)

## まとめ
- 基本的な画面遷移 (View Controller の切り替え) は NSViewController#transition が便利
- Storyboard を使用している場合は Custom Segue を利用するとよさげ

## 参考
- [OS X アプリでStoryboardとSegueを利用する ビューを切り替える編 - Qiita](https://qiita.com/masakihori/items/d9bb915ca2a0918375ae)
- [transition(from: to: options: completionHandler:) - NSViewController | Apple Developer Documentation](https://developer.apple.com/documentation/appkit/nsviewcontroller/1434407-transition)