🎬

今こそ理解する、UIKitのカスタム画面遷移

に公開

これはTimeTree Advent Calendar 2025の23日目の記事です。

はじめに

TimeTreeは最近大規模なUI改修を行いました。(2025年12月現在、アプリ設定の「新機能の利用」をオンにするとお試しきただけます。)その中の要件として、ユーザーが保持するカレンダーを開く際にカスタムの画面遷移を実装する必要がありました。

UIKitのカスタム画面遷移を実装する仕組みは古くからありますが、複雑でとっつきにくいイメージがありませんか?個人的にもいまだに馴染めていなかったのでこれを機に整理してまとめてみようと思います。今回TimeTreeで実装したものを元に、より単純化したカスタム画面遷移のサンプルを例に解説していきます。

この記事を参照することで、カスタム画面遷移に関するクラス、プロトコルそれぞれの役割を理解し、画面遷移の各フェーズでやることの手順やルールを把握できるようになることを目指します。

本記事のサンプルコードは以下に置いてあります。
https://github.com/gonsee/UIKitCustomTransition

関連APIの概要

カスタム画面遷移の実装に必要なクラスやプロトコルは数が多く複雑です。まずは登場人物の役割を大まかに把握するため以下にまとめました。

Transitioning Delegate

UIViewControllerTransitioningDelegate に適合したクラスのオブジェクトです。実装者はこのデリゲートを通じてカスタム画面遷移に必要な各種オブジェクト(Animator、Presentation Controller)をUIKitに提供します。

Animator

画面遷移時のアニメーションを実装するオブジェクトで、2つのプロトコルがあります。ユーザー操作に追従しない固定長のアニメーションの場合は UIViewControllerAnimatedTransitioning を使い、ユーザー操作に追従するインタラクティブなアニメーションの場合はこれに加えて UIViewControllerInteractiveTransitioning を使います。

Presentation Controller

画面遷移中に背景を暗くしたい場合など、表示するビュー以外に装飾のためのビューが必要になることがあります。こういった画面遷移に必要なビュー階層を構築し、画面が表示され閉じられるまでの間管理するのがPresentation Controllerです。UIPresentationController を継承してカスタマイズします。

基本的なアニメーションの実装

まずは、画面遷移中にユーザーの操作が介在しない、非インタラクティブなアニメーションを実装していきます。完成イメージは以下のGIF画像を参照ください。表示する画面は上部が角丸のシートとして下から上にアニメーションします。また、背景を徐々に透明から指定の色に変化させます。画面を閉じるときはこの逆の動きをします。

基本的な画面遷移アニメーション

Presentation Controllerの実装

はじめに、画面遷移に必要な装飾のためのビューを UIPresentationController を使って実装していきます。表示されるビューを上部が角丸のビューの上に乗せ、背景に色の付いたビューを配置する必要があります。また、画面遷移の進行に合わせて背景色を徐々に変化させるアニメーションもここで実装します。

まず、角丸のビューを SheetView として以下のように実装します。

/// 表示先のビューを上に乗せる、上部が角丸のビュー
final class SheetView: UIView {
    /// コンテンツ切り抜き用のマスクレイヤー
    private let contentMaskLayer: CAShapeLayer = .init()

    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .systemBackground
        layer.masksToBounds = true
        layer.mask = contentMaskLayer
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        // 上だけ角丸に切り抜く
        let path = UIBezierPath(
            roundedRect: bounds,
            byRoundingCorners: [.topLeft, .topRight],
            cornerRadii: CGSize(width: 24, height: 24)
        )
        contentMaskLayer.path = path.cgPath
    }
}

そして UIPresentationController を継承した CustomPresentationController を作ります。

final class CustomPresentationController: UIPresentationController {
    /// アニメーション完了時の背景色
    private let backgroundColor: UIColor
    /// 色をつける背景のビュー。最終的にステータスバー領域だけが見えるようになる。
    private let backgroundView = UIView(frame: .zero)
    /// 表示される画面の中身を配置するビュー。presentedViewになる。
    private let sheetView = SheetView(frame: .zero)

    /// 初期化
    /// - Parameters:
    ///   - presentedViewController: モーダルとして表示されるViewController
    ///   - presentingViewController: 表示元のViewController
    ///   - backgroundColor: アニメーション完了時の背景色
    init(presentedViewController: UIViewController,
         presenting presentingViewController: UIViewController?,
         backgroundColor: UIColor) {
        self.backgroundColor = backgroundColor
        super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
    }

    // 1
    override var presentedView: UIView? {
        sheetView
    }

    // 2
    override func presentationTransitionWillBegin() {
        guard let container = containerView else { return }
        // 色をつける背景のビューを配置
        backgroundView.frame = container.bounds
        container.insertSubview(backgroundView, at: 0)
        backgroundView.backgroundColor = .clear
        // 遷移先のビューをSheetViewの上に配置
        sheetView.addSubview(presentedViewController.view)
        // Auto Layoutを使うと、表示した画面の上に modalPresentationStyle = .fullScreen で別画面を表示し、
        // それを閉じたときに、元の画面の幅が0になって表示されなくなる問題があったため、autoresizingMaskにしている。
        presentedViewController.view.frame = sheetView.bounds
        presentedViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        // 画面を開くのに応じて背景に色をつける
        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
            self.backgroundView.backgroundColor = self.backgroundColor
        }, completion: nil)
    }

    // 3
    override func presentationTransitionDidEnd(_ completed: Bool) {
        // 表示がキャンセルされた場合
        if !completed {
            backgroundView.removeFromSuperview()
        }
    }

    // 4
    override func dismissalTransitionWillBegin() {
        // 画面が閉じるのに応じて背景の色を透明に戻す
        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
            self.backgroundView.backgroundColor = .clear
        }, completion: nil)
    }

    // 5
    override func dismissalTransitionDidEnd(_ completed: Bool) {
        // dismissが完了した場合
        if completed {
            backgroundView.removeFromSuperview()
        }
    }

    // 6
    /// containerViewの中に配置する表示すべき画面のframe
    override var frameOfPresentedViewInContainerView: CGRect {
        guard let containerView else { return .zero }
        // 上部 safe area の分、高さを減らして下にずらす
        return CGRect(
            x: 0,
            y: containerView.safeAreaInsets.top,
            width: containerView.bounds.width,
            height: containerView.bounds.height - containerView.safeAreaInsets.top
        )
    }
}
  1. presentedView はデフォルトで遷移先画面のビューを返しますが、これをオーバーライドすることで自作のビューを表示対象とすることができます。今回は SheetView を表示対象のビューとしたいのでオーバーライドしています。これにより後述のAnimatorオブジェクトが参照する表示先のビューが SheetView になります。

  2. 画面遷移の開始時に presentationTransitionWillBegin() が呼ばれるので、ここで必要なビュー階層の構築と、アニメーションの実装を行います。UIViewControllerTransitionCoordinatoranimate(alongsideTransition:completion:) を使ってアニメーションを記述することで、画面遷移のアニメーションと同期させることができます。ここでは背景となるビューの色を透明から指定の色に変化させています。

  3. 画面表示のプロセスが終了すると presentationTransitionDidEnd(_:) が呼ばれます。このとき引数には実際に画面遷移が完了したかどうかのブール値が入っています。これが false の場合は画面遷移がキャンセルされているので、装飾のために配置したビューを片付けておきます。

  4. 同様に、画面が閉じられようとしたときに dismissalTransitionWillBegin() が呼ばれます。ここで背景の色を透明に戻すアニメーションを実装しています。

  5. 画面を閉じるプロセスが終了したタイミングで dismissalTransitionDidEnd(_:) が呼ばます。今度は引数が true のとき、つまりプロセスが正常に完了した場合に配置していたビューの後片付けをします。

  6. frameOfPresentedViewInContainerView では表示されるビューの最終的なframeを返します。ここではセーフエリア以外の領域を占めるようにしています。

UIPresentationController は画面の表示フェーズ、非表示フェーズの他に、画面表示中の回転による変化なども管理しますが、本記事では考慮しないこととします。

Animatorの実装

続いてAnimatorとして、UIViewControllerAnimatedTransitioning に適合するクラスを作り、アニメーションを実装します。

/// モーダルを開くアニメーションを担当するクラス
final class SlideInAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    /// アニメーション継続時間
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        0.3
    }

    /// アニメーションの実行
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let toVC = transitionContext.viewController(forKey: .to),
              let toView = transitionContext.view(forKey: .to) else {
            return
        }

        // 最終的なビューの座標
        let finalFrame = transitionContext.finalFrame(for: toVC)
        // 初期位置は画面下に隠れるように配置
        toView.frame = finalFrame.offsetBy(dx: 0, dy: finalFrame.height)
        transitionContext.containerView.addSubview(toView)

        // アニメーションが必要かどうかをチェック
        if transitionContext.isAnimated {
            UIView.animate(
                withDuration: 0.3,
                delay: 0,
                usingSpringWithDamping: 1,
                initialSpringVelocity: 0,
                animations: {
                    toView.frame = finalFrame
                },
                completion: { _ in
                    // 遷移がキャンセルされていた場合に後片付けをする
                    if transitionContext.transitionWasCancelled {
                        toView.removeFromSuperview()
                    }
                    // アニメーションの完了をUIKitに伝える
                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                }
            )
        } else {
            toView.frame = finalFrame
        }
    }
}

実装必須のメソッドは2つです。transitionDuration(using:) はアニメーションの長さを規定し、animateTransition(using:) でアニメーションを実装します。

transitionContext から表示元、表示先の画面の情報を参照できます。view(forKey: .to) は先述の CustomPresentationController で実装した presentedView、つまり SheetView を返します。コンテクストから取得した表示先のビューを表示元の containerView に対して addSubview し、最終的な表示位置まで動くようにアニメーションを記述します。

注意点としては transitionContext.isAnimated でアニメーションが必要かどうかチェックして、必要な場合のみ実行すること。また、アニメーションを実行した場合は完了時に completeTransition(_:) を呼んで、UIKitにアニメーションの完了を知らせる必要があります。

同様に、画面を閉じるときのアニメーションも実装します。

/// モーダルを閉じるアニメーションを担当するクラス
final class SlideOutAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        0.3
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let fromView = transitionContext.view(forKey: .from) else {
            return
        }

        let initialFrame = fromView.frame
        // 画面下に隠れる位置
        let finalFrame = initialFrame.offsetBy(dx: 0, dy: initialFrame.height)

        if transitionContext.isAnimated {
            UIView.animate(
                withDuration: 0.3,
                delay: 0,
                usingSpringWithDamping: 1,
                initialSpringVelocity: 0,
                animations: {
                    fromView.frame = finalFrame
                },
                completion: { _ in
                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                }
            )
        } else {
            fromView.frame = finalFrame
        }
    }
}

Transitioning Delegateの実装

Presentation ControllerとAnimatorの準備ができたら、最後にTransitioning Delegateを実装してこれらのオブジェクトをUIKitに渡せるようにします。ここでは表示元のビューコントローラーにdelegateを実装します。

extension ViewController: UIViewControllerTransitioningDelegate {
    /// Presentation Controllerを返す
    func presentationController(forPresented presented: UIViewController,
                                presenting: UIViewController?,
                                source: UIViewController) -> UIPresentationController? {
        let presentationController = CustomPresentationController(
            presentedViewController: presented,
            presenting: presenting,
            backgroundColor: UIColor.systemBlue
        )
        return presentationController
    }

    /// 開くとき用のAnimatorを返す
    func animationController(forPresented presented: UIViewController,
                             presenting: UIViewController,
                             source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        SlideInAnimator()
    }

    /// 閉じるとき用のAnimatorを返す
    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        SlideOutAnimator()
    }
}

そしてこのTransitioning Delegateを使ってカスタム画面遷移を実行します。これで基本的なカスタム画面遷移のアニメーションは完成です。

final class ViewController: UIViewController {
    @IBAction func open(_ sender: Any) {
        let viewController = ChildViewController()
        let navigationController = UINavigationController(rootViewController: viewController)
        navigationController.modalPresentationStyle = .custom
        navigationController.transitioningDelegate = self
        present(navigationController, animated: true)
    }
}

final class ChildViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(close))
    }

    @objc func close() {
        dismiss(animated: true, completion: nil)
    }
}

インタラクティブなアニメーション実装

次のステップとして、開いた画面を下にドラッグしたとき、ジェスチャーに応じて画面を閉じられるようにします。

インタラクティブな画面遷移アニメーション

Presentation Controllerの修正

まず、先ほど実装した CustomPresentationController でGesture Recognizerを追加し、ドラッグ操作をAnimatorに伝えるための実装をします。

final class CustomPresentationController: UIPresentationController {
    //...

    /// ドラッグで閉じるためのジェスチャー
    private lazy var panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
    /// ドラッグで閉じるための InteractiveTransition
    private(set) var interactiveTransition: UIPercentDrivenInteractiveTransition?

    //...

    override func presentationTransitionWillBegin() {
        //...
        presentedViewController.view.frame = sheetView.bounds
        presentedViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        // ドラッグで閉じるためのジェスチャーを追加
        presentedViewController.view.addGestureRecognizer(panGestureRecognizer)
        //...
    }

    //...

    /// ドラッグジェスチャーのハンドラー
    @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
        guard let container = containerView else { return }
        let translation = gesture.translation(in: container)
        let velocity = gesture.velocity(in: container)
        // 0〜1の範囲で進行度合いを算出
        let progress = min(max(translation.y / sheetView.frame.height, 0), 1)

        switch gesture.state {
        case .possible:
            break
        case .began:
            // InteractiveTransitionのインスタンスを作成してdismissを開始
            interactiveTransition = UIPercentDrivenInteractiveTransition()
            presentingViewController.dismiss(animated: true)
        case .changed:
            // 進行状態を更新
            interactiveTransition?.update(progress)
        case .ended:
            if velocity.y > 500 || progress > 0.3 {
                // 速度または移動距離の閾値を超えたら完了させる
                interactiveTransition?.finish()
            } else {
                // それ以外は画面遷移をキャンセル
                interactiveTransition?.cancel()
            }
            interactiveTransition = nil
        case .cancelled, .failed:
            interactiveTransition?.cancel()
            interactiveTransition = nil
        @unknown default:
            interactiveTransition?.cancel()
            interactiveTransition = nil
        }
    }
}

インタラクティブな画面遷移には UIViewControllerInteractiveTransitioning を実装したAnimatorが必要になりますが、そのまま使える具象クラスとして UIPercentDrivenInteractiveTransition が用意されています。ジェスチャーの進行状況に応じて update(_:), cancel(), finish() を呼ぶことによって画面の状態に反映します。

ジェスチャーの開始 began でAnimatorのインスタンスを生成して、dismissを実行します。changedupdate(_:) を呼び、Animatorに対して画面遷移の進行度合いを0〜1の範囲で伝えます。ended では状況に応じて finish()cancel() を呼んでいます。また、画面遷移が完了またはキャンセルされたときは interactiveTransition = nil にしてAnimatorのインスタンスを破棄しています。

Transitioning Delegateの修正

続いてTransitioning DelegateでAnimatorのインスタンスを返せるように修正します。

final class ViewController: UIViewController {
    weak var customPresentationController: CustomPresentationController?
    // ...
}

extension ViewController: UIViewControllerTransitioningDelegate {
    /// Presentation Controllerを返す
    func presentationController(forPresented presented: UIViewController,
                                presenting: UIViewController?,
                                source: UIViewController) -> UIPresentationController? {
        let presentationController = CustomPresentationController(
            presentedViewController: presented,
            presenting: presenting,
            backgroundColor: UIColor.systemBlue
        )
        customPresentationController = presentationController
        return presentationController
    }

    // ...

    /// ドラッグで閉じるための InteractiveTransitioning のインスタンスを返す
    func interactionControllerForDismissal(using animator: any UIViewControllerAnimatedTransitioning) -> (any UIViewControllerInteractiveTransitioning)? {
        customPresentationController?.interactiveTransition
    }
}

インタラクティブ遷移用のAnimatorは CustomPresentationController が持っているので、これを参照できるようにするため CustomPresentationController の参照を定義しています。Presentation Controllerは画面が表示されてから閉じられるまでの間、システム側に保持されるため、参照はweakにします。(UIPresentationControllerpresentedViewController として表示元のビューコントローラーを保持するので、強参照だと循環参照になります。)

Animatorの修正

最後に SlideOutAnimator を以下のように修正します。

/// モーダルを閉じるアニメーションを担当するクラス
final class SlideOutAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    // ...
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // ...
        if transitionContext.isAnimated {
            if transitionContext.isInteractive {
                // ドラッグで閉じる場合はアニメーションをlinearにすることで、手の動きに自然に追従するようにする
                UIView.animate(
                    withDuration: 0.3,
                    delay: 0,
                    options: .curveLinear,
                    animations: {
                        fromView.frame = finalFrame
                    },
                    completion: { _ in
                        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                    }
                )
            } else {
                UIView.animate(
                    withDuration: 0.3,
                    delay: 0,
                    usingSpringWithDamping: 1,
                    initialSpringVelocity: 0,
                    animations: {
                        fromView.frame = finalFrame
                    },
                    completion: { _ in
                        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                    }
                )
            }
        } else {
            fromView.frame = finalFrame
        }
    }
}

インタラクティブな画面遷移の場合でもアニメーション自体は UIViewControllerAnimatedTransitioning の実装が使われます。ここでアニメーションカーブがlinear以外だと、ドラッグしたときの指の移動距離とビューの移動距離がずれてしまうため、不自然な動きになってしまいます。これを防ぐために transitionContext.isInteractive をチェックしてインタラクティブな画面遷移ではアニメーションカーブをlinearにしています。

以上で画面をドラッグして閉じるインタラクティブな画面遷移の実装ができました。

TimeTree Tech Blog

Discussion