画面遷移のアニメーションをカスタムする

UINavigationController を使った画面遷移のアニメーションのデフォルトは、横からスライドするアニメーションですが、これをカスタムすることができます。

最近、アニメーションをカスタムしたりといったようにカスタムトランジションについて学んでみたので、実装方法を書いてみたいと思います。

登場人物

アニメーションを管理するので少しコード量が多くなってしまって複雑に感じるかと思うのですが、登場するクラスとプロトコルに関しては以下の3つだけです。

  • UINavigationController (クラス)
  • UINavigationControllerDelegate (プロトロル)
  • UIViewControllerAnimatedTransitioning (プロトコル)

カスタムトランジションのざっくりとした流れとしては、以下の通りです。

  1. 画面遷移のメソッドが呼ばれる (UINavigationController)
  2. 画面遷移のアニメーションを決定する (UINavigationControllerDelegate)
  3. アニメーションをする (UIViewControllerAnimatedTransitioning)

UIViewControllerAnimatedTransitioning

画面遷移時に行うアニメーションを定義するためのプロトコルです。
アニメーションをカスタムするためには、このプロトコルに準拠したオブジェクトを用意する必要があります。カスタムアニメーションに必要な最低限実装すべきメソッドは以下の2つです。

以下、上記メソッドの引数名は省略します。

transitionDuration

アニメーションにかける時間を定義するためのメソッドです。

animateTransition

遷移時に行うアニメーションを定義するためのメソッドです。このメソッド内でどういったアニメーションを行うのかを実装していきます。

アニメーションは、 このメソッドの引数として渡ってくる UIViewControllerContextTransitioning を用いて実装していくことになります。 このクラスが、遷移前の View 、遷移後の View、アニメーションを描写するための View を持っているので、これらを取り出してアニメーションを組み立てていきます。

なお、プッシュ時、ポップ時と共にこちらのメソッドが呼ばれるため、内部で分岐してアニメーションを切り替える必要があります。
しかし、このメソッドの引数として渡ってくる UIViewControllerContextTransitioning にはプッシュ、ポップを判別するための API が用意されていないため、自前で用意する必要があります。

UINavigationControllerDelegate

この Protocol にカスタムトランジションの様々な設定を行うためのメソッドが用意されています。 今回はアニメーションを変更するだけなので、以下のメソッドを実装します。

このメソッドの返り値として、 UIViewControllerAnimatedTransitioning に準拠したオブジェクトを返すことで、画面遷移時にカスタムしたアニメーションが行われるようになります。

nil を返すと、横からスライドするデフォルトのアニメーションとなります。

UINavigationController

わざわざ書くまでもないかと思いますが、このクラスによって画面遷移を行います。 上記の UINavigationControllerDelegate に準拠したオブジェクトを UINavigationController オブジェクトの delegate プロパティにセットすることで、画面遷移時に UINavigationControllerDelegate の各メソッドが呼ばれるようになります。

具体的な実装

UIViewControllerAnimatedTransitioning

このプロトコルに準拠したクラスを作ります。
UIViewControllerAnimatedTransitioning が NSObjectProtocol を継承しているので、作成するクラスも NSObject を継承させる必要があります。

class CustomAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning {

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        // ...
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // ...
    }

}

transitionDuration

アニメーションにかける、良い感じの値を返すようにするだけです。

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return 0.3
}

animateTransition

先にコードを書いてしまうと、プッシュの場合は以下のようになります。

// (1)
guard let sourceView = transitionContext.view(forKey: .from),
    let destinationView = transitionContext.view(forKey: .to) else {
        // アニメーション終了の通知
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        
        return
}

// (2)
let containerView = transitionContext.containerView
containerView.addSubview(sourceView)
containerView.insertSubview(destinationView, aboveSubview: sourceView)

// (3)
UIView.animate(
    withDuration: transitionDuration(using: transitionContext),
    animations: {
        // プッシュのカスタムアニメーション処理
    },
    completion: { _ in
        // アニメーション終了の通知
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
    }
)

(1) はじめに、引数の transitionContext からアニメーションに必要なオブジェクトを取り出します。
transitionContext.view(forKey: .from) で遷移元の画面の View、 transitionContext.view(forKey: .to) で遷移元の画面の View が取得できます。
これらのメソッドの返り値は Optional なので、取得できなかった場合は return していますが、このとき transitionContext.completeTransition(!transitionContext.transitionWasCancelled) によって画面遷移が終了したことを通知します。
このメソッドの引数には画面遷移が正常に終了したかどうかを Bool で渡します。
transitionContext.transitionWasCancelled で画面遷移がキャンセルされたかどうかを知ることができるので、今回はこの結果を渡しています。

(2) 続いて、transitionContext.containerView により、 transitionContext からアニメーションを描画するためのキャンバスとなる View を取り出します。
そして、この View に先ほど取り出しておいた View 達を addSubviewinsertSubview で追加していきます。 これでアニメーションさせるための前準備は終わりです。

(3) 最後に、画面遷移時に行いたいアニメーション処理を実行します。
アニメーションが終えたら、画面遷移が終了となるので、これを通知するために transitionContext.completeTransition(!transitionContext.transitionWasCancelled) を実行します (試してはいませんが、このタイミングでは引数を true に固定してしまっても良いのかもしれません) 。

ポップの場合の処理も追加すると以下のようになるかと思います。

if プッシュなら {
    guard let sourceView = transitionContext.view(forKey: .from),
        let destinationView = transitionContext.view(forKey: .to) else {
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            
            return
    }
    
    let containerView = transitionContext.containerView
    containerView.addSubview(sourceView)
    containerView.insertSubview(destinationView, aboveSubview: sourceView)
        
    UIView.animate(
        withDuration: transitionDuration(using: transitionContext),
        animations: {
            // プッシュのカスタムアニメーション処理
        },
        completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    )
} else {
    guard let sourceView = transitionContext.view(forKey: .to),
        let destinationView = transitionContext.view(forKey: .from) else {
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            
            return
    }
    
    let containerView = transitionContext.containerView
    containerView.addSubview(destinationView)
    containerView.insertSubview(sourceView, aboveSubview: destinationView)
    
    UIView.animate(
        withDuration: transitionDuration(using: transitionContext),
        animations: {
            // ポップのカスタムアニメーション処理
        },
        completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    )
}

プッシュとポップの判別

後ほど説明しますが、 UIViewControllerAnimatedTransitioning に準拠したオブジェクトを生成するタイミングで画面遷移時の UINavigationControllerOperation の値を取得することができるので、これを保持しておいて参照できるようにしておくのが、個人的には良いのではないかと思っています。

let operation: UINavigationControllerOperation

init(operation: UINavigationControllerOperation) {
    self.operation = operation
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    switch operation {
    case .push:
        // プッシュのカスタムアニメーション処理
    case .pop:
        // ポップのカスタムアニメーション処理
    case .none:
        break
    }
}

UINavigationControllerDelegate

このプロトコルに準拠したクラスを作ります。
こちらも NSObjectProtocol を継承しているので、作成するクラスも NSObject を継承させる必要があります。 今回は UINavigationController を継承したクラスを作成し、このクラスに適用することにします。 実装すべきメソッドを実装すると以下のようになります。

class NavigationController: UINavigationController, UINavigationControllerDelegate {
    
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAnimatedTransitioning(operation: operation)
    }
    
}

このメソッドの返り値として先ほどつくったクラスのオブジェクトを返すことで、画面遷移時にカスタムアニメーションが行われるようになります。

UIViewControllerAnimatedTransitioning に準拠したオブジェクトを生成するタイミングで画面遷移時の UINavigationControllerOperation の値を取得することができるので

先ほど上記のように書きましたが、それがこのタイミングとなります。
このメソッドの引数として 画面遷移の種類を表す UINavigationControllerOperation が渡ってくるので、これを CustomAnimatedTransitioning の初期化時に渡してあげることで、 animateTransition が呼ばれたタイミングでプッシュとポップを判別することができるようになり、アニメーションを切り替えることができます。

引数の fromVCtoVC は、それぞれ遷移元、遷移先の ViewController です。そのため、画面によってデフォルトのアニメーションとカスタムアニメーションを切り替えたい場合には、以下のように分岐させることで可能となります。

if toVC is CustomViewController || fromVC is CustomViewController {
    return CustomAnimatedTransitioning(operation: operation)
} else {
    // nil を返すとデフォルトのアニメーション
    return nil
}

UINavigationController

画面遷移に使用するこのクラスのオブジェクトの delegate プロパティに、先ほどの UINavigationControllerDelegate に準拠したオブジェクトをセットします。そうすることで、画面遷移のメソッドが呼ばれた時に delegate で実装したメソッドが呼ばれ、カスタムアニメーションが実行されることになります。

なお、画面遷移のメソッドが呼ばれるタイミングより前に delegate をセットする必要があります。今回は、上記ですでに UINavigationController を継承したクラスをつくっているので、この init 内で delegate をセットするようしています。

class NavigationController: UINavigationController {
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
    }
    
    override init(rootViewController: UIViewController) {
        super.init(rootViewController: rootViewController)
        
        delegate = self
    }
    
}

extension NavigationController: UINavigationControllerDelegate {
    
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        if toVC is DestinationViewController || fromVC is DestinationViewController {
            return CustomAnimatedTransitioning(operation: operation)
        } else {
            return nil
        }
    }
    
}

以上で、アニメーションをカスタムするための実装は完了となります!

おわりに

この記事の頭に表示している GIF のアニメーションと同様のアニメーションを行うサンプルコードも用意してみたので、よかったらこちらも確認してみてください。

github.com

次は、インタラクティブなカスタムアニメーションを行うための実装について書いてみたいと思います。