本文上下兩篇已授權(quán)在 InfoQ 的移動(dòng)開發(fā)前線公眾號(hào)上首發(fā),微信閱讀地址和 InfoQ 文章鏈接。
不久前結(jié)束的 WWDC 2016 Session 216: Advances in UIKit Animations and Transitions 介紹了 iOS 10 的新動(dòng)畫 API,讓動(dòng)畫與交互無縫連接,這是「開發(fā)者的大事、大快所有人心的大好事」。兩年前 objc.io 在「交互式動(dòng)畫」一文在探討了這個(gè)話題,本文先來探討 iOS 10 以下的系統(tǒng)對(duì)交互動(dòng)畫的支持,在下篇中深度解讀 iOS 10 新 API。
交互動(dòng)畫類型
其實(shí)交互式動(dòng)畫在 iOS 系統(tǒng)里可以說是司空見慣的。在可交互動(dòng)畫的執(zhí)行過程中交互手段(一切控制當(dāng)前動(dòng)畫的手段,主要是手勢(shì))會(huì)隨時(shí)切入動(dòng)畫過程,根據(jù)交互結(jié)束后是否更改了動(dòng)畫流程可以將交互動(dòng)畫分為兩種:一種會(huì)更改動(dòng)畫流程,比如 UIScrollView 的滑動(dòng)動(dòng)畫,如今看來很普通,在 iPhone 問世之初這個(gè)效果可是征服人們的一大利器,「喬布斯在第一次展示 iPhone 時(shí),他特別指出當(dāng)他給別人看了這個(gè)滑動(dòng)例子,別人說的一句話: 當(dāng)這個(gè)界面滑動(dòng)的時(shí)候我就已經(jīng)被征服了。」(出自「交互式動(dòng)畫」一文),在這個(gè)滑動(dòng)動(dòng)畫里每次手指在界面上滑動(dòng)時(shí),前一個(gè)滑動(dòng)動(dòng)畫被中止,當(dāng)手指離開屏幕后,添加一個(gè)新的滑動(dòng)動(dòng)畫;另一種僅僅控制動(dòng)畫進(jìn)度而不修改動(dòng)畫,典型代表是交互轉(zhuǎn)場(chǎng)動(dòng)畫,除了帶來便利的操作,驚艷的轉(zhuǎn)場(chǎng)動(dòng)畫也是個(gè)有力的視覺征服利器。
這兩種交互動(dòng)畫的實(shí)現(xiàn)手法是不一樣的。后一種涉及暫停、恢復(fù)和逆轉(zhuǎn)動(dòng)畫,在系統(tǒng)支持的交互轉(zhuǎn)場(chǎng)里,只需要提供一個(gè)UIPercentDrivenInteractiveTransition
實(shí)例并在交互過程中使用updateInteractiveTransition:
來更新進(jìn)度即可,完全不用我們操心其他事情,實(shí)現(xiàn)非常簡(jiǎn)單。如何在普通的動(dòng)畫上實(shí)現(xiàn)這種控制呢?可以參考三個(gè)月前我在「iOS 開發(fā)」公眾號(hào)上發(fā)表的「iOS 視圖控制器轉(zhuǎn)場(chǎng)詳解」中的「自定義容器控制器轉(zhuǎn)場(chǎng)」章節(jié):暫停和恢復(fù)動(dòng)畫采用官方提供的方法:How to pause the animation of a layer tree?;手動(dòng)控制動(dòng)畫進(jìn)度則需要在暫停動(dòng)畫的基礎(chǔ)上更新 CAMediaTiming 協(xié)議(CALayer 遵守該協(xié)議)中的timeOffset
屬性;而在交互結(jié)束后逆轉(zhuǎn)動(dòng)畫則需要CADisplayLink
的幫助。iOS 10 引入的新 API 對(duì)這些操作進(jìn)行了封裝,實(shí)現(xiàn)會(huì)簡(jiǎn)單得多,同時(shí)兼容了前一種交互動(dòng)畫的實(shí)現(xiàn)方法,打破了兩種交互動(dòng)畫的界限。
objc.io 在「交互式動(dòng)畫」一文中探索了前一種交互式動(dòng)畫,實(shí)現(xiàn)了下面這種類似控制中心的效果:
這個(gè)簡(jiǎn)單的位移動(dòng)畫里包含了兩套交互:滑動(dòng)控制(pan 手勢(shì))和點(diǎn)擊控制(tap 手勢(shì)),要解決三個(gè)轉(zhuǎn)換問題,也是所有交互動(dòng)畫需要解決的問題:
- Animation to Gesture:動(dòng)畫過程中切入滑動(dòng)控制,需要中止當(dāng)前的動(dòng)畫并由手指來控制控制板的移動(dòng);
- Gesture to Animation:滑動(dòng)結(jié)束后添加新的動(dòng)畫,并與當(dāng)前的狀態(tài)平滑銜接;
- Animation to Animation:動(dòng)畫過程中每次點(diǎn)擊視圖后使動(dòng)畫逆轉(zhuǎn)。
objc.io 的兩位作者使用了三種方法來實(shí)現(xiàn)這個(gè)交互動(dòng)畫,手法都是實(shí)現(xiàn)彈簧動(dòng)畫(Spring Animation)去驅(qū)動(dòng)控制板視圖的移動(dòng):
- 基于 UIKit Dynamics 框架,這是 iOS 7 引入的模擬真實(shí)物理行為的動(dòng)畫框架,對(duì)控制板視圖賦予了彈簧的行為,每次移動(dòng)都如同有一個(gè)彈簧將視圖拉向目標(biāo)位置;
- 自己動(dòng)手實(shí)現(xiàn)彈簧動(dòng)畫,所謂動(dòng)畫就是數(shù)值的連續(xù)變化,作者根據(jù)彈簧的胡克定律實(shí)現(xiàn)一個(gè)算法來計(jì)算物體在運(yùn)動(dòng)過程中的位置,前面提到的
CADisplayLink
是個(gè)能夠與屏幕刷新頻率同步的定時(shí)器,通過調(diào)用指定的方法,每次屏幕刷新時(shí)更新視圖位置,效果與普通的動(dòng)畫無異。 - 將在2中實(shí)現(xiàn)的彈簧動(dòng)畫使用 Facebook 的 POP 框架驅(qū)動(dòng)。
這三種方法都沒有使用 UIView Animation 和 Core Animation(前者是后者的封裝),這樣實(shí)現(xiàn)普通動(dòng)畫的交互就比較困難,接下來討論如何使用這兩種動(dòng)畫 API 來實(shí)現(xiàn)上面的交互效果。
Animation to Gesture
添加到 CALayer 上的動(dòng)畫在結(jié)束前如果被取消會(huì)造成視覺突變,比如在一個(gè)右移的動(dòng)畫結(jié)束前取消該動(dòng)畫就會(huì)造成如下所示的跳躍,從中途直接跳到了終點(diǎn):
因此交互動(dòng)畫首要解決的就是一個(gè)很知乎的問題:「如何優(yōu)雅地中止運(yùn)行中的動(dòng)畫而不造成畫面突變?」答案是:取消動(dòng)畫時(shí)讓 modelLayer 的狀態(tài)與當(dāng)前 presentationLayer 的狀態(tài)同步。在手勢(shì)切入控制板的動(dòng)畫過程后這樣做:
let currentPosition = (panelView.layer.presentationLayer() as! CALayer).position
panelView.layer.removeAllAnimations()//或者使用 removeAnimationForKey: 取消指定的動(dòng)畫
panelView.layer.position = currentPosition
這里有個(gè)需要注意的地方,如果你使用 UIView Animation,一定要使用帶options
的 API,且必須將.AllowUserInteraction
作為選項(xiàng)之一,不然在動(dòng)畫運(yùn)行過程中視圖不會(huì)響應(yīng)觸摸事件,使用 Core Animation 則不受此影響。
Gesture to Animation: Spring Animation
上面的目標(biāo)是:滑動(dòng)結(jié)束后添加新的動(dòng)畫,并與當(dāng)前的狀態(tài)平滑銜接。這需要手指離開屏幕后添加的新動(dòng)畫應(yīng)該以手指離開屏幕時(shí)沿 Y 軸的速度開始,否則速度曲線不連續(xù),看著很不自然。離開速度可以從手勢(shì)獲取,但是指定動(dòng)畫的初始速度,在 iOS 7 公開彈簧動(dòng)畫(Spring Animation)接口之前,現(xiàn)有的動(dòng)畫 API 里沒有能夠直接做到這點(diǎn)的,iOS 7 中引入的 UIKit Dynamics 動(dòng)畫框架也可以實(shí)現(xiàn)這個(gè)目標(biāo),除此之外,要么像 objc.io 的兩位作者那樣自己動(dòng)手打造 Spring 效果要么借助第三方的動(dòng)畫庫。
彈簧動(dòng)畫的 API:
animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:
這個(gè) API 在時(shí)間曲線上模擬彈簧的簡(jiǎn)諧運(yùn)動(dòng)(簡(jiǎn)單來講就是來回振蕩),實(shí)現(xiàn)位移動(dòng)畫時(shí)模擬真實(shí)彈簧的行為。
其中的速率參數(shù)initialSpringVelocity
是個(gè)CGFloat
,這顯得很奇怪,為什么不是一個(gè)向量呢?「交互式動(dòng)畫」文中對(duì)此提出了質(zhì)疑:「當(dāng)我們給一個(gè)移動(dòng) view 的動(dòng)畫在其運(yùn)動(dòng)的方向上加一個(gè)初始的速率時(shí),你沒法告知?jiǎng)赢嬤@個(gè) view 現(xiàn)在的運(yùn)動(dòng)狀態(tài),比如我們不知道要添加的動(dòng)畫的方向是不是和原來的 view 的速度方向垂直。為了使其成為可能,這個(gè)速度需要用向量來表示」。實(shí)際上盡管速率參數(shù)是個(gè)數(shù)值而非向量,但彈簧動(dòng)畫的初始速度是有方向的:不管視圖從(100, 100)移動(dòng)到(200, 0),還是從(100, 100)移動(dòng)到(200, 200),初始速度始終是沿著起點(diǎn)到終點(diǎn)的直線方向的。我覺得在這里這兩位作者陷入了一個(gè)誤區(qū),且不說在這個(gè)場(chǎng)景里動(dòng)畫的方向是明確的(Y 軸,起點(diǎn)和終點(diǎn)我們也知道),他們似乎想用彈簧動(dòng)畫來實(shí)現(xiàn)添加反向的動(dòng)畫(即視圖在動(dòng)畫中途返回原點(diǎn),這是第三個(gè)轉(zhuǎn)換問題),這個(gè)質(zhì)疑的本質(zhì)是指彈簧動(dòng)畫無法合成速度,這類似一枚火箭在飛行中啟動(dòng)引擎在相反方向上添加推動(dòng)力來減速直至反向運(yùn)動(dòng)。但彈簧動(dòng)畫和其他的動(dòng)畫 API 都并非由力學(xué)引擎驅(qū)動(dòng),在兩位作者發(fā)布這篇文章的 iOS 7 時(shí)期,彈簧動(dòng)畫是無法做到這點(diǎn)的,從 iOS 8 開始就可以了,但是原因和這個(gè) API 本身沒有關(guān)系,下一節(jié)來解釋。兩位作者最終放棄了使用這個(gè) API,從而使得整個(gè)探索走向了完全不一樣的方向。
另外速率參數(shù)如何設(shè)置也很令人費(fèi)解,文檔里的解釋是這樣的:
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
并非直接指定初始速率,動(dòng)畫初始(變化)速率 = (toValue - fromValue) * initialSpringVelocity
,這種相對(duì)值的設(shè)計(jì)避開了動(dòng)畫的具體變化值,方便使用者估算和設(shè)置動(dòng)畫時(shí)間。那么從(100, 100)移動(dòng)到(300, 300),如果你希望視圖沿著目標(biāo)方向的初始速度為(150, 150),即合成速度約為150 X 1.4(2的開方值) = 210,直線距離約為 200 X 1.4 = 280,那么initialSpringVelocity
約為 210/280 = 0.75。
回到這個(gè)階段的問題本身,怎么解決?
switch panGesture.state {
case .Began:
cancelMoveAnimation()//封裝上一節(jié)中止動(dòng)畫運(yùn)行的代碼
case .Changed:
//隨手指移動(dòng)視圖
let point = panGesture.translationInView(view)
panelView.center.y += point.y
panGesture.setTranslation(CGPointZero, inView: view)
case .Ended, .Cancelled:
//新動(dòng)畫初始速度與手指的速度同步,保證動(dòng)畫流暢自然。
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 //根據(jù)手勢(shì)的方向計(jì)算目標(biāo)位置
}, completion: {/*更新相關(guān)狀態(tài)*/
})
default:break
}
Animation to Animation: Additive Animation
在動(dòng)畫中途點(diǎn)擊控制板視圖后讓視圖返回到原來的位置,做法是再次添加一個(gè)同樣動(dòng)畫屬性的動(dòng)畫(使用 Core Animation 時(shí)注意使用不同的 key),但在效果上完全抵消,效果有如下幾種:
使用 UIView Animation 或者 Core Animation 不做特殊設(shè)置的話,效果是第一種;使用 UIView Animation 時(shí)指定 BeginFromCurrentState 選項(xiàng)的效果是第二種,位置不會(huì)突變但速度有突變;我們需要的是第三種效果,使用 Additive 類型的動(dòng)畫時(shí),在控制板打開或者關(guān)閉過程的任何時(shí)刻點(diǎn)擊視圖,視圖將會(huì)向反方向移動(dòng),動(dòng)畫不會(huì)有位置和速度突變,但 UIView Animation 沒有這個(gè)選項(xiàng)。
在 objc.io 的這篇文章發(fā)布后的半個(gè)多月正是 WWDC 2014 大會(huì),在 Session 236: Building Interruptible and Responsive Interactions 里介紹了解決上述三個(gè)轉(zhuǎn)換問題的方法,上面的動(dòng)圖都截取自該 session,前兩個(gè)問題的解決辦法就是上面說的那些,也提到了 objc.io 這篇文章里中使用的 UIKit Dynamics 這個(gè)技巧,而最為棘手的第三個(gè)問題需要實(shí)現(xiàn) Additive 類型的動(dòng)畫,該效果來自 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 時(shí)必須將該屬性指定為true
,否則不會(huì)出現(xiàn)期待的結(jié)果。不過,在 CABasicAnimation 里使用這個(gè)屬性很需要一番技巧,我在嘗試使用這個(gè)屬性時(shí)總是得不到想要的效果,直到觀看了這個(gè) session 才恍然大悟,原來是這么設(shè)計(jì)的,文檔的解釋是正確的廢話。
如何使用 CABasicAnimation 實(shí)現(xiàn)上面的效果呢?非 Additive 的動(dòng)畫的變化范圍是絕對(duì)值設(shè)計(jì),添加到 presentationLayer 的動(dòng)畫的變化范圍是:fromValue -> toValue,Additive 的動(dòng)畫采用的是相對(duì)值設(shè)計(jì),添加到 presentationLayer 的動(dòng)畫的變化范圍是:modelLayerValue + fromValue -> modelLayerValue + toValue。假設(shè)控制板開關(guān)后的 Y 軸差距為 500,這樣實(shí)現(xiàn) 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 數(shù)據(jù)的代碼在添加動(dòng)畫之后,但無論前后,這兩者是一起提交給渲染進(jìn)程的,所以 toValue 總是 0
if panelOpened{
//向上移動(dòng) 500 單位
openXcloseAni.fromValue = 500
openXcloseAni.toValue = 0
panelView.layer.addAnimation(openXcloseAni, forKey: "open")
panelView.center.y -= 500
}else{
//向下移動(dòng) 500 單位
openXcloseAni.fromValue = -500
openXcloseAni.toValue = 0
panelView.layer.addAnimation(openXcloseAni, forKey: "close")
panelView.center.y += 500
}
panelOpened = !panelOpened
default: break
}
注意指定timingFunction
,該值默認(rèn)為 nil,效果是線性曲線(Linear),兩個(gè)動(dòng)畫疊加后的效果與 BeginFromCurrentState 等同。但Core Animation 也沒有提供 Spring Timing Function,雖然從 iOS 6 起就有人發(fā)現(xiàn)了上面的 CASpringAnimation,但是這個(gè) API 才到 iOS 9 才公開,而且沒有文檔。而 UIView Animation 沒有提供實(shí)現(xiàn) Additive 效果的選項(xiàng),只能退而求其次實(shí)現(xiàn) BeginFromCurrentState 的效果。所以點(diǎn)擊后逆轉(zhuǎn)動(dòng)畫在 iOS 7 上的效果無法完全滿足設(shè)計(jì)的要求,可以依靠一些第三方彈簧動(dòng)畫來彌補(bǔ),比如 RBBAnimation,基于 CAKeyframeAnimation,支持 iOS 6。
iOS 8 中 UIView Animation 默認(rèn)實(shí)現(xiàn)了 Additive 效果,所以從 iOS 8 開始,解決第三個(gè)轉(zhuǎn)換問題就太容易了,直接添加反向的動(dòng)畫即可:
switch tapGeture.state {
case .Ended, .Cancelled:
let targetY = panelOpened ? topY : bottomY //根據(jù)開關(guān)狀態(tài)計(jì)算目標(biāo)位置
UIView.animateWithDuration(duration, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 2, options: .AllowUserInteraction, animations: {
self.panelView.center.y = targetY
}, completion: nil)
panelOpened = !panelOpened
default: break
}
小結(jié)
從代碼上看,無比簡(jiǎn)單。不過別忘了沒有 Additive 類型的動(dòng)畫,objc.io 在「交互式動(dòng)畫」中做出的艱辛探索,實(shí)現(xiàn)成本要高出許多。在 iOS 7 中利用 UIView Animation/Core Animation 實(shí)現(xiàn)交互動(dòng)畫還有不完美的地方,而 UIKit Dynamics 框架是個(gè)非常好的替代選項(xiàng)。從 iOS 8 開始沒有了限制,而 iOS 7 以下的系統(tǒng)則需要自己打造 Spring 動(dòng)畫或者依靠第三方動(dòng)畫庫。
這個(gè)動(dòng)畫的完整代碼可在 ControlPanelAnimation 查看。
參考: