[iOS] UIViewControllerTransitioningDelegate と UIViewControllerAnimatedTransitioning でいい感じにフルスクリーン表示する

前説

今回は UIViewControllerTransitioningDelegateUIViewControllerAnimatedTransitioning
使い方についてまとめていきます。

前回の記事 でいい感じに動画コンテンツみたくインライン表示、フルスクリーン表示を切り替えることができました。

ViewController を切り替えていい感じにフルスクリーン表示する

しかし、フルスクリーン用VCが遷移周りの処理も請け負ってしまい 神クラスっぽくなってしまいました。
やばそうな雰囲気を感じるので、遷移に関する責務を分離します!

前提条件

Xcode: 12.1
シミュレータ: iOS14系

というわけで早速作っていきます。

主な登場クラス

今回以下の4つになります。

  1. インライン用VC: 今回遷移のVC
  2. フルスクリーン用VC: 今回遷移のVC
  3. UIViewControllerTransitioningDelegate: 遷移時にどの UIViewControllerAnimatedTransitioning (↓これ) を利用するかを定義する
  4. UIViewControllerAnimatedTransitioning: 遷移時の具体的なアニメーションを定義する

Transitioning ほげほげクラスは何かややこしくて最初めっちゃ混乱しがちですが、実装が必要なクラスは
UIViewControllerTransitioningDelegateUIViewControllerAnimatedTransitioning の2種類のみです🙌

画面構成

画面構成

流れをシーケンス図で表示するとこうなる

インライン -> フルスクリーン

インライン -> フルスクリーン

フルスクリーン -> インライン

フルスクリーン -> インライン

上図の絵の列の「コンテナビュー」は、遷移時にiOSが自動で生成するUIView です(後述)

コードの概要

まず遷移前後における、各クラスの役割と内容をざっくり書きます。
役割がざっくり分かれば Transitioning 系の使い方をイメージできると思います!

1.インライン用VC

        // フルスクリーン用VCのインスタンス生成
        let sampleFullScreenVC = SampleFullScreenVC()
        
        // 遷移時のデリゲートとして、独自定義したクラスをセット
        sampleFullScreenVC.transitioningDelegate = SampleTransitioningDelegate()
        
        // 表示。
        // 上記デリゲートでセットした遷移動作をさせたいのでanimated は true にする必要アリ。
        present(sampleFullScreenVC, animated: true)

2.フルスクリーン用VC

遷移のみに話を絞れば、特に何も書く必要がありません。

dismiss するときに animated: true を忘れないように、くらいです。

3.UIViewControllerTransitioningDelegate

VC を present するとき、dismiss するときにどの
UIViewControllerAnimatedTransitioning を使うかを決める。だけ。

1.インライン用VCのコード内で、フルスクリーン用VCの遷移時のデリゲートにセットしたのがこいつです。

class SampleTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
    // present するときに使うUIViewControllerAnimatedTransitioning 
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SamplePresentAnimatedTransition()
    }
    
    // dismiss するときに使うUIViewControllerAnimatedTransitioning 
    func animationController(
        forDismissed dismissed: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SampleDismissAnimatedTransition()
    }
}

4.UIViewControllerAnimatedTransitioning

遷移時のアニメーションなどを記述していくクラス。

上の UIViewControllerAnimatedTransitioning のコードにて、
present や dismiss するときにこれ使うよ〜と返却したのがこいつです。

class SamplePresentAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning {
    // 遷移の秒数を定義。正直これは無視できるので、定義されてるな〜程度に。
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }

    // 遷移の具体的な動きをここに書いていく。遷移時にOS側から呼ばれるデリゲートメソッドです。
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // 略
        
        // 遷移が完了したことを知らせるメソッドを最後に必ず呼ぶ(後述)
        transitionContext.completeTransition(true)
    }
}

以上4つです。
何となくイメージできたでしょうか。
再掲。

  1. インライン用VC: 今回遷移前のVC
  2. フルスクリーン用VC: 今回遷移後のVC
  3. UIViewControllerTransitioningDelegate: 遷移時にどの UIViewControllerAnimatedTransitioning (↓これ) を利用するかを定義する
  4. UIViewControllerAnimatedTransitioning: 遷移時の具体的なアニメーションを定義する

ではこれ以降、今回の実装に入っていきます!

フルスクリーン表示する

1.透明のフルスクリーン用VCをpresent する

インライン用VCにて以下のコードを書きます。

        let sampleFullScreenVC = SampleFullScreenVC()
        sampleFullScreenVC.view.backgroundColor = .clear
        
        if let transition = SampleTransitioningDelegate() {
            sampleFullScreenVC.transitioningDelegate = transition
        }
        
        present(sampleFullScreenVC, animated: true)

インライン用VCのコードは以上です
以降は Transitioning 系やフルスクリーン用VCのコードのみになります。

2.UIViewControllerTransitioningDelegate を定義する

上述のコードまんまですが。

class SampleTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SamplePresentAnimatedTransition()
    }
}

present のコードのみ書き、いまはdismiss を省略しています。
UIViewControllerTransitioningDelegate のコードもこれで以上です。

3.フルスクリーン用VCに見た目が変わらない位置に要素を配置する

ここが今回の肝で、遷移の具体的な動きを書いていきます。
UIViewControllerAnimatedTransitioning プロトコルに準拠した SamplePresentAnimatedTransition です。

長くなるので分割して書きます。
(最後のコードまとめにまとめて載せています)

まず、クラス宣言と遷移時間を定義します。

class SamplePresentAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }

適当に0.5秒にしました。

次に、遷移アニメーションを記述していきます。
まず最初に、遷移前のVC(インライン用VC) と 遷移後のVC(フルスクリーン用VC) を取り出します。

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let from = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? UINavigationController,
              let playerVC = from.topViewController as? SamplePlayerAndCollectionVC,
              let to = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? SampleFullScreenVC else {
            transitionContext.completeTransition(false)
            return
        }

今回インラインVC は UINavigationController 配下にあったので、
UITransitionContextViewControllerKey.from で取得できるのは UINavigationController となっています。
UINavigationController を使っていない場合はもちろん .from で直接遷移元のVC を取得できます。

次に、遷移時のコンテナビューに、フルスクリーン用VCのview を貼り付けます。

        let containerView = transitionContext.containerView
        containerView.addSubview(to.view)

唐突に出てきたコンテナビューて何やねん感🤔

コンテナビューとは、遷移時にiOSが自動で生成するUIViewです。
今回で言うと、下図のようにインラインVCの上に入ります。

フルスクリーンVCのview をaddSubview した後のview hierarchy

では、このコンテナビューをどのように利用していくかですが、
遷移先のview を addSubview する以外は特に何も操作する必要はなさそうです。
遷移のビューなので、遷移中にコンテナビューにアニメーション用の要素を貼ってリッチに動かす、
的なこともしなくてよさそうです。

僕が試したところ、コンテナビュー自体の背景色をフェードインで黒くしようと思ったのですが、
いきなり黒になってしまい、うまくアニメーションしませんでした。
なので、フルスクリーン用VCの要素を操作してフェードインなどさせています🙌


さて次に、フルスクリーン用VCの上に、見た目が変わらない位置に要素を配置します。

        guard let targetView = playerVC.targetView,
              let originalSuperviewOfTargetView = targetView.superview else {
            transitionContext.completeTransition(false)
            return
        }
        
        let initialTargetViewFrame = to.view.convert(
            targetView.frame,
            from: targetView.superview
        )
        
        // この setup ではdismiss 時に使うためにフルスクリーン用VCのプロパティに保持しているだけ
        to.setup(
            targetView: targetView,
            originalSuperviewOfTargetView: originalSuperviewOfTargetView,
            initialTargetViewFrame: initialTargetViewFrame
        )
        
        let containerView = transitionContext.containerView
        containerView.addSubview(to.view)

        to.view.addSubview(targetView)
        targetView.frame = initialTargetViewFrame

このあたりの動きは前回の記事 1.透明のフルスクリーン用VCを表示し、見た目が変わらない位置に要素を配置する
でもう少し詳しく説明しています。

4.背景を黒くし、遷移完了のメソッドを呼ぶ

        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            animations: {
                to.view.backgroundColor = .black
            },
            completion: { _ in
                // 遷移が完了したタイミングで必ず呼ぶ
                transitionContext.completeTransition(true)

                // 端末を回転させる
                to.rotateToLandscape()
            }
        )

アニメーションで背景を黒くしたあとで
遷移完了を知らせるメソッド transitionContext.completeTransition(_ didComplete: Bool) を呼んでいます。

これは遷移が完了したタイミングで必ず呼びます。
呼ばない場合、以降も遷移中のままとしてiOSに扱われ、遷移中に実行できる挙動しか受け付けなくなりそうです。

上の例で言うと、それを呼んだあとで端末を回転させています。
試したところ、完了メソッドを呼ぶ前に端末回転のコードを書いても、呼んだ後で回転しました。
遷移中は画面回転が許可されていないと思われ、
つまり完了メソッドを呼び忘れた場合、それ以降画面回転が効かなくなりそうです。

画面回転は「遷移」の範囲外の挙動なのでしょうね🤔

これで UIViewControllerAnimatedTransitioning の実装は完了です。

5.全画面表示にする

    func rotateToLandscape() {
        UIDevice.current.setValue(
            UIInterfaceOrientation.landscapeRight.rawValue,
            forKey: "orientation"
        )
        
        UIView.animate(
            withDuration: 0.5,
            animations: {
                if let superviewFrame = self.targetView?.superview?.frame {
                    self.targetView?.frame = CGRect(
                        x: 0.0,
                        y: 0.0,
                        width: superviewFrame.width,
                        height: superviewFrame.height
                    )
                }
                self.view.layoutIfNeeded()
            }
        )
    }

フルスクリーン用VCに書きます。
こちらも前回の記事の 3.画面を強制的に横向きにしつつ、要素を全画面に表示する
もう少しだけ詳しく説明しています。

以上でインラインVCからフルスクリーンVCへの遷移は完了です!

dismiss に関しては present の逆回しに考えていけばよいだけなので、
(本ポストも長くなってしまったので)割愛します。

以下にコードまとめを載せておきます。
dismiss の分も載せています。

コードまとめ

1.インライン用VC

class SamplePlayerAndCollectionVC: UIViewController {
    override var shouldAutorotate: Bool {
        return false
    }
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .portrait
    }

    func openFullScreen() {
        let sampleFullScreenVC = self.storyboard?.instantiateViewController(withIdentifier: "SampleFullScreenVC") as! SampleFullScreenVC
        sampleFullScreenVC.view.backgroundColor = .clear
        sampleFullScreenVC.transitioningDelegate = SampleTransitioningDelegate()
        
        present(sampleFullScreenVC, animated: true)
    }
}

2.フルスクリーン用VC

class SampleFullScreenVC: UIViewController {
    private(set) var targetView: UIView?
    private(set) var originalSuperviewOfTargetView: UIView?
    private(set) var initialTargetViewFrame: CGRect?
    
    override var shouldAutorotate: Bool {
        return true
    }
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .allButUpsideDown
    }
    
    func setup(
        targetView: UIView?,
        originalSuperviewOfTargetView: UIView?,
        initialTargetViewFrame: CGRect?
    ) {
        self.targetView = targetView
        self.originalSuperviewOfTargetView = originalSuperviewOfTargetView
        self.initialTargetViewFrame = initialTargetViewFrame
    }
    
    func rotateToLandscape() {
        UIDevice.current.setValue(
            UIInterfaceOrientation.landscapeRight.rawValue,
            forKey: "orientation"
        )
        
        UIView.animate(
            withDuration: 0.5,
            animations: {
                if let superviewFrame = self.targetView?.superview?.frame {
                    self.targetView?.frame = CGRect(
                        x: 0.0,
                        y: 0.0,
                        width: superviewFrame.width,
                        height: superviewFrame.height
                    )
                }
                self.view.layoutIfNeeded()
            }
        )
    }
    
    func rotateToPortrait(
        completion: (() -> ())?
    ) {
        UIDevice.current.setValue(
            UIInterfaceOrientation.portrait.rawValue,
            forKey: "orientation"
        )
        
        UIView.animate(
            withDuration: 0.5,
            animations: {
                if let initialTargetViewFrame = self.initialTargetViewFrame {
                    self.targetView?.frame = CGRect(
                        x: initialTargetViewFrame.minX,
                        y: initialTargetViewFrame.minY,
                        width: initialTargetViewFrame.width,
                        height: initialTargetViewFrame.height
                    )
                }
                self.view.layoutIfNeeded()
            },
            completion: { _ in
                completion?()
            }
        )
    }
    
    // フルスクリーンをdismiss するメソッド。どこからか呼ぶ必要あり
    func animateToClose() {
        rotateToPortrait { [weak self] in
            self?.dismiss(animated: true)
        }
    }
}

3.UIViewControllerTransitioningDelegate

class SampleTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SamplePresentAnimatedTransition()
    }
    
    func animationController(
        forDismissed dismissed: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SampleDismissAnimatedTransition()
    }
}

4.UIViewControllerAnimatedTransitioning

class SamplePresentAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        
        guard let from = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? UINavigationController,
              let playerVC = from.topViewController as? SamplePlayerAndCollectionVC,
              let to = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? SampleFullScreenVC else {
            transitionContext.completeTransition(false)
            return
        }
        
        let containerView = transitionContext.containerView
        containerView.addSubview(to.view)
        
        guard let targetView = playerVC.targetView,
              let originalSuperviewOfTargetView = targetView.superview else {
            transitionContext.completeTransition(false)
            return
        }
        
        let initialTargetViewFrame = to.view.convert(
            targetView.frame,
            from: targetView.superview
        )
        
        to.setup(
            targetView: targetView,
            originalSuperviewOfTargetView: originalSuperviewOfTargetView,
            initialTargetViewFrame: initialTargetViewFrame
        )
        
        to.view.addSubview(targetView)
        targetView.frame = initialTargetViewFrame
        
        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            animations: {
                to.view.backgroundColor = .black
            },
            completion: { _ in
                transitionContext.completeTransition(true)
                to.rotateToLandscape()
            }
        )
    }
}

class SampleDismissAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let from = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? SampleFullScreenVC else {
            transitionContext.completeTransition(false)
            return
        }
        
        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            animations: {
                from.view.backgroundColor = .clear
            },
            completion: { _ in
                if let targetView = from.targetView {
                    from.originalSuperviewOfTargetView?.addSubview(targetView)

                    if let initialTargetViewFrame = from.initialTargetViewFrame {
                        let frame = from.view.convert(
                            initialTargetViewFrame,
                            to: targetView.superview
                        )
                        targetView.frame = frame
                    }
                }
                
                transitionContext.completeTransition(true)
            }
        )
    }
}

最後に

長くなってしまいましたが以上です。

お疲れ様でした!

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中

%d人のブロガーが「いいね」をつけました。