本文上下两篇已授权在 InfoQ 的移动开发前线公众号上首发,微信阅读地址和 InfoQ 文章链接。
不久前结束的 WWDC 2016 Session 216: Advances in UIKit Animations and Transitions 介绍了 iOS 10 的新动画 API,让动画与交互无缝连接,这是「开发者的大事、大快所有人心的大好事」。两年前 objc.io 在「交互式动画」一文在探讨了这个话题,本文先来探讨 iOS 10 以下的系统对交互动画的支持,在下篇中深度解读 iOS 10 新 API。
交互动画类型
其实交互式动画在 iOS 系统里可以说是司空见惯的。在可交互动画的执行过程中交互手段(一切控制当前动画的手段,主要是手势)会随时切入动画过程,根据交互结束后是否更改了动画流程可以将交互动画分为两种:一种会更改动画流程,比如 UIScrollView 的滑动动画,如今看来很普通,在 iPhone 问世之初这个效果可是征服人们的一大利器,「乔布斯在第一次展示 iPhone 时,他特别指出当他给别人看了这个滑动例子,别人说的一句话: 当这个界面滑动的时候我就已经被征服了。」(出自「交互式动画」一文),在这个滑动动画里每次手指在界面上滑动时,前一个滑动动画被中止,当手指离开屏幕后,添加一个新的滑动动画;另一种仅仅控制动画进度而不修改动画,典型代表是交互转场动画,除了带来便利的操作,惊艳的转场动画也是个有力的视觉征服利器。
这两种交互动画的实现手法是不一样的。后一种涉及暂停、恢复和逆转动画,在系统支持的交互转场里,只需要提供一个UIPercentDrivenInteractiveTransition
实例并在交互过程中使用updateInteractiveTransition:
来更新进度即可,完全不用我们操心其他事情,实现非常简单。如何在普通的动画上实现这种控制呢?可以参考三个月前我在「iOS 开发」公众号上发表的「iOS 视图控制器转场详解」中的「自定义容器控制器转场」章节:暂停和恢复动画采用官方提供的方法:How to pause the animation of a layer tree?;手动控制动画进度则需要在暂停动画的基础上更新 CAMediaTiming 协议(CALayer 遵守该协议)中的timeOffset
属性;而在交互结束后逆转动画则需要CADisplayLink
的帮助。iOS 10 引入的新 API 对这些操作进行了封装,实现会简单得多,同时兼容了前一种交互动画的实现方法,打破了两种交互动画的界限。
objc.io 在「交互式动画」一文中探索了前一种交互式动画,实现了下面这种类似控制中心的效果:
这个简单的位移动画里包含了两套交互:滑动控制(pan 手势)和点击控制(tap 手势),要解决三个转换问题,也是所有交互动画需要解决的问题:
- Animation to Gesture:动画过程中切入滑动控制,需要中止当前的动画并由手指来控制控制板的移动;
- Gesture to Animation:滑动结束后添加新的动画,并与当前的状态平滑衔接;
- Animation to Animation:动画过程中每次点击视图后使动画逆转。
objc.io 的两位作者使用了三种方法来实现这个交互动画,手法都是实现弹簧动画(Spring Animation)去驱动控制板视图的移动:
- 基于 UIKit Dynamics 框架,这是 iOS 7 引入的模拟真实物理行为的动画框架,对控制板视图赋予了弹簧的行为,每次移动都如同有一个弹簧将视图拉向目标位置;
- 自己动手实现弹簧动画,所谓动画就是数值的连续变化,作者根据弹簧的胡克定律实现一个算法来计算物体在运动过程中的位置,前面提到的
CADisplayLink
是个能够与屏幕刷新频率同步的定时器,通过调用指定的方法,每次屏幕刷新时更新视图位置,效果与普通的动画无异。 - 将在2中实现的弹簧动画使用 Facebook 的 POP 框架驱动。
这三种方法都没有使用 UIView Animation 和 Core Animation(前者是后者的封装),这样实现普通动画的交互就比较困难,接下来讨论如何使用这两种动画 API 来实现上面的交互效果。
Animation to Gesture
添加到 CALayer 上的动画在结束前如果被取消会造成视觉突变,比如在一个右移的动画结束前取消该动画就会造成如下所示的跳跃,从中途直接跳到了终点:
因此交互动画首要解决的就是一个很知乎的问题:「如何优雅地中止运行中的动画而不造成画面突变?」答案是:取消动画时让 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:
这个 API 在时间曲线上模拟弹簧的简谐运动(简单来讲就是来回振荡),实现位移动画时模拟真实弹簧的行为。
其中的速率参数initialSpringVelocity
是个CGFloat
,这显得很奇怪,为什么不是一个向量呢?「交互式动画」文中对此提出了质疑:「当我们给一个移动 view 的动画在其运动的方向上加一个初始的速率时,你没法告知动画这个 view 现在的运动状态,比如我们不知道要添加的动画的方向是不是和原来的 view 的速度方向垂直。为了使其成为可能,这个速度需要用向量来表示」。实际上尽管速率参数是个数值而非向量,但弹簧动画的初始速度是有方向的:不管视图从(100, 100)移动到(200, 0),还是从(100, 100)移动到(200, 200),初始速度始终是沿着起点到终点的直线方向的。我觉得在这里这两位作者陷入了一个误区,且不说在这个场景里动画的方向是明确的(Y 轴,起点和终点我们也知道),他们似乎想用弹簧动画来实现添加反向的动画(即视图在动画中途返回原点,这是第三个转换问题),这个质疑的本质是指弹簧动画无法合成速度,这类似一枚火箭在飞行中启动引擎在相反方向上添加推动力来减速直至反向运动。但弹簧动画和其他的动画 API 都并非由力学引擎驱动,在两位作者发布这篇文章的 iOS 7 时期,弹簧动画是无法做到这点的,从 iOS 8 开始就可以了,但是原因和这个 API 本身没有关系,下一节来解释。两位作者最终放弃了使用这个 API,从而使得整个探索走向了完全不一样的方向。
另外速率参数如何设置也很令人费解,文档里的解释是这样的:
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。
回到这个阶段的问题本身,怎么解决?
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: {
self.panelView.center.y = targetY //根据手势的方向计算目标位置
}, completion: {/*更新相关状态*/
})
default:break
}
Animation to Animation: Additive Animation
在动画中途点击控制板视图后让视图返回到原来的位置,做法是再次添加一个同样动画属性的动画(使用 Core Animation 时注意使用不同的 key),但在效果上完全抵消,效果有如下几种:
使用 UIView Animation 或者 Core Animation 不做特殊设置的话,效果是第一种;使用 UIView Animation 时指定 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 //注意开启 additive 属性
openXcloseAni.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionDefault)
//尽管这里修改 modelLayer 数据的代码在添加动画之后,但无论前后,这两者是一起提交给渲染进程的,所以 toValue 总是 0
if panelOpened{
//向上移动 500 单位
openXcloseAni.fromValue = 500
openXcloseAni.toValue = 0
panelView.layer.addAnimation(openXcloseAni, forKey: "open")
panelView.center.y -= 500
}else{
//向下移动 500 单位
openXcloseAni.fromValue = -500
openXcloseAni.toValue = 0
panelView.layer.addAnimation(openXcloseAni, forKey: "close")
panelView.center.y += 500
}
panelOpened = !panelOpened
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 开始,解决第三个转换问题就太容易了,直接添加反向的动画即可:
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
}
小结
从代码上看,无比简单。不过别忘了没有 Additive 类型的动画,objc.io 在「交互式动画」中做出的艰辛探索,实现成本要高出许多。在 iOS 7 中利用 UIView Animation/Core Animation 实现交互动画还有不完美的地方,而 UIKit Dynamics 框架是个非常好的替代选项。从 iOS 8 开始没有了限制,而 iOS 7 以下的系统则需要自己打造 Spring 动画或者依靠第三方动画库。
这个动画的完整代码可在 ControlPanelAnimation 查看。
参考: