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

Qiitaに移行しました。
https://qiita.com/morikiyo/items/64fc525cb10fa04eb559

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

環境

やりたいこと

  • 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: 吹き出しで表示

サンプル

参考サイトのソースを 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 は割愛)

  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 で書き直してみた

  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

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

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

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

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

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

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

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

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

または、Storyboard で Core Animation Layer を指定する

f:id:bitsandbobs:20181213141442p:plain

まとめ

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

参考