Open7

UIKit における画面遷移のカスタマイズ

maiyamamaiyama

UIPresentationController - Apple

  • VC が表示されてから非表示になるまで、 UIKit は UIPresentationController を使ってその VC の表示のいろいろな側面を管理している
    • 例えば present メソッドで VC を表示する時も、 dismiss されるまで UIPresentationController が表示を管理している
    • カスタムの UIPresentationController を使うことができるのは、 modelPresentationStyle を .custom にした時のみ
    • カスタムの UIPresentationController は delegate を通じて使う

  • UIPresentationController が管理する表示プロセスは3つのフェーズに分かれる
    • presentation phase : アニメーションとともに新しい VC を画面に追加する
    • management phase : VC が画面表示されている間、デバイスの回転など環境の変化に対応する
    • dismissal phase : アニメーションとともに VC を画面から削除する
  • presentation / dismissal phase においては UIPresentationController はカスタムの view を view hierarchy に追加し、それらにアニメーションをつけることができる

  • サイズクラスの変化によって、 VC の表示方法を変えたいことがある
    • 例 : horizontal が regular から compact になる時 popover は fullscreen に変化する
    • adaptivePresentationStyle(for:) が呼ばれ、サイズクラスに適した UIModalPresentationStyle が返される
  • サイズクラスの変化時に UIPresentationController の delegate の presentationController(_:viewControllerForAdaptivePresentationStyle:) も呼ばれ、ここで UIPresentationController の変更もできる
    • インスタンス自体が入れ替わることもある
      • そのため、常に VC の presentationController プロパティから UIPresentationController を取得する必要があることに注意
  • サイズクラスの変化時に UIPresentationController のカスタム view を変更する際には、 viewWillTransition(to:with:) が呼ばれるのでその中で行う
    • このメソッドは UIViewController でも呼ばれる


  • まだカスタムの view を追加することで遷移をカスタマイズするというイメージができていない。blur などのこと?
  • delegate が2つ出てくるので、位置付けを整理する必要あり
    • UIViewControllerTransitioningDelegate
    • UIAdaptivePresentationControllerDelegate
maiyamamaiyama

UIPresentationControllerを知る - shiba1014.medium

  • UIPresentationController はアラートやハーフモーダルなどの UI 実装に使える
  • 遷移アニメーションのためのカスタム view は通常 presentation / dismissal phase で同じものを使うため、 VC が表示されている間は view hierarchy に追加したままにしておく

  • VC のサイズとして、 size と frame をそれぞれ返す。冗長に思えるが、それぞれ別の箇所から利用されることもあるので両方必要
  • containerViewWillLayoutSubviews() でレイアウト関連の変更を行う。特に、 presentedView.frame に frameOfPresentedViewInContainerView を指定する
    • frameOfPresentedViewInContainerView はあくまで遷移アニメーションの際に参照される値なので、サイズクラスが変更された際は presentedView に値を反映する必要がある
maiyamamaiyama

^ のサンプルコードを参考に書いた UIPresentationController の例

RootViewController.swift
final class RootViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemBackground

        let button = UIButton(
            configuration: .plain(),
            primaryAction: .init(
                title: "Present",
                handler: { [weak self] _ in
                    let alertVC = CustomAlertViewController()
                    let navigationVC = UINavigationController(rootViewController: alertVC)
                    navigationVC.modalPresentationStyle = .custom
                    navigationVC.transitioningDelegate = alertVC
                    self?.present(navigationVC, animated: true)
                }
            )
        )
        view.addSubview(button)
        button.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }
}
TwoThirdsSizePresentationController.swift
final class TwoThirdsSizePresentationController: UIPresentationController {
    private let dimmedView: UIView = {
        let view = UIView()
        view.backgroundColor = .gray
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    override func presentationTransitionWillBegin() {
        guard let containerView else { return }

        dimmedView.alpha = 0
        containerView.insertSubview(dimmedView, at: 0)
        NSLayoutConstraint.activate([
            dimmedView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
            dimmedView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
            dimmedView.topAnchor.constraint(equalTo: containerView.topAnchor),
            dimmedView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
        ])

        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
            self.dimmedView.alpha = 0.5
        })
    }

    override func presentationTransitionDidEnd(_ completed: Bool) {
        if !completed {
            dimmedView.removeFromSuperview()
        }
    }

    override func dismissalTransitionWillBegin() {
        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
            self.dimmedView.alpha = 0
        })
    }

    override func dismissalTransitionDidEnd(_ completed: Bool) {
        if completed {
            dimmedView.removeFromSuperview()
        }
    }

    override func size(forChildContentContainer container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
        .init(width: parentSize.width * (2.0 / 3.0), height: parentSize.height * (2.0 / 3.0))
    }

    override var frameOfPresentedViewInContainerView: CGRect {
        guard let containerViewBounds = containerView?.bounds else { return .zero }

        let frame: CGRect = {
            var frame = CGRect.zero

            frame.size = size(forChildContentContainer: presentedViewController, withParentContainerSize: containerViewBounds.size)

            frame.origin.x = (containerViewBounds.size.width - frame.size.width) / 2
            frame.origin.y = (containerViewBounds.size.height - frame.size.height) / 2

            return frame
        }()

        return frame
    }
    // 画面回転のハンドリング
    // viewWillTransition でやると、引数に渡ってくる size は回転後の値だが frameOfPresentedViewInContainerView が回転前の値になってしまうのでここでハンドリングしている
    override func containerViewWillLayoutSubviews() {
        presentedView?.frame = frameOfPresentedViewInContainerView
    }
}
CustomAlertViewController.swift
final class CustomAlertViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemBackground

        let label = UILabel()
        label.text = "Presented"
        view.addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])

        navigationItem.leftBarButtonItem = .init(primaryAction: .init(title: "Cancel", handler: { [weak self] _ in
            self?.dismiss(animated: true)
        }))
    }
}

extension CustomAlertViewController: UIViewControllerTransitioningDelegate {
    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
        TwoThirdsSizePresentationController(presentedViewController: presented, presenting: presenting)
    }
}
maiyamamaiyama

UIPresentationController のプロパティ

  • presentingController
  • presentedController
  • presentedView : 表示される view。 presentedController.view と一致する?
  • containerView : 表示される view のコンテナ。 presentedView.superview と一致する?
maiyamamaiyama

UIPresentationController Tutorial: Getting Started

  • present を呼んだ時に iOS がやること

    • UIPresentationController をインスタンス化する
    • presented view controller をアタッチする
    • presented view controller を組み込みの modal で表示する
  • UIPresentationController をカスタムすることで表示方法を変更することができる

  • presented view controller は UIViewControllerTransitioningDelegate を持つ。この transitioning delegate が、 UIPresentationController や遷移時の animation controller (UIViewControllerAnimatedTransitioning) の生成に責任を持つ

  • presentation controller は presentation / dismissal / present 中の presented controller の表示に責任を持ち、カスタムの view を追加したり presented controller のサイズを決めたりすることができる

  • animation controller は presentation / dismissal 時のアニメーションに責任を持つ

    • presentation / dismissal に対してそれぞれ異なる controller を利用することも可能
  • trait collection が変化した際に presentation controller の delegate (UIAdaptivePresentationControllerDelegate) のメソッドを利用することができる

  • 別の概念として UIViewControllerTransitionCoordinator も存在し、画面遷移中の presented view controller の transitionCoordinator から取得できる

    • 遷移と共になんらかのアニメーションを行う animate(alongsideTransition:) メソッドが利用できる
  • UIViewControllerAnimatedTransitioning が要求するメソッドは2つ

    • transitionDuration(using:)
    • animateTransition(using:)
import UIKit

final class SlideInPresentationAnimator: NSObject {
  let direction: PresentationDirection

  /// presentation なら true / dismissal なら false
  /// presentation と dismissal に異なる AnimatedTransitioning を利用することもあるが、
  /// 同じものを使い回す場合このようなフラグが必要になる
  let isPresentation: Bool

  init(direction: PresentationDirection, isPresentation: Bool) {
    self.direction = direction
    self.isPresentation = isPresentation
    super.init()
  }
}

extension SlideInPresentationAnimator: UIViewControllerAnimatedTransitioning {
  func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    0.3
  }

  func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    // アニメーション対象の view controller を取得する
    let key: UITransitionContextViewControllerKey = isPresentation ? .to : .from
    guard let controller = transitionContext.viewController(forKey: key) else {
      return
    }

    // presentation の場合は view を hierarchy に追加する
    if isPresentation {
      transitionContext.containerView.addSubview(controller.view)
    }

    // アニメーションの初期状態と終了状態それぞれにおける frame を計算する
    let presentedFrame = transitionContext.finalFrame(for: controller)
    var dismissedFrame = presentedFrame
    switch direction {
    case .left:
      dismissedFrame.origin.x = -presentedFrame.width
    case .right:
      dismissedFrame.origin.x = transitionContext.containerView.frame.size.width
    case .top:
      dismissedFrame.origin.y = -presentedFrame.height
    case .bottom:
      dismissedFrame.origin.y = transitionContext.containerView.frame.size.height
    }

    let initialFrame = isPresentation ? dismissedFrame : presentedFrame
    let finalFrame = isPresentation ? presentedFrame : dismissedFrame

    // initialFrame から finalFrame までのアニメーションを実行する
    let animationDuration = transitionDuration(using: transitionContext)
    controller.view.frame = initialFrame
    UIView.animate(
      withDuration: animationDuration,
      animations: {
        controller.view.frame = finalFrame
      },
      completion: { finished in
        // dismissal の場合は view を hierarchy から取り除く
        if !self.isPresentation {
          controller.view.removeFromSuperview()
        }
        transitionContext.completeTransition(finished)
      }
    )
  }
}
  • traitCollection によって presentation の方法を変えたい場合は UIAdaptivePresentationControllerDelegate を作成して adaptivePresentationStyle メソッドを実装し、 presentation controller に delegate としてセットする
    • presentation の方法だけでなく、 presentation controller 自体を入れ替えたい場合は presentationController メソッドを実装する
extension SlideInPresentationManager: UIViewControllerTransitioningDelegate {
  func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
    let presentationController = SlideInPresentationController(
      presentedViewController: presented,
      presentingViewController: presenting,
      direction: direction
    )
    presentationController.delegate = self
    return presentationController
  }
}

extension SlideInPresentationManager: UIAdaptivePresentationControllerDelegate {
  func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
    if traitCollection.verticalSizeClass == .compact {
      // presentation controller を上書きして overFullScreen で表示する
      return .overFullScreen
    } else {
      // presentation controller の指定をそのまま利用する
      return .none
    }
  }

  /// presentation controller のインスタンスを入れ替える
  func presentationController(_ controller: UIPresentationController, viewControllerForAdaptivePresentationStyle style: UIModalPresentationStyle) -> UIViewController? {
    // presentation controller を上書きしない
    guard case .overFullScreen = style else { return nil }
    // presentation controller を上書きする
    return UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier: "RotateViewController")
  }
}
maiyamamaiyama

カスタムアニメーションによるView Controllerの遷移 - shiba1014.medium

  • UIPresentationController は遷移中に追加するカスタムビューについて記述するが、 UIViewControllerAnimatedTransitioning は遷移元や遷移先の view controller のアニメーションを記述すると考えると役割がわかりやすい
  • animateTransition(using:) において、引数の transitionContext.isAnimatedfalse ならばアニメーションさせるべきではない
  • transitionContext.completeTransition に渡す引数は !transitionContext.transitionContext.transitionWasCancelled にするのが良いらしい
    • UIView.animatecompletion の引数をそのまま渡しがちだが、これは UIView.animate が完了したかのフラグであり遷移が完了したかはわからない
  • present のアニメーションにおいて containerView に遷移先の view を追加するが、 UIView.animate の completion にて transitionContext.transitionContext.transitionWasCancelledtrue ならこれを取り除く必要がある
    • (記事では dismiss のアニメーションにおいてこれを行なっているがサンプルコードのミス?)
maiyamamaiyama

Customizing the Transition Animations

  • 遷移には presentation と dismissal の2種類がある

    • presentation は新しい1つの view controller を hierarchy に追加し、 dismissal は1つもしくは複数の view controller を hierarchy から取り除く
  • 遷移のアニメーションには多くのオブジェクトが登場するが、 UIKit はデフォルトの遷移に関しては全てのオブジェクトをよしなに実装してくれている

    • カスタマイズしたい場合はそれらのサブセットを実装すれば良い
  • 登場人物

    • transitioning delegate : UIViewControllerTransitioningDelegate
      • view controller の遷移のアニメーションと表示中のスタイルを仕切る管理役
      • 実体としての処理は持たず、どの部分をどのオブジェクトに任せるかのみを指定する
    • animator : UIViewControllerAniamtedTransitioning
      • view controller の presentation / dismissal のアニメーションを記述する
      • presentation / dismissal それぞれに異なるインスタンスを提供することが可能
    • interactive animator : UIViewControllerInteractiveTransitioning
      • タッチやジェスチャーを認識してインタラクティブにアニメーションのタイミングを決める
      • UIPercentDrivenInteractiveTransition をサブクラス化するのがパターン
    • presentation controller : UIPresentationController
      • view controller が画面上にいる間の表示のスタイルを管理する
  • 表示する view controller に transitioning delegate を指定することでカスタムな遷移をしたいという意思表示になる

    • 具体的にどの部分をカスタムしたいかは実装される delegate メソッドによって異なる

![[Pasted image 20230514210055.png]]

  • 遷移時のアニメーションの流れ

    • 1: UIKit が transitioning delegate の animationController(forPresented:presenting:source:) を呼び、 animator を取得する
    • 2: UIKit が transitioning delegate の interactionControllerForPresentation(using:) を呼び、 interactive animator を取得する。もし返り値が nil であれば UIKit はユーザインタラクションなしのアニメーションを行う
      • state を見て nil / not-nil を返し分けていたのはこういう意味があった!つまり、 v をタップ時にはインタラクティブでないアニメーションをしてほしいのであえて nil を返す必要がある
    • 3: UIKit が animator の transitionDuration(using:) メソッドを呼んで duration を取得する。例えば navigation controller の push の duration に使われる
      • システムアニメーションと自分で記述するカスタムアニメーションの動作を合わせるために、カスタムアニメーションの duration としてもこのメソッドの返り値を使う必要がある
    • 4: UIKit がアニメーションを開始するための適切なメソッドを呼ぶ
      • インタラクティブでないアニメーションについては animator の animateTransition(using:)
      • インタラクティブなアニメーションについては interactive animator の startInteractiveTransition(_:)
        • 自分で実装することは稀?
    • 5: UIKit は animator オブジェクトが context transitioning の completeTransition(_:) を呼ぶのを待つ。開発者はアニメーションが終了した時点でこのメソッドを呼ぶ必要があり、 UIKit はメソッドが呼ばれたことで遷移が完了し、 present メソッドの completion や animator の animationEnded(_:) を呼んでいいことを知る
      • completeTransition を呼ばないとアプリ側に制御が返ってこない
  • 遷移のアニメーション開始前に、 UIKit が UIViewControllerContextTransitioning プロトコルに準拠している transitioning context を作成し、アニメーションに必要な遷移に関する情報で埋めてくれている

    • 例えば、
      • 遷移に関わる view / view controller への参照を持っている
      • アニメーションがインタラクティブかどうかは isInteractive でわかる
    • animator は context が提供する情報をもとにアニメーションを実行する
  • animator は、他のキャッシュした情報ではなく常に context が提供する情報を使わなければいけない

    • 遷移はさまざまな条件下で起こるため自前でキャッシュした情報は古くなっていることがあり得るが、 context は常にアニメーションに必要な正しい情報を提供してくれる

![[Pasted image 20230514214033.png]]

  • アニメーションは UIKit が context を通じて提供する containerView を利用して行うこと

    • 例えば、 present する view controller の view は containerView の subView として追加しなければいけない
    • containerView は window かもしれないし、 view かもしれない
  • context が提供するもの

    • viewControllerForKey メソッドで "from" / "to" それぞれの view controller を取得する
    • containerView メソッドでアニメーションの間の superview を取得する。アニメーションのために追加する view のすべてと、 presented view controller の view はこの containerView に addSubview すること
    • viewForKey メソッドで追加 / 削除される view を取得する。遷移においては view controller の rview 以外にも presentation controller が追加する view などが存在しうるが、このメソッドでは追加 / 削除される view の root view を取得してくれるらしい
    • finalFrameForViewController で追加 / 削除される view controller の遷移完了時の frame を取得する
  • "from" と "to" について、常に "from" は遷移前に画面表示されている方、 "to" は遷移後に画面表示されている方の view controller を表していることに注意

  • 組み込みでもカスタムでも、画面遷移の間は transition coordinator が遷移のアニメーションのために生成される

    • present / dismiss 以外に画面回転や画面の frame の変化なども遷移と捉えられる
    • transition coordinator には、遷移している間の view controller の transitionCoordinator プロパティからアクセスできる

![[Pasted image 20230515062627.png]]

  • 典型的な presentation アニメーションの流れ

    • 1: view(Controller)ForKey を使って "from" と "to" それぞれの view controller / view を取得する
    • 2: "to" の view をアニメーションの開始位置に配置する
    • 3: finalFrameForViewController を使って "to" の view の終了位置を取得しておく
    • 4: "to" の view を container view に追加する
    • 5: "to" の view を終了位置まで移動するアニメーションを実行する。 completion で completeTreansition を必ず呼ぶこと
  • dismissal のアニメーションも同じような流れで、アニメーション完了時に "from" の view を container view から削除する必要があることのみ注意

  • アニメーション完了後に context の completionTransition メソッドを忘れずに呼ぶこと

    • UIKit にユーザが presented view controller を利用できるようになったことを伝える
    • 他の completion メソッドが呼ばれるきっかけとなる
      • presentcompletion
      • animator の animationEnded
    • completionTransition に渡す引数は transitionWasCancelled が適切なようだ
  • transitionWasCancelled の値が true であれば、それ相応の後処理が必要となる

  • インタラクティブなアニメーションのためには UIPercentDrivenInteractiveTransition を使うのが簡単

    • 既存の animator と協調して動作し、アニメーションのタイミング・進行具合をコントロールする役割を持つ
    • コードから完了のパーセンテージを渡す必要がある
  • UIPercentDrivenInteractiveTreansition はサブクラス化してもいいし、せずにそのまま使ってもいい

  • インタラクティブなアニメーションは、ユーザの操作とアニメーションが同期するように完全にリニアなアニメーションにするのがいい

    • 初期速度やバネのエフェクト、非線形の completion curve は避ける