87
87

More than 5 years have passed since last update.

[Objective-C] UIViewControllerの遷移をカスタマイズする

Last updated at Posted at 2015-03-13

UINavigationControllerなど、ViewControllerをアニメーションさせながら切り替えるものがあります。
それらのアニメーションもカスタマイズできるようにUIKitでは色々準備してくれています。

ただ、アニメーションは込み入ったことになりやすく、UIKitで用意されているものも決して扱いやすいものではないようです。
実際、思った挙動を実装するのに、調査だけでだいぶ時間がかかりました。

作ろうとしたもの

今回作ろうと思ったのは、UINavigationControllerで管理されたViewControllerのアニメーションのうち、画面端からのスワイプで戻る際にナビゲーションバーの透明度を変更する、というものです。
下の動画のナビバー部分を見てください。スワイプ中に徐々に透明になっていくのが確認できると思います。

animation-sample.gif

今回作ったサンプルは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:

これは、アニメーションがどれだけの時間かけて実行されるかを定義します。
通常は固定値を返すだけでいいと思いますが、例えばビューの構造が特定の場合だけ時間を倍にする、みたいなこともできます。

transitionDuration
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext
{
    return 1;
}

実際、サンプルではただたんに1を返しています。

animateTransition:

アニメーションとして重要なのはこちらです。
このメソッドは、トランジションが開始される際に一度だけ呼ばれます。
UINavigationControllerの場合であればPushやPopなどを実行した際に呼ばれます。

そして引数にUIViewControllerContextTransitioningプロトコルを実装したオブジェクトが渡されます。
このオブジェクトには様々なメソッドがあり、適宜情報を取り出してアニメーションのための準備を行います。

詳細はドキュメントをご覧ください。

ちなみに簡単にやれることを書くと、遷移元/遷移先のビューコントローラを取り出したり、初期のframeを取り出したりといった情報から、遷移が終わったことをシステムに伝えるfinishInteractiveTransition:メソッドなども実装されています。

トランジションを表現する舞台を整える

このメソッド内で行う大事な処理があります。
それは、これから行われるトランジションを表現するための舞台を整えることです。

まずはコードを見てもらったほうが早いでしょう。

setup-transition-views
/////////////////////////////////////////////////////////////////////////////
// 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-animation
/**
 *  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:ですが、実は上記のメソッド内で戻り値として返すことで以下のメソッドが呼ばれます。

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をここで保持しておきます。

あとは、「アニメーションの実装」部分で出てきたように、適切にコンテキストオブジェクトを利用してアニメーションを実装します。

後始末

さて、アニメーションの実装が出来ました。
が、まだいくつかやることがあります。それが後始末です。

トランジションアニメーション用に用意した舞台を終えなければなりません。
ただ、そうした処理のほとんどはシステム側で行ってくれます。
プログラマが行うのは、システムにトランジションが終了した/キャンセルされたことを伝えるだけです。
(ちなみにこれらも任意のメソッドです)

finish/cancel-transition
/**
 *  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];
}

ここでの大事なポイントは以下の部分です。

important-things
// for canceling
[self.transitionContext cancelInteractiveTransition];
[self.transitionContext completeTransition:NO];

/////////////////////////////////////////////////////////////////////////////

// for finishing
[self.transitionContext finishInteractiveTransition];
[self.transitionContext completeTransition:YES];

それぞれキャンセル時と終了時のものです。
そしてどちらもcompleteTransition:メソッドを実行していることに注意してください。
これはシステムに、完了状態を伝えるものです。

これを呼び出さないとシステム側はまだトランジション中と判断し、戻るボタンなどを押しても動作しなくなってしまいます。また呼び出す順番も重要なようです。
(最初、completeTransition:を先に呼び出したら戻るボタンなどが動作しなくなった)

Panジェスチャで戻るを実装する

上記で、トランジションについての実装は終了です。
ただ今回はPanジェスチャを使ってアニメーションを行いたかったのでそれも実装しました。
(ちなみにUIScreenEdgePanGestureRecognizerなんてジェスチャがあるの知らなかった)

ジェスチャを登録する

register-gesture
UIScreenEdgePanGestureRecognizer *pan = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
pan.edges    = UIRectEdgeLeft;
pan.delegate = self;
[self.view addGestureRecognizer:pan];

ちなみにこの処理はViewControllerのviewDidLoad内で実行しています。

ジェスチャのトラッキング

traking-gesture
/**
 *  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:してやる必要があります。
(まぁレアケースかもしれませんが)

参考リンク

今回のサンプルを実装するにあたり参照した記事を列挙しておきます。
(多分、ここで書ききれてないものもあると思うので)

87
87
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
87
87