這次會有機會碰到交互動畫,是因為專案當中,有個Presented 的畫面,左上角做了一個Dismissed 的按鈕,但他卻是一般Push 的back 按鈕,使用上UX 真的很不舒服,因此嘗試做了Presented 的動畫改變,並增加Gesture 來增加UX。

靈感來源是Facebook,下圖是Facebook 在Presented 自己的WebView 時所實作的交互動畫:

這次範例的專案完整的上傳到GitHub,若您對本文有興趣,歡迎先把專案下載下來邊讀邊玩。

Step 1. 建立專案與原生Present

首先,我會先建立專案,其中包含

  1. CPAPresentingViewController
  2. CPAPresentedViewController

Presenting為正在Present 別的ViewController 的人,Presented則是被Presented 的人,夠簡單明瞭吧。

我在CPAPresentingViewController 中加了兩個按鈕,一個將會展示系統預設的動畫,一種則是我們所自定義的動畫。

原生Presented

Custom Presented

Step 2. 建立Transition delegate

CPATransitioningDelegate是一個實作UIViewControllerTransitioningDelegate Protocol的Class,在這個階段,我們實作以下兩個方法來告訴UIViewController 在進行Presented and Dismissed 時需要用的動畫。

- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed {
    self.animationController.isDismiss = YES;

    return self.animationController;
}

- (id <UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source {
    self.animationController.isDismiss = NO;

    return self.animationController;
}

其中,animationController property 是一個實作UIViewControllerAnimatedTransitioning Protocol 的Class,實作內容再下一節會提到。

由於我們共用Presented and Dismissed 的transition animator,因此我們需要一個Flag 來讓animator 知道現在是Presented 或是Dismissed。

Step 3. 建立Animation controller

上一節,我們只是將轉場動畫的Delegate 轉給我們自己實作的Class,在這一節,將會提到整個動畫的核心,從規劃到實作,並盡可能講解我所知道的內容。

CPAAnimationController 是一個實作UIViewControllerAnimatedTransitioning Protocol 的Class,其中我們實作以下兩個Method:

- (NSTimeInterval)transitionDuration:(nullable id <UIViewControllerContextTransitioning>)transitionContext {
    return .7;
}

第一個Method 很簡單,就是整個動畫的過程中,你的秒數是幾秒。

- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext {
    // 1. Init some var for transtion

    UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    CGRect finalFrameForToView = [transitionContext finalFrameForViewController:toViewController];
    UIView *containerView = transitionContext.containerView;
    CGRect bounds = [UIScreen mainScreen].bounds;

    // 2. Add toView to containerView

    [containerView addSubview:toViewController.view];

    // 3. Before animation, setup some value to staring point.

    if (!self.isDismiss) {
        // 3 - 1. Setting a offset for toView when present a view

        toViewController.view.frame = CGRectOffset(finalFrameForToView,bounds.size.width, 0);
    } else {
        // 3 - 2. Setting toView's frame to finalFrame, alpha to 0.5, and toView's transform to 0.96x, finally, send it to back in containerView.

        toViewController.view.frame = finalFrameForToView;
        toViewController.view.alpha = 0.5f;
        toViewController.view.transform = CGAffineTransformMakeScale(0.96, 0.96);

        [containerView sendSubviewToBack:toViewController.view];
    }

     // 4. Start animation

    [UIView animateWithDuration:[self transitionDuration:transitionContext]
                          delay:0.0
         usingSpringWithDamping:1.f
          initialSpringVelocity:0.0
                        options:UIViewAnimationOptionCurveLinear
                     animations:^{
                         // 4 - 1. Let all of these value from 3 - 1 and 3 - 2 setup value to following value with animation.

                         if (!self.isDismiss) {                             
                             fromViewController.view.alpha = 0.5f;
                             fromViewController.view.transform = CGAffineTransformMakeScale(0.96, 0.96);
                             toViewController.view.frame = finalFrameForToView;
                         } else {
                             fromViewController.view.frame = CGRectOffset(fromViewController.view.frame, bounds.size.width, 0);
                             toViewController.view.alpha = 1.0f;
                             toViewController.view.transform = CGAffineTransformIdentity;
                         }
                     } completion:^(BOOL finished) {
                         // 5. After animation, cleanup or reset the value.

                         if (!self.isDismiss) {
                             fromViewController.view.transform = CGAffineTransformIdentity;
                         }

                         [transitionContext completeTransition:YES];
                     }];
}

核心動畫部分,我們分成五個部分來說明:

第一部分

我們透過transitionContext來獲取fromViewControllertoViewControllerfinalFramecontainerView… 等等動畫過程中的重要資訊。

第二部分

toView在動畫開始之前,並不在containerView當中,因此我們將toView加入containerView

第三部分

這個部分主要是開始「動畫之前」,各個數值的初始值,可以把它想成整個動畫路徑的起點,這個部分我們就必須分成Presented and Dissmised 兩部分來處理。

3 – 1

處理Presented 的初始值,我們將toView 往右偏移到畫面右側一個螢幕寬度。

3 – 2

處理Dismissed 的初始值,我們將toViewalpha設定成0.5、frame設定成finalFrame (原本該有的大小),並將transform 設定為一個0.96x 的CGAffineTransform。

特別值得一提的是,Dismissed 時,toView 必須要被放到containerView的最後。

第四部分

這個部分我們要「開始動畫」,開始之前,若對於UIView 所提供的animation block 不熟悉,可以看一下這裡來補充基礎知識。

animations block 當中設定的數值,將會被透過動畫的方式來進行改變,其中,在UIView 的註解當中,也會標示哪些property 是animatable。

這個階段,我們可以把它想成整個動畫路徑的終點,一樣必須分成Presented and Dissmised 兩部分來處理。

如果沒辦法很清楚看懂文字說明,那我們就來看看動圖吧:

Presented 示意圖

Dismissed 示意圖

我們可以看到,其實兩者就是互相交換起點與終點的數值,就這麼簡單。

第五部分

最後,我們在動畫結束後,於completion block,把一些值設定回初始值。

到目前為止,我們只需要告訴transitionContext 動畫已結束,並且在Presented 的case 時,將transform 設定回預設值,避免要Dismissed 時出錯。

Step 4. 建立Interactive transitioning controller

一般的Presented and Dismissed 都準備好後,我們最後要實作的就是側滑的Interaction。

CPASwipeInteractiveTransition

首先我們建立一個名為CPASwipeInteractiveTransition 的class,在建立該物件的instance 時,需要讓它知道Presented 時的toView,因為我們要在toView 上面增加一個Gestrue。

Gesture 的部分,我們使用UIKit 提供的UIScreenEdgePanGestureRecognizer,來進行左側邊框的Pan 行為偵測。

Gesture 的delegate,則需要判斷各種狀況:

- (void)swipe:(UIScreenEdgePanGestureRecognizer *)gestureRecognizer {
    CGPoint translation = [gestureRecognizer translationInView:gestureRecognizer.view.superview];
    CGFloat width = CGRectGetWidth(gestureRecognizer.view.superview.bounds);

    switch (gestureRecognizer.state)
    {
        case UIGestureRecognizerStateBegan:
            self.interacting = YES;

            [self.toViewController dismissViewControllerAnimated:YES completion:nil];

            break;
        case UIGestureRecognizerStateChanged: {
            CGFloat fraction = translation.x / width;

            //Limit it between 0 and 1
            fraction = fminf(fmaxf(fraction, 0.0), 1.0);
            self.shouldComplete = (fraction > 0.3);

            [self updateInteractiveTransition:fraction];
            break;
        }

        case UIGestureRecognizerStateEnded:
        case UIGestureRecognizerStateCancelled:

            self.interacting = NO;

            if (!self.shouldComplete || gestureRecognizer.state == UIGestureRecognizerStateCancelled) {
                [self cancelInteractiveTransition];
            } else {
                [self finishInteractiveTransition];
            }

            break;
        default:
            break;
    }
}

其中,在開始時,必須先呼叫Dismissed method 來開始進行動畫interacting 設定成YES,讓Animator 知道。

在改變的過程中,我們會計算目前滑動的比例佔畫面的多少,這範例當中,我們讓比例超過30% 就當作Complete,這個Flag 關係到接下來的結束或取消的條件,並且我們得呼叫updateInteractiveTransition: 將計算好的比例傳給此method,來進行InteractiveTransition。

在結束或取消時,則需要將interacting 設定成NO,並且根據條件,來決定呼叫cancelInteractiveTransition或是finishInteractiveTransition

CPAAnimationController

CPAAnimationController 則需要有一個weak reference 來知道CPASwipeInteractiveTransition 的instance 是誰,所以我們得稍微修改一下他的Constructor。

這裡有一點需特別注意,我們原本使用的Animation method 包含SpringWithDampingSpringVelocity這兩個值,再透過Gesture 滑動時,動畫看起來並不會跟著手,所以我們才需要CPASwipeInteractiveTransitioninteracting flag。

我們稍做修改,增加了一個條件:

if (self.interactiveTransition.interacting) {
        // Only possible happend when dismissed
        [UIView animateWithDuration:[self transitionDuration:transitionContext]
                              delay:0.0
                            options:UIViewAnimationOptionCurveLinear
                         animations:^{
                             fromViewController.view.frame = CGRectOffset(fromViewController.view.frame, bounds.size.width, 0);
                             toViewController.view.alpha = 1.0f;
                             toViewController.view.transform = CGAffineTransformIdentity;
                         } completion:^(BOOL finished) {
                             BOOL wasCancelled = [transitionContext transitionWasCancelled];

                             toViewController.view.transform = CGAffineTransformIdentity;

                             [transitionContext completeTransition:!wasCancelled];
                         }];
    } else {
        [UIView animateWithDuration:[self transitionDuration:transitionContext]
                              delay:0.0
             usingSpringWithDamping:1.f
              initialSpringVelocity:0.0
                            options:UIViewAnimationOptionCurveLinear
                         animations:^{
                             if (!self.isDismiss) {
                                 fromViewController.view.alpha = 0.5f;
                                 fromViewController.view.transform = CGAffineTransformMakeScale(0.96, 0.96);
                                 toViewController.view.frame = finalFrameForToView;
                             } else {
                                 fromViewController.view.frame = CGRectOffset(fromViewController.view.frame, bounds.size.width, 0);
                                 toViewController.view.alpha = 1.0f;
                                 toViewController.view.transform = CGAffineTransformIdentity;
                             }
                         } completion:^(BOOL finished) {
                             BOOL wasCancelled = [transitionContext transitionWasCancelled];

                             if (!wasCancelled && !self.isDismiss) {
                                 fromViewController.view.transform = CGAffineTransformIdentity;
                             }

                             [transitionContext completeTransition:!wasCancelled];
                         }];

    }

除了新增如果是interacting 的狀態下,使用不同的UIView 動畫method 之外,我們另外增加了wasCancelled 這個值的判斷,當動畫是被取消的話,我們必須讓context 知道,這是與上一章節不同的地方,因為,當滑動小於30% 時,動畫就算使取消,而不是真的完成。

CPATransitioningDelegate

CPATransitioningDelegate 則需要增加這兩個實作method:

- (CPASwipeInteractiveTransition *)swipeInteractiveTransition {
    if (!_swipeInteractiveTransition) {
        _swipeInteractiveTransition = [[CPASwipeInteractiveTransition alloc] initWithToViewController:self.toViewController];
    }

    return _swipeInteractiveTransition;
}

#pragma mark - Interactive

- (id<UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id<UIViewControllerAnimatedTransitioning>)animator {
    return self.swipeInteractiveTransition.interacting ? self.swipeInteractiveTransition : nil;
}

- (id<UIViewControllerInteractiveTransitioning>)interactionControllerForPresentation:(id<UIViewControllerAnimatedTransitioning>)animator {
    return self.swipeInteractiveTransition.interacting ? self.swipeInteractiveTransition : nil;
}

如此一來,就大功告成囉。

結論

整體來說,動畫不算太複雜,難的還是想像力,或許也因為對於這部分不夠了解,若有什麼錯誤的地方,也歡迎讀到這篇文章的人分享指教。

Reference

  1. Customizing the Transition Animations
  2. Introduction to Custom View Controller Transitions and Animations
  3. 使用 iOS 8 Spring Animation API 创建动画