UIKit における画面遷移のカスタマイズ
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 の view 内部のアニメーションは animator (=
UIViewControllerAnimatedTransitioning
) によって管理されていて、 UIPresentationController はそれに対して外側の VC 自体のアニメーションを追加する? - 呼ばれる UIPresentationController のメソッド
- presentation phase
- dismissal phase
- VC の view 内部のアニメーションは animator (=
- サイズクラスの変化によって、 VC の表示方法を変えたいことがある
- 例 : horizontal が regular から compact になる時 popover は fullscreen に変化する
-
adaptivePresentationStyle(for:)
が呼ばれ、サイズクラスに適した UIModalPresentationStyle が返される
- サイズクラスの変化時に UIPresentationController の delegate の
presentationController(_:viewControllerForAdaptivePresentationStyle:)
も呼ばれ、ここで UIPresentationController の変更もできる- インスタンス自体が入れ替わることもある
- そのため、常に VC の presentationController プロパティから UIPresentationController を取得する必要があることに注意
- インスタンス自体が入れ替わることもある
- サイズクラスの変化時に UIPresentationController のカスタム view を変更する際には、
viewWillTransition(to:with:)
が呼ばれるのでその中で行う- このメソッドは UIViewController でも呼ばれる
- カスタムの UIPresentationController を作りたい時にはサブクラスを作成する
- 初期化時には
init(presentedViewController:presenting:)
を呼ぶ必要がある - override するメソッド
-
presentationTransitionWillBegin()
: 遷移アニメーションのために利用するカスタムの view を view hierarchy に追加する -
presentationTransitionDidEnd(_:)
: ^ の後片付けを行う。とくに、遷移が中止されたときにカスタム view を取り除くする必要があることに注意 -
dismissalTransitionWillBegin()
/dismissalTransitionDidEnd(_:)
: dismiss アニメーションに関わる処理を行う。カスタム view の削除は DidEnd の方でやる必要があることに注意 -
viewWillTransition(to:with:)
: サイズクラスの変化に合わせてカスタム view になんらかの変更を加える -
shouldPresentInFullscreen
/frameOfPresentedViewInContainerView
: 表示される VC のサイズを決める
-
- 初期化時には
- まだカスタムの view を追加することで遷移をカスタマイズするというイメージができていない。blur などのこと?
- delegate が2つ出てくるので、位置付けを整理する必要あり
- UIViewControllerTransitioningDelegate
- UIAdaptivePresentationControllerDelegate
UIPresentationControllerを知る - shiba1014.medium
- UIPresentationController はアラートやハーフモーダルなどの UI 実装に使える
- 遷移アニメーションのためのカスタム view は通常 presentation / dismissal phase で同じものを使うため、 VC が表示されている間は view hierarchy に追加したままにしておく
- VC のサイズとして、 size と frame をそれぞれ返す。冗長に思えるが、それぞれ別の箇所から利用されることもあるので両方必要
-
containerViewWillLayoutSubviews()
でレイアウト関連の変更を行う。特に、 presentedView.frame に frameOfPresentedViewInContainerView を指定する- frameOfPresentedViewInContainerView はあくまで遷移アニメーションの際に参照される値なので、サイズクラスが変更された際は presentedView に値を反映する必要がある
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)
}
}
UIPresentationController
のプロパティ
presentingController
presentedController
-
presentedView
: 表示される view。presentedController.view
と一致する? -
containerView
: 表示される view のコンテナ。presentedView.superview
と一致する?
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
メソッドを実装する
- presentation の方法だけでなく、 presentation controller 自体を入れ替えたい場合は
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")
}
}
カスタムアニメーションによるView Controllerの遷移 - shiba1014.medium
-
UIPresentationController
は遷移中に追加するカスタムビューについて記述するが、UIViewControllerAnimatedTransitioning
は遷移元や遷移先の view controller のアニメーションを記述すると考えると役割がわかりやすい -
animateTransition(using:)
において、引数のtransitionContext.isAnimated
がfalse
ならばアニメーションさせるべきではない -
transitionContext.completeTransition
に渡す引数は!transitionContext.transitionContext.transitionWasCancelled
にするのが良いらしい-
UIView.animate
のcompletion
の引数をそのまま渡しがちだが、これはUIView.animate
が完了したかのフラグであり遷移が完了したかはわからない
-
- present のアニメーションにおいて
containerView
に遷移先の view を追加するが、UIView.animate
の completion にてtransitionContext.transitionContext.transitionWasCancelled
がtrue
ならこれを取り除く必要がある- (記事では dismiss のアニメーションにおいてこれを行なっているがサンプルコードのミス?)
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 が画面上にいる間の表示のスタイルを管理する
- transitioning delegate :
-
表示する 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(_:)
- 自分で実装することは稀?
- インタラクティブでないアニメーションについては animator の
- 5: UIKit は animator オブジェクトが context transitioning の
completeTransition(_:)
を呼ぶのを待つ。開発者はアニメーションが終了した時点でこのメソッドを呼ぶ必要があり、 UIKit はメソッドが呼ばれたことで遷移が完了し、present
メソッドのcompletion
や animator のanimationEnded(_:)
を呼んでいいことを知る- completeTransition を呼ばないとアプリ側に制御が返ってこない
- 1: UIKit が transitioning delegate の
-
遷移のアニメーション開始前に、 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
を必ず呼ぶこと
- 1:
-
dismissal のアニメーションも同じような流れで、アニメーション完了時に "from" の view を container view から削除する必要があることのみ注意
-
アニメーション完了後に context の
completionTransition
メソッドを忘れずに呼ぶこと- UIKit にユーザが presented view controller を利用できるようになったことを伝える
- 他の completion メソッドが呼ばれるきっかけとなる
-
present
のcompletion
- animator の
animationEnded
-
-
completionTransition
に渡す引数はtransitionWasCancelled
が適切なようだ
-
transitionWasCancelled
の値がtrue
であれば、それ相応の後処理が必要となる -
インタラクティブなアニメーションのためには
UIPercentDrivenInteractiveTransition
を使うのが簡単- 既存の animator と協調して動作し、アニメーションのタイミング・進行具合をコントロールする役割を持つ
- コードから完了のパーセンテージを渡す必要がある
-
UIPercentDrivenInteractiveTreansition
はサブクラス化してもいいし、せずにそのまま使ってもいい -
インタラクティブなアニメーションは、ユーザの操作とアニメーションが同期するように完全にリニアなアニメーションにするのがいい
- 初期速度やバネのエフェクト、非線形の completion curve は避ける