UINavigationController
など、ViewControllerをアニメーションさせながら切り替えるものがあります。
それらのアニメーションもカスタマイズできるようにUIKitでは色々準備してくれています。
ただ、アニメーションは込み入ったことになりやすく、UIKitで用意されているものも決して扱いやすいものではないようです。
実際、思った挙動を実装するのに、調査だけでだいぶ時間がかかりました。
作ろうとしたもの
今回作ろうと思ったのは、UINavigationControllerで管理されたViewControllerのアニメーションのうち、画面端からのスワイプで戻る際にナビゲーションバーの透明度を変更する、というものです。
下の動画のナビバー部分を見てください。スワイプ中に徐々に透明になっていくのが確認できると思います。
今回作ったサンプルはGitHubに上げておきました。
できるだけシンプルな実装になっているので、コードを見てもらえればある程度なにをしているのか分かるかと思います。
登場人物
実装するにあたって、利用するデリゲートとメソッドの多さに目眩がしました;
主に利用するデリゲートは以下の3つ。
ただ、3つ目はモーダルで表示したときに使うもので、UINavigationControllerのアニメーションに干渉するにはUINavigationControllerDelegate
を使う必要があります。
- UIViewControllerAnimatedTransitioning (アニメーションコントローラ)
- UIViewControllerContextTransitioning (画面遷移コンテキスト ※1)
- UIViewControllerTransitioningDelegate (画面遷移デリゲート ※2)
※1 画面遷移に必要な情報を管理するオブジェクト。
e.g.) 遷移元のビューと遷移先のビューなど。
※2 モーダルビュー表示の場合のデリゲート。UINavigationControllerなどの場合は別途別のデリゲートとなる。
UIViewControllerAnimatedTransitioning - アニメーションコントローラ
UIViewControllerAnimatedTransitioning
プロトコルは以下のふたつのメソッドを実装しなければなりません。
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext;
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext;
ここで渡されるUIViewControllerContextTransitioning
プロトコルを実装したtransitionContext
はシステムが用意するオブジェクトです。
画面遷移に関する様々な情報を管理するオブジェクトで、これを使って遷移を制御します。
transitionDuration:
これは、アニメーションがどれだけの時間かけて実行されるかを定義します。
通常は固定値を返すだけでいいと思いますが、例えばビューの構造が特定の場合だけ時間を倍にする、みたいなこともできます。
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext
{
return 1;
}
実際、サンプルではただたんに1
を返しています。
animateTransition:
アニメーションとして重要なのはこちらです。
このメソッドは、トランジションが開始される際に一度だけ呼ばれます。
UINavigationControllerの場合であればPushやPopなどを実行した際に呼ばれます。
そして引数にUIViewControllerContextTransitioning
プロトコルを実装したオブジェクトが渡されます。
このオブジェクトには様々なメソッドがあり、適宜情報を取り出してアニメーションのための準備を行います。
詳細はドキュメントをご覧ください。
ちなみに簡単にやれることを書くと、遷移元/遷移先のビューコントローラを取り出したり、初期のframe
を取り出したりといった情報から、遷移が終わったことをシステムに伝えるfinishInteractiveTransition:
メソッドなども実装されています。
トランジションを表現する舞台を整える
このメソッド内で行う大事な処理があります。
それは、これから行われるトランジションを表現するための舞台を整えることです。
まずはコードを見てもらったほうが早いでしょう。
/////////////////////////////////////////////////////////////////////////////
// Set up views for a transition.
// Get from/to view controllers in a context.
UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
// Get a container view in a context.
UIView *containerView = [transitionContext containerView];
// Constracting views for a transition animation.
[containerView insertSubview:toVC.view
belowSubview:fromVC.view];
// ...
上記がセットアップ部分です。
なにをしているかと言うと、コンテキストから 遷移元/遷移先のビューコントローラを取り出し、それぞれのビューを 「containerView」と呼ばれる特殊なビューにaddSubview:
しています。
こうすることで、ひとつのコンテナビュー上でふたつのビューのアニメーションが行われる、というわけです。
そのためのセットアップをここでしているわけです。
アニメーションをコントロールする
上記までで舞台が整いました。
最後に、アニメーション部分を実装します。
アニメーション自体は、基本的には通常のUIViewのアニメーションと同様にアニメーションさせれば問題ありません。
内部の実装にUIView#animateWithDuration:animations:
を利用しても構いませんし、CADisplayLink
などを使って自前でアニメーションを組んでも構いません。
今回のサンプルでは、画面端からのスワイプに応じてアニメーションさせたかったので、Panジェスチャの位置を利用してアニメーションさせるようにしています。
アニメーションの実装
実際の処理は以下のようにしました。
(メソッド名はUIViewControllerContextTransitioning
プロトコルにあるものと同一ですが、こちらは内部でtransitionContext
の同名のメソッドを使う任意のメソッドです)
/**
* Update interactive transition
*
* Update transition with `transitionContext`
*
* @param percent
*/
- (void)updateInteractiveTransition:(CGFloat)percent
{
NSLog(@"%s", __PRETTY_FUNCTION__);
[self.transitionContext updateInteractiveTransition:percent];
// Get view controllers(from/to) in context.
UIViewController *fromVC = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIViewController *toVC = [self.transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
// Do any transition with view controllers.
const CGRect initFrameFromVC = [self.transitionContext initialFrameForViewController:fromVC];
CGRect fromFrame = fromVC.view.frame;
fromFrame.origin.x = initFrameFromVC.size.width * percent;
fromVC.view.frame = fromFrame;
const CGFloat delta = initFrameFromVC.size.width / 3.5;
CGRect toFrame = toVC.view.frame;
toFrame.origin.x = -delta * MAX(0.0, (1.0 - percent));
toVC.view.frame = toFrame;
}
このメソッドは、Panジェスチャのイベントの内部から呼ばれる想定で、引数のpercent
に応じて位置を変更する、という実装になっています。
重要なポイントとして、内部で保持しているself.transitionContext
の同名のメソッドも実行して、 システムにもトランジションの進捗を伝えている 点です。
これを実行しないと、システムがどの程度トランジションが進んでいるのかを把握することができます。
例えばUINavigationControllerの場合、この進捗度合いによってナビバーのアイテムの透明度や位置を変更しますが、これが行われなくなります。
transitionContextの取得
さて、上記の処理はほぼすべて、transitionContext
を利用することで行っていました。
ではこのオブジェクトはいつ、どこで取得するのでしょうか。
実はここが躓いた点だったんですが、答えはUINavigationControllerDelegate
のプロトコルメソッドです。
プロトコル自体は、今回はUIViewController
のサブクラスに実装しました。
viewDidAppear:
のタイミングで以下のようにデリゲートを設定しています。
self.navigationController.delegate = self;
そして以下のふたつのプロトコルを実装します。
/**
* It'll be called when starting an animation of transition.
*/
- (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
animationControllerForOperation:(UINavigationControllerOperation)operation
fromViewController:(UIViewController *)fromVC
toViewController:(UIViewController *)toVC
{
NSLog(@"%s", __PRETTY_FUNCTION__);
if (operation == UINavigationControllerOperationPop) {
self.animationController = [FadeAnimationController create];
if (self.isGesture) {
[self.animationController startAsSwipe];
}
return self.animationController;
}
// To use by default.
return nil;
}
/////////////////////////////////////////////////////////////////////////////
- (id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController
interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController
{
NSLog(@"%s", __PRETTY_FUNCTION__);
return self.animationController;
}
上記ふたつのメソッドは関連しており、上のメソッドのあとに下のメソッドが続いて呼ばれます。
見てもらうと分かりますが、上のほうではUINavigationControllerOperation
がPopの場合だけ、今回作成したアニメーションコントローラを利用するようにしています。
(当然、Pushの場合にもカスタムのアニメーションを付与することができます)
ちなみにself.isGesture
を使って分岐しているのは、このメソッド自体はトランジションが発生した際に自動的に呼ばれるので、バックボタンなどを押した場合にも実行されます。
なので、スワイプでの開始なのか、ボタンからの開始なのかをここでハンドリングしているというわけです。
下のメソッドは実はよく分かっておらず、こうしたら動く、というものです;
ただ、このメソッドで、生成したアニメーションコントローラのクラスを返さないと、アニメーションコントローラのstartInteractiveTransition:
メソッドが呼ばれないため、定義したアニメーションが実行されません。
[2015.03.06追記]
ドキュメントには
Called to allow the delegate to return an interactive animator object for use during view controller transitions.
とありました。
つまり、ここで返したanimator objectを利用して遷移が行われるようです。
transitionContextの保持
しれっと書いたstartInteractiveTransition:
ですが、実は上記のメソッド内で戻り値として返すことで以下のメソッドが呼ばれます。
/**
* This is UIViewControllerInteractiveTransitioning protocol.
*
* It'll be called when a view controller is push / pop or anything like that are performed.
*
* @param transitionContext
*/
- (void)startInteractiveTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
NSLog(@"%s", __PRETTY_FUNCTION__);
self.transitionContext = transitionContext;
[self animateTransition:transitionContext];
}
そしてこのときの引数こそ、今回の記事の要であるtransitionContext
を取得するタイミングです。
ここで取得したtransitionContext
をここで保持しておきます。
あとは、「アニメーションの実装」部分で出てきたように、適切にコンテキストオブジェクトを利用してアニメーションを実装します。
後始末
さて、アニメーションの実装が出来ました。
が、まだいくつかやることがあります。それが後始末です。
トランジションアニメーション用に用意した舞台を終えなければなりません。
ただ、そうした処理のほとんどはシステム側で行ってくれます。
プログラマが行うのは、システムにトランジションが終了した/キャンセルされたことを伝えるだけです。
(ちなみにこれらも任意のメソッドです)
/**
* Canceling transition.
*
* Will be called it when transiton is canceled.
*
* This method perform two method on transitionContext are `cancelInteractiveTransition` and `completeTransition:`.
* Both methods are required to invoke when trainsition is canceled.
*
* IMPORTANT: `completeTransition:` method must be called after `cancelInteractiveTransition`.
*/
- (void)cancelInteractiveTransition
{
NSLog(@"%s", __PRETTY_FUNCTION__);
self.isSwipe = NO;
UIViewController *fromVC = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
CGRect initFrame = [self.transitionContext initialFrameForViewController:fromVC];
fromVC.view.frame = initFrame;
[self.transitionContext cancelInteractiveTransition];
[self.transitionContext completeTransition:NO];
}
/////////////////////////////////////////////////////////////////////////////
/**
* Finishing transition.
*
* This method perform two methods on a transitionContext are `finishInteractiveTransition` and `completeTransition:`.
* Both methods are required to invoke when a trainsition is finished.
*/
- (void)finishInteractiveTransition
{
NSLog(@"%s", __PRETTY_FUNCTION__);
self.isSwipe = NO;
UIViewController *toVC = [self.transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
CGRect frame = [self.transitionContext finalFrameForViewController:toVC];
toVC.view.frame = frame;
[self.transitionContext finishInteractiveTransition];
[self.transitionContext completeTransition:YES];
}
ここでの大事なポイントは以下の部分です。
// for canceling
[self.transitionContext cancelInteractiveTransition];
[self.transitionContext completeTransition:NO];
/////////////////////////////////////////////////////////////////////////////
// for finishing
[self.transitionContext finishInteractiveTransition];
[self.transitionContext completeTransition:YES];
それぞれキャンセル時と終了時のものです。
そしてどちらもcompleteTransition:
メソッドを実行していることに注意してください。
これはシステムに、完了状態を伝えるものです。
これを呼び出さないとシステム側はまだトランジション中と判断し、戻るボタンなどを押しても動作しなくなってしまいます。また呼び出す順番も重要なようです。
(最初、completeTransition:
を先に呼び出したら戻るボタンなどが動作しなくなった)
Panジェスチャで戻るを実装する
上記で、トランジションについての実装は終了です。
ただ今回はPanジェスチャを使ってアニメーションを行いたかったのでそれも実装しました。
(ちなみにUIScreenEdgePanGestureRecognizer
なんてジェスチャがあるの知らなかった)
ジェスチャを登録する
UIScreenEdgePanGestureRecognizer *pan = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
pan.edges = UIRectEdgeLeft;
pan.delegate = self;
[self.view addGestureRecognizer:pan];
ちなみにこの処理はViewControllerのviewDidLoad
内で実行しています。
ジェスチャのトラッキング
/**
* Pan Gesture's handler
*/
- (void)handlePan:(UIScreenEdgePanGestureRecognizer *)gesture
{
NSLog(@"%s", __PRETTY_FUNCTION__);
CGFloat width = gesture.view.frame.size.width;
static UINavigationController *navigationController;
if (gesture.state == UIGestureRecognizerStateBegan) {
self.isGesture = YES;
navigationController = self.navigationController;
[self.navigationController popViewControllerAnimated:YES];
}
else if (gesture.state == UIGestureRecognizerStateChanged) {
CGPoint translation = [gesture translationInView:gesture.view];
CGFloat percent = ABS(translation.x / width);
navigationController.navigationBar.alpha = 1.0 - percent;
[self.animationController updateInteractiveTransition:percent];
}
else if (gesture.state == UIGestureRecognizerStateEnded ||
gesture.state == UIGestureRecognizerStateCancelled) {
self.isGesture = NO;
CGPoint translation = [gesture translationInView:gesture.view];
CGPoint velocity = [gesture velocityInView:gesture.view];
CGFloat percent = MAX(0, translation.x + velocity.x * 0.25) / width;
if (percent < 0.5 || gesture.state == UIGestureRecognizerStateCancelled) {
[self.animationController cancelInteractiveTransition];
}
else {
[self.animationController finishInteractiveTransition];
}
}
}
スワイプ開始時に遷移開始を伝える
画面端をスワイプした際にトランジションに移行します。
移行させるには単純にpopViewControllerAnimated:
を実行するだけです。
スワイプ位置を取る
以降は普通のジェスチャのハンドリングと同じです。
スワイプ中の位置計算を行って、自身のself.animationController
に状態を通知し、アニメーションを実行してもらいます。
スワイプ終了時の位置に応じて、キャンセルさせるか完了させるかを分岐させています。
まとめ
以上が今回作成したサンプルの解説です。
理解してしまえばそこまでむずかしくない気もしますが、実際、どのプロトコルが必須で、どれがどのタイミングで呼ばれるのか、そしてそれをどう使えばいいのかが分からないところからのスタートだったのでだいぶ骨が折れました( ;´Д`)
が、これをうまく使っていけば色々な画面遷移を実装できそうです。
[2015.03.13 追記]
ハマったメモ
いくつかハマったことがあったのでメモ。
画面遷移中なのにスクロールできてしまう
今回のサンプルで、UIScreenEdgePanGestureRecognizer
を使ってインタラクティブなアクションに対して演出を入れる、というのをやりました。
実際の案件で使う際、このgestureのインスタンスを使いまわすようにしていたため、インタラクティブモードに移行した瞬間に、遷移先のビューにaddGesture:
をしていました。
が、こうすると現在のビューのジェスチャーが外れ、画面遷移中なのにスクロールできてしまう、というバグになりました。
なので、遷移が完了(キャンセル)したタイミングで、適切にaddGesture:
してやる必要があります。
(まぁレアケースかもしれませんが)
参考リンク
今回のサンプルを実装するにあたり参照した記事を列挙しておきます。
(多分、ここで書ききれてないものもあると思うので)