Skip to content

Interactive Animations

seedante edited this page Apr 11, 2017 · 5 revisions

不久前结束的 WWDC 2016 Session 216: Advances in UIKit Animations and Transitions 介绍了 iOS 10 中引入的的新动画 API,让动画与交互无缝连接,这是「开发者的大事、大快所有人心的大好事」。两年前 objc.io 在「交互式动画」一文在探讨了这个话题,在观看这个 session 前我回顾了这篇文章和相关的 session,本文来探讨下来 iOS 上交互动画的解决方案。

交互动画类型

其实交互式动画在 iOS 系统里可以说是司空见惯的。在可交互动画的执行过程中交互手段(一切控制当前动画的手段,主要是手势)会随时切入动画过程,根据交互结束后是否更改了动画流程可以将交互动画分为两种:一种会更改动画流程,比如 UIScrollView 的滑动动画,如今看来很普通,在 iPhone 问世之初这个效果可是征服人们的一大利器,「乔布斯在第一次展示 iPhone 时,他特别指出当他给别人看了这个滑动例子,别人说的一句话: 当这个界面滑动的时候我就已经被征服了。」(出自「交互式动画」一文),在这个滑动动画里每次手指在界面上滑动时,前一个滑动动画被中止,当手指离开屏幕后,添加一个新的滑动动画;另一种仅仅控制动画进度而不修改动画,典型代表是交互转场动画,除了带来便利的操作,惊艳的转场动画也是个有力的视觉征服利器。

这两种交互动画的实现手法是完全不一样的。后一种涉及暂停、恢复和逆转动画,在系统支持的交互转场里,只需要提供一个UIPercentDrivenInteractiveTransition实例并在交互过程中使用updateInteractiveTransition:来更新进度即可,完全不用我们操心其他事情,实现非常简单。如何在普通的动画上实现这种控制呢?可以参考三个月前我在唐巧前辈维护的微信公众号「iOSDevTips」上发表的「iOS 视图控制器转场详解」中的「自定义容器控制器转场」章节:暂停和恢复动画采用官方提供的方法:How to pause the animation of a layer tree?;手动控制动画进度则需要在暂停动画的基础上更新 CAMediaTiming 协议(CALayer 遵守该协议)中的timeOffset属性;而在交互结束后逆转动画则需要CADisplayLink的帮助。iOS 10 引入的新 API 对这些操作进行了封装,实现会简单得多,同时兼容了前一种交互动画的实现方法,打破了两种交互动画的界限。

objc.io 在「交互式动画」一文中探索了前一种交互式动画,实现了下面这种类似控制中心的效果:

Pane Control Interactive Animation.gif

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

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

objc.io 的两位作者使用了三种方法来实现这个交互动画,手法都是实现弹簧动画(Spring Animation)去驱动控制板视图的移动:

  1. 基于 UIKit Dynamics 框架,这是 iOS 7 引入的模拟真实物理行为的动画框架,对控制板视图赋予了弹簧的行为,每次移动都如同有一个弹簧将视图拉向目标位置;
  2. 自己动手实现弹簧动画,所谓动画就是数值的连续变化,作者根据弹簧的胡克定律实现一个算法来计算物体在运动过程中的位置,前面提到的CADisplayLink是个能够与屏幕刷新频率同步的定时器,通过调用指定的方法,每次屏幕刷新时更新视图位置,效果与普通的动画无异。
  3. 将在2中实现的弹簧动画使用 Facebook 的 POP 框架驱动。

这三种方法都没有使用 UIView Animation 和 Core Animation(前者是后者的封装),这样就无法将普通的动画也变得交互起来,接下来讨论下如何使用这两种动画 API 来实现上面的效果。

动画与交互的转换

Animation to Gesture

添加到 CALayer 上的动画在结束前如果被取消会造成视觉突变,比如在一个右移的动画结束前取消该动画就会造成如下所示的跳跃,从中途直接跳到了终点:

AnimationJump.gif

因此交互动画首要解决的就是一个很知乎的问题:「如何优雅地中止运行中的动画而不造成画面突变?」答案是:取消动画时让 modelLayer 的状态与当前 presentationLayer 的状态同步。在手势切入控制板的动画过程后这样做:

let currentPosition = (panelView.layer.presentationLayer() as! CALayer).position
panelView.layer.removeAllAnimations()//或者使用 removeAnimationForKey: 取消指定的动画
panelView.layer.position = currentPosition

这里有个需要注意的地方,如果你使用 UIView Animation,一定要使用带options的 API,且必须将.AllowUserInteraction作为选项之一,不然在动画运行过程中视图不会响应触摸事件,使用 Core Animation 则不受此影响。

Gesture to Animation: Spring Animation

上面的目标是:滑动结束后添加新的动画,并与当前的状态平滑衔接。这需要手指离开屏幕后添加的新动画应该以手指离开屏幕时沿 Y 轴的速度开始,否则速度曲线不连续,看着很不自然。离开速度可以从手势获取,但是指定动画的初始速度,在 iOS 7 公开弹簧动画(Spring Animation)接口之前,现有的动画 API 里没有能够直接做到这点的,iOS 7 中引入的 UIKit Dynamics 动画框架也可以实现这个目标,除此之外,要么像 objc.io 的两位作者那样自己动手打造 Spring 效果要么借助第三方的动画库。

弹簧动画的 API:

animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:

其中的速率参数initialSpringVelocity是个CGFloat,这显得很奇怪,为什么不是一个向量呢?「交互式动画」文中对此提出了质疑:「当我们给一个移动 view 的动画在其运动的方向上加一个初始的速率时,你没法告知动画这个 view 现在的运动状态,比如我们不知道要添加的动画的方向是不是和原来的 view 的速度方向垂直。为了使其成为可能,这个速度需要用向量来表示」。实际上尽管速率参数是个数值而非向量,但弹簧动画的初始速度是有方向的:不管视图从(100, 100)移动到(200, 0),还是从(100, 100)移动到(200, 200),初始速度始终是沿着起点到终点的直线方向的。我觉得在这里这两位作者陷入了一个误区,且不说在这个场景里动画的方向是明确的(Y 轴,起点和终点我们也知道),他们似乎想用弹簧动画来实现添加反向的动画(即视图在动画中途返回原点,这是第三个转换问题),这个质疑的本质是指弹簧动画无法合成速度,这类似一枚火箭在飞行中启动引擎在相反方向上添加推动力来减速直至反向运动。但弹簧动画和其他的动画 API 都并非由力学引擎驱动,在两位作者发布这篇文章的 iOS 7 时期,弹簧动画是无法做到这点的,从 iOS 8 开始就可以了,但是原因和这个 API 本身没有关系,下一节来解释。两位作者最终放弃了使用这个 API,从而使得整个探索走向了完全不一样的方向。

弹簧动画在时间曲线上模拟弹簧的简谐运动(简单来讲就是来回振荡),实现位移动画时模拟真实弹簧的行为。既然有了速率,为何还有时间参数,毕竟弹簧的各项物理参数确定的情况下,运动时间是固定的,而且从弹簧的行为模式来说,这个 API 还缺乏物理参数,与真实的弹簧行为略有差异,具体可阅读 Your Spring Animations Are Bad(And It’s Probably Apple’s Fault)。这个 API 实际上是 CASpringAnimation 的简化封装版本(或者说阉割版本),我猜测简化的目的为了方便使用,因为 CASpringAnimation 的动画时间是由我们给定的(四个)物理参数决定,非常不直观。

速率参数如何设置也很令人费解,文档里的解释是这样的:

A value of 1 corresponds to the total animation distance traversed in one second. For example, if the total animation distance is 200 points and you want the start of the animation to match a view velocity of 100 pt/s, use a value of 0.5.

initialSpringVelocity并非直接指定初始速率,动画初始(变化)速率 = (toValue - fromValue) * initialSpringVelocity,这种相对值的设计避开了动画的具体变化值,方便使用者估算和设置动画时间。那么从(100, 100)移动到(300, 300),如果你希望视图沿着目标方向的初始速度为(150, 150),即合成速度约为150 X 1.4(2的开方值) = 210,直线距离约为 200 X 1.4 = 280,那么initialSpringVelocity约为 210/280 = 0.75。

Timing Curve: Spring VS EaseInOut

看看弹簧动画的速率曲线,其初期的速率非常大,这种设计意图使用剧烈的变化来吸引用户的注意,从 iOS 7 开始系统的大部分动画都是采用弹簧动画来实现的,比如 App/App 文件夹打开的动画,push & pop 操作;在弹簧动画的前半部分时间,已经完成了动画的绝大部分进度,因此在后期速率降到很低,当阻尼系数Damping比较低时,动画表现为在目标位置来回摆动,就像弹簧一样。而duration与这两个参数结合来调整速率曲线,如下图:

回到这个阶段的问题本身,怎么解决?

switch panGesture.state {
case .Began:
    cancelMoveAnimation()//封装上一节中止动画运行的代码
case .Changed:
    //随手指移动视图
    let point = panGesture.translationInView(view)
    panelView.center.y += point.y
    panGesture.setTranslation(CGPointZero, inView: view)
case .Ended, .Cancelled:
    //新动画初始速度与手指的速度同步,保证动画流畅自然。
    let gestureVelocity = panGesture.velocityInView(view)
    let velocity = abs(gestureVelocity.y) / abs(paneView.center.y - targetY)
    UIView.animateWithDuration(0.5, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: velocity, options: .AllowUserInteraction, animations: {
        panelView.center.y = targetY // 根据手势的方向计算目标位置
        }, completion: {/*更新相关状态*/
    })
default:break
}

Animation to Animation: Additive Animation

在动画中途点击控制板视图后让视图返回到原来的位置,做法是再次添加一个同样动画属性的动画(使用 Core Animation 时注意使用不同的 key),但在效果上完全抵消,效果有如下几种:

AnimationDiff.gif

使用 UIView Animation 或者 Core Animation 不做特殊设置的话,效果是第一种;使用 UIView Animation API 指定 BeginFromCurrentState 选项的效果是第二种,位置不会突变但速度有突变;我们需要的是第三种效果,使用 Additive 类型的动画时,在控制板打开或者关闭过程的任何时刻点击视图,视图将会向反方向移动,动画不会有位置和速度突变,但 UIView Animation 没有这个选项。

在 objc.io 的这篇文章发布后的半个多月正是 WWDC 2014 大会,在 Session 236: Building Interruptible and Responsive Interactions 里介绍了解决上述三个转换问题的方法,上面的动图都截取自该 session,前两个问题的解决办法就是上面说的那些,也提到了 objc.io 这篇文章里中使用的 UIKit Dynamics 这个技巧,而最为棘手的第三个问题需要实现 Additive 类型的动画,该效果来自 CAAnimation 子类 CAPropertyAnimation 的additive属性。

常用的动画类

additive属性自 iOS 2 起就存在,文档解释:

If YES, the value specified by the animation will be added to the current render tree value of the property to produce the new render tree value. The addition function is type-dependent, e.g. for affine transforms the two matrices are concatenated. The default is NO.

使用 CAKeyframeAnimation 时必须将该属性指定为true,否则不会出现期待的结果。不过,在 CABasicAnimation 里使用这个属性很需要一番技巧,我在尝试使用这个属性时总是得不到想要的效果,直到观看了这个 session 才恍然大悟,原来是这么设计的,文档的解释是正确的废话。

如何使用 CABasicAnimation 实现上面的效果呢?非 Additive 的动画的变化范围是绝对值设计,添加到 presentationLayer 的动画的变化范围是:fromValue -> toValue,Additive 的动画采用的是相对值设计,添加到 presentationLayer 的动画的变化范围是:modelLayerValue + fromValue -> modelLayerValue + toValue。假设控制板开关后的 Y 轴差距为 500,这样实现 Additive 效果:

switch tapGeture.state {
case .Ended, .Cancelled:
    let openXcloseAni = CABasicAnimation(keyPath: "position.y")
    openXcloseAni.duration = 1
    openXcloseAni.additive = true //注意开启这个属性
    openXcloseAni.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionDefault)
    //尽管这里修改 modelLayer 数据的代码在添加动画之后,但无论前后,这两者是一起提交给渲染进程的,所以 toValue 总是 0
    if paneOpened{
        //向上移动 500 单位
        openXcloseAni.fromValue = 500
        openXcloseAni.toValue = 0
        paneView.layer.addAnimation(openXcloseAni, forKey: "open")
        paneView.center.y -= 500 
    }else{
        //向下移动 500 单位
        openXcloseAni.fromValue = -500
        openXcloseAni.toValue = 0
        paneView.layer.addAnimation(openXcloseAni, forKey: "close")
        paneView.center.y += 500
    }
    paneOpened = !paneOpened
default: break
}

注意指定timingFunction,该值默认为 nil,效果是线性曲线(Linear),两个动画叠加后的效果与 BeginFromCurrentState 等同。但Core Animation 也没有提供 Spring Timing Function,虽然从 iOS 6 起就有人发现了上面的 CASpringAnimation,但是这个 API 才到 iOS 9 才公开,而且没有文档。而 UIView Animation 没有提供实现 Additive 效果的选项,只能退而求其次实现 BeginFromCurrentState 的效果。所以点击后逆转动画在 iOS 7 上的效果无法完全满足设计的要求,可以依靠一些第三方弹簧动画来弥补,比如 RBBAnimation,基于 CAKeyframeAnimation,支持 iOS 6。

iOS 8 中 UIView Animation 默认实现了 Additive 效果,所以从 iOS 8 开始,解决第三个转换问题(添加同属性动画)就太容易了,直接使用 UIView Animation API 添加新动画即可。

switch tapGeture.state {
case .Ended, .Cancelled:
   let targetY = panelOpened ? topY : bottomY //根据开关状态计算目标位置
   UIView.animateWithDuration(duration, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 2, options: .AllowUserInteraction, animations: {
       self.panelView.center.y = targetY
       }, completion: nil)
   panelOpened = !panelOpened
default: break
}

iOS 10 新 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。

iOS 10 新 API 实践

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

点击交互:逆转动画

先进行设置:

//这个场景里需要使用具有初速度的弹簧动画,使用 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 当前有动画运行,就会出现上面的 Additive 效果。
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()这条路线。而stopAnimation:适合实现 UIScrollView 滑动动画,这类交互动画的目标值会不断变化,必须在中断后添加新的动画。

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不会修改),从而影响后面添加的动画的运行时间,这是个奇怪的设计。

总结

从代码上看,使用 UIView Animation 实现交互动画无比简单。不过别忘了没有 Additive 类型的动画,objc.io 在「交互式动画」中做出的艰辛探索,实现成本要高出许多。在 iOS 7 中利用 UIView Animation/Core Animation 实现交互动画还有不完美的地方,而 UIKit Dynamics 框架是个非常好的替代选项。从 iOS 8 开始没有了限制,而 iOS 7 以下的系统则需要自己打造 Spring 动画或者依靠第三方动画库。

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

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

完整代码可在 ControlPanelAnimation 查看。

细节补充

UIView Animation Additive 效果的适用范围与限制:

  1. 支持的动画属性: center, frame, bounds, transform, and layer.transform(基本上只适用于几何属性)。
  2. 不支持与以下动画混用: Keyframe Animation, repeating Animation, absolute Animation。

UIViewPropertyAnimator的适用范围与改进:

  1. 支持的动画属性:frame, center, alpha, and transform。
  2. 支持 Keyframe Animation。

UIViewPropertyAnimator的(疑似) Bug 总结:

  1. 现有遵守UITimingCurveProvider协议的两个类在 Additive 效果都有缺陷:使用UISpringTimingParameters时初始速度必须为(0, 0);使用UICubicTimingParameters则无法无限制地添加动画,当原来设定的动画时间截止时,添加的动画就会直接跳转到最终位置。
  2. continueAnimation(withTimingParameters:durationFactor:)的诡异设计:withTimingParameters只是临时修改时间函数,而durationFactor在不为0时则会修改原来的动画时间。

我在推特上向这次 Session 的主讲人提问了上面两个问题,不过一直没有得到回应。

UIViewPropertyAnimator的确凿 Bug:

  1. 对 transform 的支持,虽然文档表示支持,但是在使用它来实现转场动画时,不能正常完成动画。

参考:

  1. objc.io 第12期专题:动画
  2. WWDC 2014 Session 236: Building Interruptible and Responsive Interactions
  3. WWDC 2014 Session 221: Creating Custom iOS User Interfaces
  4. WWDC 2016 Session 216: Advances in UIKit Animations and Transitions
  5. iOS 10 API Diff
  6. iOS 视图控制器转场详解
  7. 使用Facebook Pop框架填补手势与动画间的差距