iOS学习笔记(8)-自定义过渡动画

这篇笔记翻译自raywenderlick网站的过渡动画的一篇文章,原文用的swift,由于考虑到swift版本变动以及一些语法兼容问题,这里我还是用Objective-C进行了改写,没有逐字翻译,加了部分自己的理解。原文链接Creating Custom UIViewController Transitions。过渡动画有些地方也是翻译成转场动画,即从一个视图控制器切到另一个视图控制器,本文以过渡来译。

1 前言

iOS自身就提供了很多针对UIViewController的过渡动画,比如Cover Vertically(从下往上弹出效果)Cross Dissolve(淡入淡出效果)Partial Curl(书卷翻页效果)等。如图1就是本文用到的示例中的iOS原生的Cover Vertically效果的展示。

图1 Cover Vertically效果展示

为了自己的APP更有个性,自带的效果往往不够酷炫,所以需要自定义过渡动画,通过这篇文章,我们会GET到下面几个技能:

  • 过渡动画API的构建。
  • 使用自定义的过渡动画来present和dismiss一个视图控制器。present过渡会在应用视图层级结构中添加一个新的视图控制器,而dismiss过渡会从层级结构中删除一个或多个视图控制器。
  • 学会使用交互式过渡动画。

在我们开始的示例代码中,还没有加入自定义过渡动画,已经有的内容是一个PageViewController,里面装载的为CardViewController(内容为一个UIView+一个Label用于展示图片描述),点击CardViewController里面的卡片,会切换到RevealViewController(包含一个Label展示图片名字,一个Image View展示宠物图片,一个按钮用于返回到卡片视图)。而我们最终要达到的效果如图2所示:

图2 最终效果图

2 过渡动画API探究

过渡动画API涉及到的一些角色如图3所示,下面分开介绍:

图3 过渡动画API角色

2.1 过渡动画API中的角色

本节内容对过渡动画API中的各个角色进行说明,包含的角色参照图3。

2.1.1 过渡动画代理(Transitioning Delegate)

每个View Controller都有一个transitionDelegate属性,这个代理实现了UIViewControllerTransitioningDelegate协议。

每当你要present或者dismiss一个View Controller的时候,UIKit会去过渡动画代理中查询需要使用的动画效果。实际项目中,我们可以设置代理为自定义的类来返回我们需要的自定义的动画效果。

2.1.2 动画控制器(Animation Controller)

动画控制器是实现了UIViewControllerAnimatedTransitioning协议的用于执行过渡动画的对象。

2.1.3 过渡动画上下文对象(Transitioning Context)

上下文对象实现了UIViewControllerContextTransitioning协议,在动画过程中是至关重要的,它封装了所有的参与过渡动画的View Controllers的信息。不过我们不用写代码实现它,在动画控制器里面,过渡动画执行的时候,我们的函数会接收到一个上下文对象作为参数并从中获取相关View Controller的信息。

2.2 过渡动画流程

    1. 你触发一个过渡动作。可以通过编码或者segue来触发。
    1. UIKit询问要过渡到的目的视图控制器它是否有自定义的过渡动画代理。如果没有,则UIKit将使用iOS自带的过渡动画。
    1. 然后,UIKit通过过渡动画代理,获取到动画控制器。比如通过 animationControllerForPresentedController(_:presentingController:sourceController:)方法获取到动画控制器,如果返回空,则使用默认的动画控制器。
    1. 一旦找到了动画控制器,UIKit构建上下文对象。
    1. 接着,UIKit通过动画控制器的 transitionDuration(_:)方法获取动画执行时长。
    1. 再接着调用动画控制器的animateTransition(_:)完成过渡动画。
    1. 最后动画控制器调用上下文对象的completeTransition(_:)方法指示动画完成。图4是官方文档的一个过渡动画的API角色示意图。
图4 过渡动画角色示意图2

2.3 实现Presentation过渡动画

我们总共要实现三个动画效果,一个是Presentation过渡动画,一个是dismiss过渡动画,另外还有一个交互动画。

Presentation的效果主要如下:

  • 点击卡片的时候,卡片翻转显示第二个视图,且第二个视图初始大小跟卡片大小一样。
  • 第二个视图放大至整个屏幕大小。

2.3.1 创建Presentation动画控制器

我们创建一个名为FlipPresentAnimationController的类来完成Presentation动画效果,这个类在我们上面说的角色中就是动画控制器。

核心代码如下,代码中有注解:

/*设置动画时长函数*/
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
    return 2.0;
}

/*执行动画的函数*/
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
    //1 上下文对象transitionContext包含了参与过渡动画的视图
    // 和视图控制器信息,可以通过对应的参数获取。
    CardViewController *fromVC = (CardViewController *)[transitionContext viewControllerForKey:UITransitionContextFromViewKey];
    UIView *containerView = [transitionContext containerView];
    RevealViewController *toVC = (RevealViewController *)[transitionContext viewControllerForKey:UITransitionContextToViewKey];
    
    //2 设置过渡目的视图的初始大小和结束大小。
    //   初始大小为第一个视图的卡片的大小,结束大小为整个屏幕大小。
    BOOL hasViewForKey = [transitionContext respondsToSelector:@selector(viewForKey:)];
    UIView *fromView = hasViewForKey ? [transitionContext viewForKey:UITransitionContextFromViewKey] : fromVC.view;
    UIView *toView = hasViewForKey ? [transitionContext viewForKey:UITransitionContextToViewKey] : toVC.view;
    CGRect initialFrame = self.originFrame;    
    CGRect finalFrame = hasViewForKey? toView.frame : [transitionContext finalFrameForViewController:toVC];
    
    //3 获取一个目的视图的一个快照。设置初始frame为initFrame。
    UIView *snapshot = [toView snapshotViewAfterScreenUpdates:YES];
    snapshot.frame = initialFrame;
    snapshot.layer.cornerRadius = 25;
    snapshot.layer.masksToBounds = YES;
    
    //4 containerView加入目的视图和快照视图,并先隐藏目的视图。
    //   我们的动画都在containerView来实现。
    [containerView addSubview:toView];
    [containerView addSubview:snapshot];
    toView.hidden = YES;
    
    //5 设置动画视角,将快照视图先沿Y轴旋转到PI/2的位置。
    [AnimationHelper persipectiveTransformForContainerView:containerView];
    snapshot.layer.transform = [AnimationHelper yRotation:M_PI_2]; 
    
    CGFloat duration = [self transitionDuration:transitionContext];
    
    [UIView animateKeyframesWithDuration:duration delay:0 options:UIViewKeyframeAnimationOptionCalculationModeCubic animations:^{
        [UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:1.0/3 animations:^{
            //6 将第一个视图旋转到-PI/2的位置,方向是顺时针
            fromView.layer.transform = [AnimationHelper yRotation:-M_PI_2]; 
        }];
        
        [UIView addKeyframeWithRelativeStartTime:1.0/3 relativeDuration:1.0/3 animations:^{
            //7 将快照视图从PI/2的位置旋转到轴线位置,也是顺时针。正好接上6的旋转效果。
            snapshot.layer.transform = [AnimationHelper yRotation:0.0];
        }];
        
        [UIView addKeyframeWithRelativeStartTime:2.0/3 relativeDuration:1.0/3 animations:^{
            //8 将快照视图的frame放大至整个屏幕。
            snapshot.frame = finalFrame;
        }];
        } completion:^(BOOL finished){
            toView.hidden = NO; //显示目的视图
            fromView.layer.transform = [AnimationHelper yRotation:0.0]; //恢复第一个视图的位置
            [snapshot removeFromSuperview]; //移除快照视图
            [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; //通知UIKit动画执行完成
        }
     ];
}

额外说明几点:

    1. 注释2这段代码跟原文的swift的有点不一样,直接通过transitionContext viewControllerForKey:UITransitionContextToViewKey等函数取到的View Controller发现是nil,这样就没法取到动画过程中的视图信息。而通过transitionContext viewForKey:UITransitionContextToViewKey取到的视图是正常的,看网上资料说可能是ios8的BUG,没有确切资料可以确认,如果是其他设置问题,麻烦大虾们告知一下。
    1. 关于旋转方向的问题,通过上一篇笔记我们总结了三维视图中沿Y轴旋转的正反方向,正方向为逆时针。因此注释5中我们的快照视图显示逆时针的转到了PI/2的位置,而注释6会先将第一个视图转到-PI/2的位置,动画中的旋转方向是以距离最近来旋转,因此第一个视图会顺时针旋转PI/2,然后快照视图也是顺时针旋转PI/2,最后再试快照视图放大到整个屏幕。
    1. 最后的completeTransition方法调用是必须的,如果不调用的话,动画结束后目的视图将无法接受事件响应。

2.3.2 连接动画控制器

在我们的CardViewController中加入动画控制器初始化代码。这里的CardViewController实现了UIViewControllerTransitioningDelegate协议,我们要设置目的控制器的transitionDelegate为CardViewController。并实现代理的方法返回我们刚刚创建的动画控制器。代码如下:

- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source {
    self.flipPresentAnimationController.originFrame = self.cardView.frame;
    return self.flipPresentAnimationController;
}

// 在CardViewController的prepareSegue方法中,设置了transitionDelegate。
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
    ......
    revealViewController.transitioningDelegate = self;
}

2.4 实现dismiss过渡动画

dismiss的过渡动画原理类似,不过多介绍了,实现功能是:

  • 第二个视图的图片先缩小到第一个视图的卡片大小。
  • 两个视图先后翻转,最终回到初始位置。

代码如下:

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
    CardViewController *fromVC = (CardViewController *)[transitionContext viewControllerForKey:UITransitionContextFromViewKey];
    UIView *containerView = [transitionContext containerView];
    RevealViewController *toVC = (RevealViewController *)[transitionContext viewControllerForKey:UITransitionContextToViewKey];
  
    BOOL hasViewForKey = [transitionContext respondsToSelector:@selector(viewForKey:)];
    
    UIView *fromView = hasViewForKey ? [transitionContext viewForKey:UITransitionContextFromViewKey] : fromVC.view;
    UIView *toView = hasViewForKey ? [transitionContext viewForKey:UITransitionContextToViewKey] : toVC.view;
        
    CGRect initialFrame = fromView.frame;
    CGRect finalFrame = self.destinationFrame;
    
    UIView *snapshot = [fromView snapshotViewAfterScreenUpdates:YES];
    snapshot.frame = initialFrame;
    snapshot.layer.cornerRadius = 25;
    snapshot.layer.masksToBounds = YES;
    
    [containerView addSubview:toView];
    [containerView addSubview:snapshot];
    fromView.hidden = YES;
    
    [AnimationHelper persipectiveTransformForContainerView:containerView];
    toView.layer.transform = [AnimationHelper yRotation:-M_PI_2];
    
    CGFloat duration = [self transitionDuration:transitionContext];
    
    [UIView animateKeyframesWithDuration:duration delay:0 options:UIViewKeyframeAnimationOptionCalculationModeCubic animations:^{
        [UIView addKeyframeWithRelativeStartTime:0.0 relativeDuration:1.0/3.0 animations:^{
            snapshot.frame = finalFrame;
        }];
        
        [UIView addKeyframeWithRelativeStartTime:1.0/3.0 relativeDuration:1.0/3.0 animations:^{
            snapshot.layer.transform = [AnimationHelper yRotation:M_PI_2];
        }];
        
        [UIView addKeyframeWithRelativeStartTime:2.0/3.0 relativeDuration:1.0/3.0 animations:^{
            toView.layer.transform = [AnimationHelper yRotation:0.0];
        }];
    } completion:^(BOOL finished){
        fromView.hidden = NO;
        [snapshot removeFromSuperview];
        [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
    }
    ];
}

当然,也少不了要在代理类中关联好dismiss的动画控制器。

2.5 实现交互动画

2.5.1 交互动画示例

iPhone上面的设置APP就是交互动画的一个很典型的例子,如图5所示,从左边缘开始滑动,过渡动画的进度是跟随你的手指滑动的位置来确定的(比如坐标X超过了多少则表示切换到下一个视图,否则切回上一个视图。

图5 交互动画示例

2.5.2 交互动画原理

交互动画通过交互控制器来控制,为了实现交互动画,过渡动画代理需要额外提供一个交互控制器。交互控制器只要实现了UIViewControllerInteractiveTransitioning协议即可,它响应触控事件,通过交互控制器,动画会随着手势拖动逐渐展开而不是像之前那样直接执行完毕。

iOS提供了一个UIPercentDrivenInteractiveTransition类,它实现了UIViewControllerInteractiveTransitioning协议,我们在例子中要用到这个类。

2.5.3 创建交互过渡动画

创建交互动画代码如下,我们需要添加拖动事件响应,在处理事件响应的函数handleGesture中,我们根据当前手势状态和所在的位置来进行处理。注意到gestureRecognizer.view是对应的目的视图也就是RevealViewController对应的View。而它的superview则是UITransitionView这个视图。

- (void)wireToViewController:(UIViewController *)viewController {
    self.viewController = viewController;
    [self prepareGestureRecognizerInView:viewController.view];
}

- (void)prepareGestureRecognizerInView:(UIView *)view {
    UIScreenEdgePanGestureRecognizer *gesture = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action: @selector(handleGesture:)];
    gesture.edges = UIRectEdgeLeft;
    [view addGestureRecognizer:gesture];
}

- (void)handleGesture:(UIScreenEdgePanGestureRecognizer *)gestureRecognizer {
    //1 获取手势当前的坐标点
    CGPoint translation = [gestureRecognizer translationInView:gestureRecognizer.view.superview];
    CGFloat progress = (translation.x / 200);
    progress = fminf(fmaxf(progress, 0.0), 1.0);
    switch (gestureRecognizer.state) {
        //2 开始手势,设置开始交互的标识,开始触发dismissal操作。
        case UIGestureRecognizerStateBegan:
            self.interactionInProgress = YES;
            [self.viewController dismissViewControllerAnimated:YES completion:nil];
            Break;
        //3 手势拖动,判断当前的手势横轴坐标是否大于100,大于100则设置过渡动画完成。
        case UIGestureRecognizerStateChanged:
            self.shouldCompleteTransition = progress > 0.5;
            [self updateInteractiveTransition:progress];
            Break;
        //4 手势取消,设置交互状态为NO,并取消交互动画。
        case UIGestureRecognizerStateCancelled:
            self.interactionInProgress = NO;
            [self cancelInteractiveTransition];
            Break;
        //5 手势结束,根据进度来判断是取消还是完成交互动画。
        case UIGestureRecognizerStateEnded:
            self.interactionInProgress = NO;
            if (!self.shouldCompleteTransition) {
                [self cancelInteractiveTransition];
            } else {
                [self finishInteractiveTransition];
            }
        default:
            NSLog(@"Unsupported");
            break;
    }

在CardViewController中需要加入对应代码才能呈现交互动画,加入代码如下:

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

/* 在CardViewController的prepareSegue方法中,
 设置了transitionDelegate,加入交互动画事件捕获。*/
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
    ......
    revealViewController.transitioningDelegate = self;
    [self.swipeInteractionControllers wireToViewController:revealViewController];
}

至此整个动画效果完成,完整代码参见

3 参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容