从 iOS 的动画说到转场

brownsnake 发布于2年前
0 条问题

动画与转场,个人认为在概念上并不复杂,只是在代码的组织和形式上比较复杂,因此我尝试先讲讲概念,再讲讲实现,让思绪清晰一些。

什么是动画(Animation)?

所谓动画,就是在一段时间内,一些 view 的位置、颜色等属性会逐渐变化的一个现象。那么要完成一个动画,我们只需要确定三点:动画有多久、动画涉及到哪些 view 、这些 view 都有哪些属性改变了,说简单点儿就是时间、元素、变化形式。明确了这三点,各种 API 的变化只是在代码的简洁性和复用度上不停的做文章而已。

那,什么是转场(Transition)?

我们说到,动画的三个主要元素是时间、元素、变化形式,在元素这里动画并没有做过多的约束,而从概念上讲,转场就是一个动画的子集,其约束动画的元素必须为两个元素,并且一般都是两个 view controller 的主 view 进行的转换(所以说转场是针对两个 vc 的动画也没啥大毛病)。

iOS 中动画怎么做?

了解了动画的关键概念,我们来看看在 iOS 中,应该如何用代码去描述这三个概念。

第一种:使用UIView 的 begin/commit :

    _demoView.frame = CGRectMake(0, SCREEN_HEIGHT/2-50, 50, 50);
    [UIView beginAnimations:nil context:nil]; 
    [UIView setAnimationDuration:1.0f];// 这里描述时间
    _demoView.frame = CGRectMake(SCREEN_WIDTH, SCREEN_HEIGHT/2-50, 50, 50);// 这里同时描述了元素和变化形式
    [UIView commitAnimations];

第二种:直接通过 block 调用

    _demoView.frame = CGRectMake(0, SCREEN_HEIGHT/2-50, 50, 50);
   [UIView animateWithDuration:1.0f delay:1.0f // 这里是时间
   options:UIViewAnimationOptionCurveEaseIn // 这里是一些封装的变化形式
   animations:^{
            _demoView.frame = CGRectMake(SCREEN_WIDTH, SCREEN_HEIGHT/2-50, 50, 50);// 这里同时描述了元素和变化形式
        } completion:nil];

第三种:将对属性的变化封装到 CoreAnimation 对象中,然后应用到某个 view 的 layer 上

    CABasicAnimation *anima = [CABasicAnimation animationWithKeyPath:@"position"];
    anima.fromValue = [NSValue valueWithCGPoint:CGPointMake(0, SCREEN_HEIGHT/2-75)];
    anima.toValue = [NSValue valueWithCGPoint:CGPointMake(SCREEN_WIDTH, SCREEN_HEIGHT/2-75)];// 这里描述了变化形式
    anima.duration = 1.0f;// 这里描述了时间
    [_demoView.layer addAnimation:anima forKey:@"positionAnimation"];// 这里则是描述了元素

这三种方式中,第一种是很久以前(iOS 4.0)使用的形式,无论是便捷度和复用度都不是很高。第二种是最方便的,但是缺点在于不好复用(除非把 block 保存起来,可以在一个 vc 中实现复用)。第三种是一种很容易复用的形式,将动画的三个元素中时间、变化形式单独抽离出来,使得其可以自由的应用在任意的元素上。(由此可以看出,如果想要代码的复用度更高,就需要不断的减少一段代码或者一个对象在概念上的职责)

iOS 中转场怎么做?

前面我们说过,转场是针对于两个特定的 view 的动画,所以我们需要先约定一下术语,假如我们有两个 VC A/B,我们要从 A 转换到 B,我们称呼 A 为 presentingViewController(或者 fromViewController),称呼 B 为 presentedViewController(或者 toViewController)。当从 B 结束转换回到 A 时,我们仍然称呼 A 为 presentingViewController,B 为 presentedViewController,但是我们会称呼 A 为 toViewController ,而 B 为 fromViewController。明白区别了么?from/to 是针对一次动画的,而 presented/presenting 是针对一次完整的转场的。

虽然从概念上来说,转场是一种特定的动画,但是实际上转场需要考虑的事情要比一般的动画要多(比如一般的动画可能不需要交互,但是转场可能需要),因此在代码的组织结构上,转场使用了更多的对象去更加细致的拆分概念上的职责。

最基本的一种实现转场的方式,非常类似于上面所说的第二种动画的表现形式:

    [self transitionFromViewController:self.fromVC
                      toViewController:self.toVC // 元素
                              duration:5 // 时间
                               options:UIViewAnimationOptionCurveEaseInOut // 变化形式的封装
                            animations:^{
        CGRect frame = self.thirdVC.view.frame;
        frame.origin.y  = 150;
        self.thirdVC.view.frame = frame;
    }
                            completion:nil];

这个转场一般在容器 VC 中使用。缺点其实是和最基本的动画调用方式一样,都是不容易复用,并且使用场景有限,只能用在容器 vc 中,不能用在两个平级的 vc 中。也就是说,为了从 A 转到 B,我们必须首先有一个 C ,然后让 A、B 作为 C 的 child vc ,显然很不方便啊,那么我们就需要考虑一种新的代码组织形式,将转场的职责进行拆分。

转场的职责划分

在一次自定义的转场中,我们会将指责进行如下形式的划分:

首先,我们需要有两个 vc(废话(╬▔皿▔)),然后设置 presentingVC 的 modalPresentationStyleUIModalPresentationCustom,接下来将 presentingVC 的 transitioningDelegate 属性指向一个实现了 UIViewControllerTransitioningDelegate协议的对象上。这样就告诉 UIKit 任意一个 vc 用 prensentViewController:animated:completion 方法展示 presentingVC 时,presentingVC 的转场效果完全由 transitioningDelegate 属性所指向的对象来负责。

// PresentingVC
self.transitioningDelegate = [TransitionDelegate new];// 转场效果这一部分职责从 vc 中剥离了出去

TransitionDelegate 是一个实现了 UIViewControllerTransitioningDelegate 协议的对象,在这个协议中又将转场效果的职责分为三个对象去负责:一个负责转场动画效果的 Animator,一个负责转场过程中交互的 InteractiveAnimator,和一个则负责转场过程中 view 的层级关系以及在不同屏幕上的适配。这三个对象的职责,在代码上的表现形式就是将UIViewControllerTransitioningDelegate的内容分为三组。我们来一个个了解一下。

TransitionAnimator

这个对象负责转场的动画效果,具体点儿来说,他决定了可见的视图从 PresentingViewController 的 view 到可见视图变为 PresentedViewController 的 view 的过程中,两个 view 应该如何去变化。在UIViewControllerTransitioningDelegate协议中,该对象可以通过两个方法返回:

- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source;
- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed

两个方法中,前者决定了 present 过程中的动画效果,后者则决定了 dismiss 过程中的动画效果。而具体 Animator 如何去控制转场过程中的动画,我们就需要看看 UIViewControllerAnimatedTransitioning 这个协议中的方法都有些什么:

- (NSTimeInterval)transitionDuration:(nullable id <UIViewControllerContextTransitioning>)transitionContext;
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext;

第一个方法决定了转场的时间,第二个方法则是通过一个 transitionContext 对象传递给 Animator 对象转场过程中的 FromVC/ToVC,以及 containerView ,也就是转场过程中的元素,然后我们就可以通过 UIKit 的动画 API 决定转场的变化形式了。在这个方法中我们要做的就是:

  1. 得到 ToVC 的 view,设定其初始状态

  2. 将 ToVC 的 view 添加到 containerView 中

  3. 通过任意一种动画形式对 ToVC 的 view 做动画,然后在结束的时候调用 transitionContext 对象的 completeTransition: 方法告知系统我们的动画做完了。

更具体的内容,可以参见如下的一段代码:

- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext
{
    // 获取所有需要的 view 以及 vc
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *containerView = transitionContext.containerView;
    // 设定初始状态
    toVC.view.frame = CGRectMake(0, - CGRectGetHeight(fromVC.view.frame), CGRectGetWidth(fromVC.view.frame), CGRectGetHeight(fromVC.view.frame));
    toVC.view.alpha = 0.0f;
    
    // 一定要自己手动添加 subview, fromVC 的 view UIKit 会自动移除,但是 UIKit 不会自动添加 toVC 的 view
    [containerView addSubview:toVC.view];
    
    // 获取动画时间
    NSTimeInterval duration = [self transitionDuration:transitionContext];
    
    // 开始动画
    [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionCurveLinear animations:^{
        toVC.view.alpha = 1.0f;
        toVC.view.frame = fromVC.view.frame;
    } completion:^(BOOL finished) {
        if (finished) {
            [transitionContext completeTransition:YES];
            NSLog(@"finished");
        }
    }];
}

InteractiveAnimator

对于一般的转场来说,实现了基本的动画效果可能就够了,但是实际开发中,我们可能对于转场有更加深入的需求,比如希望转场能够带有用户交互,像系统的全局返回手势那样,这个时候,我们就需要额外返回一个 InteractiveAnimator 来告诉 UIKit 随着用户的手势变化,动画应该执行到百分之多少或者是否需要取消,这些操作我们都可以通过 context 对象中的方法来完成:

- (void)cancelInteractiveTransition;
- (void)finishInteractiveTransition;
- (void)updateInteractiveTransition:(CGFloat)percentComplete;

因此,如果想实现一个交互式的转场,我们需要做如下几件事儿:

  1. 在 presentingVC 中添加一个 button 点击以外的『触发器』(一般来说,都是一个 Gesture Recognizer),比如添加一个边缘滑动的 Gesture Recognizer,当一个边缘滑动开始时,我们在对应的回调中 present PresentedVC。

  2. 在 presentedVC 的 transitionDelegate 中,返回一个 InteractiveAnimator。

  3. 在 Animator 中的 startInteractiveTransition: 方法中将 context 对象保存起来。

  4. 想办法将 Gesture Recognizer 传递给 InteractiveAnimator,使得在 Animator 中可以获取当前手势的信息,结合 context 对象中的 containerView 等信息,我们可以知道当前手势在 view 中更具体的信息。

  5. 根据预先设定好的规则,在 Gesture Recognizer 的回调中调用 context 对象的 cancel/finished/update 方法

比如,如果我们想实现一个边缘滑动的交互动画效果,我们可以这么来写代码:

- (void)startInteractiveTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
    // 把 context 对象保存起来
    self.transitionContext = transitionContext;
    [super startInteractiveTransition:transitionContext];
}


// 根据手势的偏移来计算当前动画应该有的完成度
- (CGFloat)percentForGesture:(UIScreenEdgePanGestureRecognizer *)gesture
{
    // 根据 container view 以及 gesture recognizer 计算偏移量
    UIView *transitionContainerView = self.transitionContext.containerView;
    CGPoint locationInSourceView = [gesture locationInView:transitionContainerView];
    
    // 根据偏移量得出百分比
    CGFloat width = CGRectGetWidth(transitionContainerView.bounds);
    return (width - locationInSourceView.x) / width;
}


// gesture recognizer 的回调
- (IBAction)gestureRecognizeDidUpdate:(UIScreenEdgePanGestureRecognizer *)gestureRecognizer
{
    switch (gestureRecognizer.state)
    {
        case UIGestureRecognizerStateBegan:
            break;
        case UIGestureRecognizerStateChanged:
            // 计算百分比,并返回
            [self updateInteractiveTransition:[self percentForGesture:gestureRecognizer]];
            break;
        case UIGestureRecognizerStateEnded:
            // 根据预先设定的阈值决定是结束还是取消,这里我们设定 view 中间是分界线
            if ([self percentForGesture:gestureRecognizer] >= 0.5f)
                [self finishInteractiveTransition];
            else
                [self cancelInteractiveTransition];
            break;
        default:
            // 其他情况,取消转场
            [self cancelInteractiveTransition];
            break;
    }
}

PresentationController

以上的两组接口,分别让我们自定义了转场过程中的动画、动画执行百分比,但是不管是哪个,都会在最后将 fromVC 的 view 从 containerView 上移除,并且整个转场过程中如果我们想添加一些额外的 view 也是无法做到的。如果想要实现这些功能,就需要我们创建一个 UIPresentationController 的子类,然后重载其 四个转场的生命周期方法:

  • presentationTransitionWillBegin

  • presentationTransitionDidEnd:

  • dismissalTransitionWillBegin

  • dismissalTransitionDidEnd:

在重载这些方法时,我们也可以使用其 presentingViewController 属性的 transitionCoordinator 来同步的为我们新添加的 view 执行动画(所谓同步就是和我们之前在 Animator 中写的动画同时执行)。
比如,我们可以为我们添加的一个 dimming view 的透明度设置一个动画:

id<UIViewControllerTransitionCoordinator> transitionCoordinator = self.presentingViewController.transitionCoordinator;
        
        self.dimmingView.alpha = 0.f;
        [transitionCoordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context) {
            self.dimmingView.alpha = 0.5f;
        } completion:NULL];

总结一下来说,如果我们想要使用 UIPresentationController ,我们需要:

  1. 设置 presentedVC 的 presentStyle 为 UIModalPresentationCustom

  2. 在 presentedVC 的 transitionDelegate 中返回我们创建的 UIPresentationController 的子类

  3. 在子类中重载转场生命周期的四个方法,添加我们所需要的自定义的view

扩展阅读

查看原文: 从 iOS 的动画说到转场

  • silverleopard
  • organicgoose
  • smallmouse
  • blackswan
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。