交互式动画(上):iOS 10 以下的实现

本文上下两篇已授权在 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 在「交互式动画」一文中探索了前一种交互式动画,实现了下面这种类似控制中心的效果:

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.mov.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:

这个 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),但在效果上完全抵消,效果有如下几种:

AnimationDiff.mov.gif

使用 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 查看。

参考:

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

推荐阅读更多精彩内容