交互式动画(下):UIViewPropertyAnimator in iOS 10

96
seedante
2016.07.13 01:15* 字数 3410

本文上下两篇已授权在 InfoQ 的移动开发前线公众号上首发,微信阅读地址InfoQ 文章链接

不久前结束的 WWDC 2016 Session 216: Advances in UIKit Animations and Transitions 介绍了 iOS 10 的新动画 API,让动画与交互无缝连接,这是「开发者的大事、大快所有人心的大好事」。在上篇我探讨了 iOS 10 以下的系统中如何使用 UIView Animation 实现交互动画,本篇来探讨 iOS 10 带来的变化。

新 API 的改进

交互动画 API
交互动画 API

新 API 的核心是 UIViewPropertyAnimator 类,在 UIViewAnimating 协议中定义了交互动画需要的所有基础功能:暂停,恢复,停止,逆转动画以及控制动画进度。UIView Animation 并没有提供这些功能,这些功能都需要回到 Core Animation 作用的 CALayer 里使用分散且文档晦涩难懂的 API 来实现。UIViewImplicitlyAnimating 协议主要补充了与 UIView Animation 类似的添加动画 Block 的方法。

UITimingCurveProvider 协议重新封装了时间函数,而 UISpringTimingParameters 类终于带来了期待已久的两点改进:

  1. 以向量CGVector(dx: CGFloat, dy: CGFloat)为单位的初始速度,在 iOS 10 之前的弹簧动画 API 里的速度都是数值,在位移动画里方向是沿着起点到终点的直线方向,速度为向量意味着合成的初始速度可以不沿着这个方向;速度分量为负时,以 X 轴方向分量dx为例,表示与目标方向在 X 轴的分量相反,而非是沿着 X 轴反方向;
  2. 完全版本的弹簧动画:iOS 7 引入了简化的 Spring UIView Animation API,iOS 9 引入了无文档的完全版本的 Spring Core Animation API;而这两个版本的初始速度皆为数值,iOS 10 的所有弹簧动画的速度都是向量。

UIViewPropertyAnimator类可以视为面向对象版本的 UIView Animation,以动画 Block 为基础的设计解决了多个 UIView 参与动画时的交互控制,而使用 UIView Animation 时面对多个视图参与交互动画就需要针对每个视图进行控制。

交互转场的最后一块拼图

在转场动画里,非交互转场与交互转场之间有着明显的界限:如果以交互转场开始,尽管在交互结束后会切换到非交互状态,但之后无法再次切换到交互状态,只能等待其结束;如果以非交互转场开始,在转场动画结束前是无法切换到交互控制状态的,只能等待其结束。iOS 10 在转场协议中引入了上述 API,这使得非交互转场与交互转场之间的界限不再泾渭分明。

让转场动画在非交互状态与交互状态之间自由切换很困难,UIViewPropertyAnimator类实现了需要的所有基础功能,使得难度降低了许多。在 session 的现场演示中,工程师演示了使用该类从头打造可全程在非交互与交互状态之间自由切换的转场动画。转场协议为了实现高度定制化,定义的方法是比较冗余的,iOS 10 在此基础上引入的新 API 使得协议更加复杂,虽然在演示中添加的代码只有百来行,另一方面演示的转场动画本身也相对复杂,使得这一切看上去很非常复杂。

事实上,依靠UIViewPropertyAnimator类,在实现转场动画在非交互与交互状态之间自由切换的基础上,还可以大幅精简现有的转场协议体系。但转场动画本身是个很繁杂的话题,展开讲将占用大量的篇幅,这部分具体内容我放在了「iOS 视图控制器转场详解」更新的章节里。转场动画本质上是相关视图控制器的转换,并将其中视图的转换使用动画的形式展现。除去控制器的部分,转场动画就与使用 UIView 下面这个方法来实现的的视图转换动画无异。

transitionFromView:toView:duration:options:completion:

objc.io 在「交互式动画」中探讨了如何让普通的动画实现交互,这与 iOS 10 对转场动画的改进是一脉相承的,因此接下来我将使用UIViewPropertyAnimator类来继续 objc.io 的探讨来深度讲解新 API。

新 API 实践

要实现的效果如下:

Pane Control Interactive Animation.gif

这个简单的位移动画里包含了两套交互:滑动控制(pan 手势)和点击控制(tap 手势),要解决三个转换问题,也是所有交互动画需要解决的问题:

  1. Animation to Gesture:动画过程中切入滑动控制,需要中止当前的动画并由手指来控制控制板的移动;
  2. Gesture to Animation:滑动结束后添加新的动画,并与当前的状态平滑衔接,这需要 Spring 动画;
  3. Animation to Animation:动画过程中每次点击视图后使动画逆转。

前面提到UIViewPropertyAnimator封装了交互动画需要的所有基础功能,实现交互动画的难度大大降低了,这篇文章似乎没有写的必要了。以上每个转换问题该类都有几种解决办法,使用方法非常灵活,但相对地,复杂性增加了不少,也有不少地方需要注意。这次不像上篇中分别解决三个转换问题,而是将之归类为实现滑动控制和点击控制,并首先解决后者。

点击交互:逆转动画

先进行设置:

//这个场景里需要使用具有初速度的弹簧动画,使用 Spring Timing 进行配置
let timing = UISpringTimingParameters(dampingRatio: 0.7, initialVelocity: CGVector(dx: 0, dy: 1))
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: timing)
//根据控制板的开关状态计算动画的目标位置,如果 animator 的 state 不是 active,下面的动画并不会运行,必须手动启动。
animator.addAnimations({
    panelView.center.y = targetY
})
//根据动画结束的位置来更新开关状态:end 表示到达了预定目标位置,start 表示回到了起点,current 表示动画停在了中途某个位置
animator.addCompletion({ position in
    if position == .end{ //动画可能会逆转,或者中止在中途,只有到达了预定的目标位置才能将开关状态置反
        panelOpened = !panelOpened
    }
})

添加的 Animation Block 和 Completion Blcok 是一次性的,不会重复使用。接下来处理 Tap 手势:

switch tapGesture.state {
case .ended, .cancelled:
    switch animator.state {
    //初始化后 animator 的状态为: state->inactive, running->false
    case .inactive, .stopped:
        animator.startAnimation()//手动启动,状态变化:state->active, running->true
    case .active:
        //逆转动画:下面每个步骤都不能少,注意暂停动画后 state 依然为 active,区别在 running
        animator.pauseAnimation()//暂停当前的动画,状态变化:state->active, running->false
        animator.isReversed = !(animator.isReversed)//让动画的方向与当前的方向相反
        animator.startAnimation()//继续运行动画,状态变化:state->active, running->true
    }
default:break
}

上面的代码逆转动画的效果如同下面的 BeginFromCurrentState,而我们更需要的是更加自然的 Additive 效果,虽然在这个场景里,0.5s的动画时间无法看出这两种效果的差别:

ReverseAnimation

实现 Additive 效果可以通过添加反向的动画来实现,使用 UIView Animation 时也是这样做来逆转动画:

//每次 Tap 手势结束后添加向反方向运动的动画
animator.addAnimations({
    panelView.center.y = targetY //targetY 为相反位置的坐标
})

为何不选择这种方法?不能仅仅为了展示UIViewPropertyAnimator不同于 UIView Animation 的特性而让效果打折,事实上,这是无奈之举:不知是否是 Bug,当 Spring Timing 的初始速度不为(0, 0)时,这种方法无法实现 Additive 效果,而是中止动画直接跳跃到最终位置,其他类型的 Timing 则没有这个问题,然而这个场景里的位移动画必须是带初始速度的 Spring 动画;不过即使此处不要求初始速度>0,通过添加反向动画实现 Additive 效果的做法也会有瑕疵,同样不知是否 Bug:最初添加的动画的运行时间截止时,如果依然添加动画,动画会直接跳跃到最终位置。

其实UIViewPropertyAnimator使用初始速度不为(0, 0)的 Spring Timing 也可以实现 Additive 效果,关键在于isInterruptible属性,默认为 true。禁用这个属性后,UIViewPropertyAnimator完全与 UIView Animation 无异,上段里提到的问题都不存在;然而,禁用这个属性后,UIViewAnimating协议里定义的与交互动画有关的方法和属性都不能使用:包括上面使用的暂停和逆转动画的功能,以及接下来会用到的停止动画的功能,禁用后使用这些方法和属性会触发异常。将UIViewPropertyAnimator当作 UIView Animation 使用的话,去看上篇就好了,我在文末给出的 Demo 里示范了这种用法。

综合来讲,UIViewPropertyAnimator逆转转动画的效果比不上 UIView Animation ,现在暂且带着效果打折的遗憾继续使用UIViewPropertyAnimator来实现滑动交互。

滑动交互:控制进度、平滑转变

当手指接触到视图时,如何中止当前的动画?UIViewPropertyAnimator给了我们两个选择:暂停或停止动画。在使用 UIView Animation 时,我们直接取消了视图的动画,也就是停止动画,这里选择用该类的方式来停止动画:

switch panGesture.state {
case .began:
    //由于暂停后,animator 的 state 依然为 active,只有 running 才能判断是否有动画在运行
    if animator.isRunning{
        animator.stopAnimation(true)//停止动画,传递的参数为true的话,状态变化:state->inactive, running->false               
    }
case .changed:
    /*随手指移动控制板视图*/
case .ended, .cancelled:
    //为保证手指离开屏幕新动画能够与当前的速度保持衔接,需要新的 Spring Animation
    let (springTiming, isUp, targetY) = relayTiming_direction_targetY(withPangesture: panGesture)
    animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: springTiming)
    animator.addAnimations({[unowned self] in
        self.panelView.center.y = targetY //目标位置由手指离开屏幕时的方向决定
    })
    animator.addCompletion({[unowned self] position in
        if position == .end{//只在动画完成了预定目标才更新开关状态
            self.panelOpened = isUp ? false : true
        }
    })
    animator.startAnimation()
default:break
}

停止动画还有另外一种使用方法:

animator.stopAnimation(false)//传递的参数为false 的话,状态变化:state->stoped, running->false
//这个方法只能跟在 stopAnimation(false) 后使用,用来调整动画的最终位置,可以让动画回到初始位置或者直接跳到预定目标位置,但这都会造成视图位置的跳跃
animator.finishAnimation(at: .current)//在手势里,我们应该让其停留在当前的位置

不管手指接触控制板视图时是否在运动中,手指离开屏幕后都需要添加新的弹簧动画。然而上面的方案在特定条件下有漏洞:假设此时控制板处于打开状态(底部位置),用户向上滑动来关闭控制板,滑动结束后控制板在动画中移往顶部位置,如果用户想取消这个操作,于是点击了控制板视图,那么控制板视图最终并不会回到底部位置,而是在中间某个位置(滑动结束时的位置)。造成这个结果的根源在于点击交互的实现手法:如果是通过添加反向的动画来实现逆转,那么就不会出现这个问题;而无论是出于展示新 API 特点的目的还是为了能够在这里使用stopAnimation:方法,我选择了使用isReversed属性来逆转动画。滑动结束后动画的起始位置是手指离开屏幕的位置,使用isReversed逆转动画最终只能回到这个位置,而这个位置肯定和控制板在打开/关闭状态所处的位置有段差距。

选择使用isReversed来逆转动画时,在所有连续类型的手势参与的交互动画里,使用stopAnimation:都会有这样的漏洞。完美的解决方案是在手指接触视图时将其暂停,不过不注意的话也会出现这样的漏洞:

switch panGesture.state {
case .began:
    switch animator.state {
    // 这一步的处理是关键,不然也会出现上面的漏洞。开始滑动时如果没有动画在运行,控制板必定处于打开/关闭状态
    case .inactive://没有动画运行
        configure(animator)/*配置 animator,添加动画,目标位置与手势当前的方向有关*/
        animator.startAnimation()//必须先启动动画才能保证手势结束后 continueAnimation: 的正常运行
        animator.pauseAnimation()
    case .active://有动画在运行
        animator.pauseAnimation()
        if animator.isReversed{
            animator.isReversed = false
        }
    case .stopped: break//不使用stopAnimation(false)是不会出现这个状态的
    }
case .changed:
    /*随手指移动控制板视图: 直接移动视图或者使用 fractionComplete 属性来更新动画的进度*/
case .ended, .cancelled:
    //根据手势的结束状态来计算新的 Spring Timing 和动画的最终方向
    let (springTiming, isUp, _) = relayTiming_direction_targetY(withPangesture: panGesture)
    let isSameDirection: Bool = (panelOpened && isUp) || (!panelOpened && !isUp)
    animator.isReversed = isSameDirection ? false : true //更改动画的方向
    //至关重要的方法,以新的 Spring Timing 继续剩下的动画
    animator.continueAnimation(withTimingParameters: springTiming, durationFactor: 0)
default:break
}

使用pauseAnimation()能够解决这个漏洞的原因在于:在手势的起始阶段为控制板视图提供从底部位置到顶部位置的完整动画,逆转后始终能够回到正确的位置;而使用stopAnimation:时不能提供完整路径的动画。

如果不在手势的起始阶段就添加动画,而是在手势的结束阶段才添加动画,pauseAnimation()也会出现上述漏洞;另一方面,使用stopAnimation:无法在手势的变化阶段控制动画的进度,只能修改视图本身。从这两点考虑,实现转场动画以及在非交互与交互状态之间自由切换应该选择pauseAnimation()这条路线。

continueAnimation(withTimingParameters:durationFactor:)UIViewImplicitlyAnimating协议定义的方法,这是保证交互动画流畅的关键,如同使用 UIView Animation 实现交互动画时 Spring Animation 的作用一样。这个方法将动画的起始位置重置为当前位置,然后继续执行,在这里可以动态修改剩余这段动画运行时的 Timing 和 Duration。withTimingParameters = nil时,以原来的 Timing 运行,这里以springTiming继续剩下的动画;动画的剩余运行时间为durationFactor * durationdurationFactor = 0时,运行时间依然为原来的duration。因此,

animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)

相当于执行animator.startAnimation()来继续动画。

continueAnimation(withTimingParameters:durationFactor:)结束后,animator 的 Timing 依然是初始化时的 Timing,修改只是暂时的;不过durationFactor会修改 animator 原来的的duration(规则未知,每次调用这个方法都会修改,durationFactor = 0不会修改),从而影响后面添加的动画的运行时间,这是个奇怪的设计。

小结

上面的演示主要偏向于突出UIViewPropertyAnimator在交互方面的特性,它也完全可以当作 UIView Animation 一样使用,也可以混合这两种风格,我在 ControlPanelAnimation 中演示了多种风格实现上面的交互动画。不过即使假设实现逆转动画时的各种瑕疵是实现上的 Bug,在让普通的动画实现交互时,UIViewPropertyAnimator相对于 UIView Animation 并不具备优势:相比上篇中使用 UIView Animation 时的简单,UIViewPropertyAnimator引入的交互状态和解决不同转换问题时看似灵活的搭配选择,都显得太复杂了。

不过,使用UIViewPropertyAnimator实现转场动画在非交互与交互状态之间的自由切换是非常方便的,而且还能大幅精简当前复杂的转场协议体系,这得益于其封装的交互功能解决了最困难的部分,具体可查看「iOS 视图控制器转场详解」,Demo: iOS10PushPop

参考:

  1. WWDC 2016 Session 216: Advances in UIKit Animations and Transitions
  2. iOS 视图控制器转场详解
动画
Web note ad 1