在2007年,賈伯斯在第一次介紹 iPhone 的時候,iPhone 的觸控式螢幕互動簡直就像是一種魔法。最好的例子就是在他第一次滑動 TableView 的展示上。你可以感受到當時觀眾的反應是多麼驚訝,但是對於現在的我們來說早已習以為常。在展示的後面一部分,他特別指出當他給別人看了這個滑動例子,別人說的一句話: “當這個介面滑動的時候我就已經被征服了”.
是什麼樣的滑動能讓人有‘哇哦’的效果呢?
滑動是最完美地展示了通過觸控式螢幕直接操作的例子。滾動檢視遵從於你的手指,當你的手指離開螢幕的時,檢視會自然地繼續滑動直到該停止的時候停止。它用自然的方式減速,甚至在快到界限的時候也能表現出細膩的彈力效果。滑動在任何時候都保持相應,並且看上去非常真實。
動畫的狀態
在 iOS 中的大部分動畫仍然沒有按照最初 iPhone 指定的滑動標準實現。這裡有很多動畫一旦它們執行就不能互動(比如說解鎖動畫,主介面中開啟資料夾和關閉資料夾的動畫,和導航欄切換的動畫,還有很多)。
然而現在有一些應用給我一種始終在控制動畫的體驗,我們可以直接操作那些我在用的動畫。當我們將這些應用和其他的應用相比較之後,我們就能感覺到明 顯的區別。這些應用中最優秀的有最初的 Twitter iPad app, 和現在的 Facebook Paper。但目前,使用直接操作為主並且可以中斷動畫的應用仍然很少。這就給我們做出更好的應用提供了機會,讓我們的應用有更不同的,更高質量的體驗。
真實互動式動畫的挑戰
當我們用 UIView 或者 CAAnimation 來實現互動式動畫時會有兩個大問題: 這些動畫會將你在螢幕上的內容和 layer 上的實際的特定屬性分離開來,並且他們直接操作這些特定屬性。
模型 (Model) 和顯示 (Presentation) 的分離
Core Animation 是通過分離 layer 的模型屬性和你在螢幕上看到的介面 (顯示層) 的方式來設計的,這就導致我們很難去建立一個可以在任何時候能互動的動畫,因為在動畫時,模型和介面已經不能匹配了。這時,我們不得不通過手動的方式來同 步這兩個的狀態,來達到改變動畫的效果:
1 2 3 |
view.layer.center = view.layer.presentationLayer.center; [view.layer removeAnimationForKey:@"animation"]; // 新增新動畫 |
直接控制 vs 間接控制
CAAnimation
動畫的更大的問題是它們是直接在 layer 上對屬性進行操作的。這意味著什麼呢?比如我們想指定一個 layer 從座標為 (100, 100) 的位置運動到 (300, 300) 的位置,但是在它運動到中間的時候,我們想它停下來並且讓它回到它原來的位置,事情就變得非常複雜了。如果你只是簡單地刪除當前的動畫然後再新增一個新 的,那麼這個 layer 的速率就會不連續。
然而,我們想要的是一個漂亮的,流暢地減速和加速的動畫。
只有通過間接操作動畫才能達到上面的效果,比如通過模擬力在介面上的表現。新的動畫需要用 layer 的當前速度向量作為引數傳入來達到流暢的效果。
看一下 UIView 中關於彈簧動畫的 API (animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:
),你會注意到速率是個 CGFloat
。所以當我們給一個移動 view 的動畫在其運動的方向上加一個初始的速率時,你沒法告知動畫這個 view 現在的運動狀態,比如我們不知道要新增的動畫的方向是不是和原來的 view 的速度方向垂直。為了使其成為可能,這個速度需要用向量來表示。
解決方案
讓我們看一下我們怎樣來正確實現一個可互動並且可以中斷的動畫。我們來做一個類似於控制中心板的東西來實現這個效果:
這個控制板有兩個狀態:開啟和關閉。你可以通過點選來切換這兩個狀態,或者通過上下拖動來調調整它向上或向下。我要將這個控制皮膚的所有狀態都做到 可以互動,甚至是在動畫的過程中也可以,這是一個很大的挑戰。比如,當你在這個控制板還沒有切換到開啟狀態的動畫過程中,你點選了它,那麼它應該從現在這 個點的位置馬上回到關閉狀態的位置。在現在很多的應用中,大部分都是用預設的動畫 API,你必須要等一個動畫結束之後你才能做自己想做的事情。或者,如果你不等待的話,就會看到一個不連續的速度曲線。我們要解決這個問題。
UIKit 力學
隨著 iOS7 的釋出,蘋果向我們展示了一個叫 UIKit 力學的動畫框架 (可以參見 WWDC 2013 sessions 206 和 221)。UIKit 力學是一個基於模擬物理引擎的框架,只要你新增指定的行為到動畫物件上來實現 UIDynamicItem 協議就能實現很多動畫。這個框架非常強大,並且它能夠在多個物體間啟用像是附著和碰撞這樣的複雜行為。請看一下 UIKit Dynamics Catalog,確認一下什麼是可用的。
因為 UIKit 力學中的的動畫是被間接驅動的,就像我在上面提到的,這使我們實現真實的互動式動畫成為可能,它能在任何時候被中斷並且擁有連續的加速度。同 時,UIKit 力學在物理層的抽象上能完全勝任我們一般情況下在使用者介面中的所需要的所有動畫。其實在大部分情況下,我們只會用到其中的一小部分功能。
定義行為
為了實現我們的控制板的行為,我們將使用 UIkit 力學中的兩個不同行為:UIAttachmentBehavior 和 UIDynamicItemBehavior。附著行為用來扮演彈簧的角色,它將介面向目標點拉動。另一方面,我們用動態 item behvaior 定義了比如摩擦係數這樣的介面的內建屬性。
我建立了一個我們自己的行為子類,以將這兩個行為封裝到我們的控制板上:
1 2 3 4 5 6 7 8 |
@interface PaneBehavior : UIDynamicBehavior @property (nonatomic) CGPoint targetPoint; @property (nonatomic) CGPoint velocity; - (instancetype)initWithItem:(id <UIDynamicItem>)item; @end |
我們通過一個 dynamic item 來初始化這個行為,然後就可以設定它的目標點和我們想要的任何速度。在內部,我們建立了附著行為和 dynamic item 行為,並且將這些行為新增到我們自定義的行為中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
- (void)setup { UIAttachmentBehavior *attachmentBehavior = [[UIAttachmentBehavior alloc] initWithItem:self.item attachedToAnchor:CGPointZero]; attachmentBehavior.frequency = 3.5; attachmentBehavior.damping = .4; attachmentBehavior.length = 0; [self addChildBehavior:attachmentBehavior]; self.attachmentBehavior = attachmentBehavior; UIDynamicItemBehavior *itemBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.item]]; itemBehavior.density = 100; itemBehavior.resistance = 10; [self addChildBehavior:itemBehavior]; self.itemBehavior = itemBehavior; } |
為了用 targetPoint
和 velocity
屬性來影響 item 的 behavior,我們需要重寫它們的 setter 方法,並且分別修改在附著行為和 item behaviors 中的對應的屬性。我們對目標點的 setter 方法來說,這個改動很簡單:
1 2 3 4 5 |
- (void)setTargetPoint:(CGPoint)targetPoint { _targetPoint = targetPoint; self.attachmentBehavior.anchorPoint = targetPoint; } |
對於 velocity
屬性,我們需要多做一些工作,因為 dynamic item behavior 只允許相對地改變速度。這就意味如果我們要將 velocity
設定為絕對值,首先我們就需要得到當前的速度,然後再加上速度差才能得到我們的目標速度。
1 2 3 4 5 6 7 |
- (void)setVelocity:(CGPoint)velocity { _velocity = velocity; CGPoint currentVelocity = [self.itemBehavior linearVelocityForItem:self.item]; CGPoint velocityDelta = CGPointMake(velocity.x - currentVelocity.x, velocity.y - currentVelocity.y); [self.itemBehavior addLinearVelocity:velocityDelta forItem:self.item]; } |
將Behavior投入使用
我們的控制板有三個不同狀態:在開始或結束位置的靜止狀態,正在被使用者拖動的狀態,以及在沒有使用者控制時運動到結束位置的動畫狀態。
為了將從直接操作狀態 (使用者拖動這個滑動板) 過渡到動畫狀態這個過程做的流暢,我們還有很多其他的事要做。當使用者停止拖動控制板時,它會傳送一個訊息到它的 delegate。根據這個方法,我們可以知道這個板應該朝哪個方向運動,然後在我們自定義的 PaneBehavior
上設定結束點,以及初始速度 (這非常重要),並將行為新增到動畫器中去,以此確保從拖動操作到動畫狀態這個過程能夠非常流暢。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
- (void)draggableView:(DraggableView *)view draggingEndedWithVelocity:(CGPoint)velocity { PaneState targetState = velocity.y >= 0 ? PaneStateClosed : PaneStateOpen; [self animatePaneToState:targetState initialVelocity:velocity]; } - (void)animatePaneToState:(PaneState)targetState initialVelocity:(CGPoint)velocity { if (!self.paneBehavior) { PaneBehavior *behavior = [[PaneBehavior alloc] initWithItem:self.pane]; self.paneBehavior = behavior; } self.paneBehavior.targetPoint = [self targetPointForState:targetState]; if (!CGPointEqualToPoint(velocity, CGPointZero)) { self.paneBehavior.velocity = velocity; } [self.animator addBehavior:self.paneBehavior]; self.paneState = targetState; } |
一旦使用者用他的手指再次觸動控制板時,我必須要將所有的 dynamic behavior 從 animator 刪除,這樣才不會影響控制板對拖動手勢的響應:
1 2 3 4 |
- (void)draggableViewBeganDragging:(DraggableView *)view { [self.animator removeAllBehaviors]; } |
我們不僅僅允許控制板可以被拖動,還要允許它可以被點選,讓它可以從一個位置跳轉到另一個位置以達到開關的效果。一旦點選事件發生,我們就會立即調整這個滑動板的目標位置。因為我們不能直接控制動畫,但是通過彈力和摩擦力,我們的動畫可以非常流暢地執行這個動作:
1 2 3 4 5 |
- (void)didTap:(UITapGestureRecognizer *)tapRecognizer { PaneState targetState = self.paneState == PaneStateOpen ? PaneStateClosed : PaneStateOpen; [self animatePaneToState:targetState initialVelocity:CGPointZero]; } |
這樣就實現了我們的大部分功能了。你可以在 GitHub 上檢視完整的例子。
重申一點:UIKit 力學可以通過在介面上模擬力來間接地驅動動畫(我們的例子中,使用的是彈力和摩擦力)。這間接地使我們在任何時候都能以連續的速度曲線來與介面進行互動。
現在我們已經通過 UIKit 力學實現了整個互動,讓我們回顧一下這個場景。這個例子的動畫中我們只用了 UIKit 力學中一小部分功能,並且它的實現方式也非常簡單。對於我們來說這是一個去理解它其中的過程的很好的例子,但是如果我們使用的環境中沒有 UIKit 力學 (比如說在 Mac 上),或者你的使用場景中不能很好的適用 UIKit 力學呢。
自己操作動畫
至於在你的應用中大部分時間會用的動畫,比如簡單的彈力動畫,我們控制它真的不難。我們可以做一個練習,來看看如何拋棄 UIKit 力學這個巨大的黑盒子,看要如何“手動”來實現一個簡單的互動。想法非常簡單:我們只要每秒修改這個 view 的 frame 60 次。每一幀我們都基於當前速度和作用在 view 上的力來調整 view 的 frame 就可以了。
物理原理
首先讓我們看一下我們需要知道的基礎物理知識,這樣我們才能實現出剛才使用 UIKit 力學實現的那種彈簧動畫效果。為了簡化問題,雖然引入第二個維度也是很直接的,但我們在這裡只關注一維的情況 (在我們的例子中就是這樣的情況)。
我們的目標是依據控制皮膚當前的位置和上一次動畫後到現在為止經過的時間,來計算它的新位置。我們可以把它表示成這樣:
1 |
y = y0 + Δy |
位置的偏移量可以通過速度和時間的函式來表達:
1 |
Δy = v ⋅ Δt |
這個速度可以通過前一次的速度加上速度偏移量算出來,這個速度偏移量是由力在 view 上的作用引起的。
1 |
v = v0 + Δv |
速度的變化可以通過作用在這個 view 上的衝量計算出來:
1 |
Δv = (F ⋅ Δt) / m |
現在,讓我們看一下作用在這個介面上的力。為了得到彈簧效果,我們必須要將摩擦力和彈力結合起來:
1 |
F = F_spring + F_friction |
彈力的計算方法我們可以從任何一本教科書中得到 (編者注:簡單的胡克定律):
1 |
F_spring = k ⋅ x |
k
是彈簧的勁度係數,x
是 view 到目標結束位置的距離 (也就是彈簧的長度)。因此,我們可以把它寫成這樣:
1 |
F_spring = k ⋅ abs(y_target - y0) |
摩擦力和 view 的速度成正比:
1 |
F_friction = μ ⋅ v |
μ
是一個簡單的摩擦係數。你可以通過別的方式來計算摩擦力,但是這個方法能很好地做出我們想要的動畫效果。
將上面的表示式放在一起,我們就可以算出作用在介面上的力:
1 |
F = k ⋅ abs(y_target - y0) + μ ⋅ v |
為了實現起來更簡單點些,我們將 view 的質量設為 1,這樣我們就能計算在位置上的變化:
1 |
Δy = (v0 + (k ⋅ abs(y_target - y0) + μ ⋅ v) ⋅ Δt) ⋅ Δt |
實現動畫
為了實現這個動畫,我們首先需要建立我們自己的 Animator
類,它將扮演驅動動畫的角色。這個類使用了 CADisplayLink
,CADisplayLink
是專門用來將繪圖與螢幕重新整理頻率相同步的定時器。換句話說,如果你的動畫是流暢的,這個定時器就會每秒呼叫你的方法60次。接下來,我們需要實現 Animation
協議來和我們的 Animator
一起工作。這個協議只有一個方法,animationTick:finished:
。螢幕每次被重新整理時都會呼叫這個方法,並且在方法中會得到兩個引數:第一個引數是前一個 frame 的持續時間,第二個引數是一個指向 BOOL
的指標。當我們設定這個指標的值為 YES
時,我們就可以與 Animator
取得通訊並彙報動畫完成;
1 2 3 |
@protocol Animation &lt;NSObject&gt; - (void)animationTick:(CFTimeInterval)dt finished:(BOOL *)finished; @end |
我們會在下面實現這個方法。首先,根據時間間隔我們來計算由彈力和摩擦力的合力。然後根據這個力來更新速度,並調整 view 的中心位置。最後,當這個速度降低並且 view 到達結束位置時,我們就停止這個動畫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
- (void)animationTick:(CFTimeInterval)dt finished:(BOOL *)finished { static const float frictionConstant = 20; static const float springConstant = 300; CGFloat time = (CGFloat) dt; //摩擦力 = 速度 * 摩擦係數 CGPoint frictionForce = CGPointMultiply(self.velocity, frictionConstant); //彈力 = (目標位置 - 當前位置) * 彈簧勁度係數 CGPoint springForce = CGPointMultiply(CGPointSubtract(self.targetPoint, self.view.center), springConstant); //力 = 彈力 - 摩擦力 CGPoint force = CGPointSubtract(springForce, frictionForce); //速度 = 當前速度 + 力 * 時間 / 質量 self.velocity = CGPointAdd(self.velocity, CGPointMultiply(force, time)); //位置 = 當前位置 + 速度 * 時間 self.view.center = CGPointAdd(self.view.center, CGPointMultiply(self.velocity, time)); CGFloat speed = CGPointLength(self.velocity); CGFloat distanceToGoal = CGPointLength(CGPointSubtract(self.targetPoint, self.view.center)); if (speed &lt; 0.05 &amp;&amp; distanceToGoal &lt; 1) { self.view.center = self.targetPoint; *finished = YES; } } |
這就是這個方法裡的全部內容。我們把這個方法封裝到一個 SpringAnimation
物件中。除了這個方法之外,這個物件中還有一個初始化方法,它指定了 view 中心的目標位置 (在我們的例子中,就是開啟狀態時介面的中心位置,或者關閉狀態時介面的中心位置) 和初始的速度。
將動畫新增到 view 上
我們的 view 類剛好和使用 UIDynamic 的例子一樣:它有一個拖動手勢,並且根據拖動手勢來更新中心位置。它也有兩個同樣的 delegate 方法,這兩個方法會實現動畫的初始化。首先,一旦使用者開始拖動控制板時,我們就取消所有動畫:
1 2 3 4 |
- (void)draggableViewBeganDragging:(DraggableView *)view { [self cancelSpringAnimation]; } |
一旦停止拖動,我們就根據從拖動手勢中得到的最後一個速率值來開始我們的動畫。我們根據拖動狀態 paneState
計算出動畫的結束位置:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
- (void)draggableView:(DraggableView *)view draggingEndedWithVelocity:(CGPoint)velocity { PaneState targetState = velocity.y &gt;= 0 ? PaneStateClosed : PaneStateOpen; self.paneState = targetState; [self startAnimatingView:view initialVelocity:velocity]; } - (void)startAnimatingView:(DraggableView *)view initialVelocity:(CGPoint)velocity { [self cancelSpringAnimation]; self.springAnimation = [UINTSpringAnimation animationWithView:view target:self.targetPoint velocity:velocity]; [view.animator addAnimation:self.springAnimation]; } |
剩下來要做的就是新增點選動畫了,這很簡單。一旦我們觸發這個狀態,就開始動畫。如果這裡正在進行彈簧動畫,我們就用當時的速度作為開始。如果這個彈簧動畫是 nil,那麼這個開始速度就是 CGPointZero。想要知道為什麼依然可以進行動畫,可以看看 animationTick:finished:
裡的程式碼。當這個起始速度為 0 的時候,彈力就會使速度緩慢地增長,直到皮膚到達目標位置:
1 2 3 4 5 6 |
- (void)didTap:(UITapGestureRecognizer *)tapRecognizer { PaneState targetState = self.paneState == PaneStateOpen ? PaneStateClosed : PaneStateOpen; self.paneState = targetState; [self startAnimatingView:self.pane initialVelocity:self.springAnimation.velocity]; } |
動畫驅動
最後,我們需要一個 Animator
,也就是動畫的驅動者。Animator 封裝了 display link。因為每個 display link 都連結一個指定的 UIScreen
,所以我們根據這個指定的 UIScreen 來初始化我們的 animator。我們初始化一個 display link,並且將它加入到 run loop 中。因為現在還沒有動畫,所以我們是從暫停狀態開始的:
1 2 3 4 5 6 7 8 9 10 11 |
- (instancetype)initWithScreen:(UIScreen *)screen { self = [super init]; if (self) { self.displayLink = [screen displayLinkWithTarget:self selector:@selector(animationTick:)]; self.displayLink.paused = YES; [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; self.animations = [NSMutableSet new]; } return self; } |
一旦我們新增了這個動畫,我們要確保這個 display link 不再是停止狀態:
1 2 3 4 5 6 7 |
- (void)addAnimation:(id&lt;Animation&gt;)animation { [self.animations addObject:animation]; if (self.animations.count == 1) { self.displayLink.paused = NO; } } |
我們設定這個 display link 來呼叫 animationTick:
方法,在每個 Tick 中,我們都遍歷它的動畫陣列,並且給這些動畫陣列中的每個動畫傳送一個訊息。如果這個動畫陣列中已經沒有動畫了,我們就暫停這個 display link。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
- (void)animationTick:(CADisplayLink *)displayLink { CFTimeInterval dt = displayLink.duration; for (id&lt;Animation&gt; a in [self.animations copy]) { BOOL finished = NO; [a animationTick:dt finished:&amp;finished]; if (finished) { [self.animations removeObject:a]; } } if (self.animations.count == 0) { self.displayLink.paused = YES; } } |
完整的專案在 GitHub 上。
權衡
我們必須記住,通過 display link 來驅動動畫 (就像我們剛才演示的例子,或者我們使用UIkit力學來做的例子,又或者是使用 Facebook 的 Pop 框架) 是有代價需要進行權衡的。就像 Andy Matuschar 指出的那樣,UIView 和 CAAnimation 動畫比其他任務更少受系統的影響,因為比起你的應用來說,渲染處於更高的優先順序。
回到 Mac
現在 Mac 中還沒有 UIKit 力學。如果你想在 Mac 中建立一個真實的互動式動畫,你必須自己去實現這些動畫。我們已經向你展示瞭如何在 iOS 中實現這些動畫,所以在 OS X 中實現相似的功能也是非常簡單的。你可以檢視在 GitHub 中的完整專案,如果你想要應用到 OS X 中,這裡還有一些地方需要修改:
- 第一個要修改的就是
Animator
。在Mac中沒有CADisplayLink
,但是取而代之的有CVDisplayLink
,它是以 C 語言為基礎的 API。建立它需要做更多的工作,但也是很直接。 - iOS 中的彈簧動畫是基於調整 view 的中心位置來實現的。而 OS X 中的
NSView
類沒有 center 這個屬性,所以我們用為 frame 中的 origin 做動畫來代替。 - 在 Mac 中是沒有手勢識別,所以我要在我們自定義的 view 子類中實現
mouseDown:
,mouseUp:
和mouseDragged:
方法。
上面就是我們需要在 Mac 中使用我們的動畫效果在程式碼所需要做的修改。對於像這樣的簡單 view,它能很好的勝任。但對於更復雜的動畫,你可能就不會想通過為 frame 做動畫來實現了,我們可以用 transform
來代替,瀏覽 Jonathan Willing 寫的關於 OS X 動畫的部落格,你會獲益良多。
Facebook 的 POP 框架
上個星期圍繞著 Facebook 的 POP 框架的討論絡繹不絕。POP 框架是 Paper 應用背後支援的動畫引擎。它的操作非常像我們上面講的驅動動畫的例子,但是它以非常靈活的方式巧妙地封裝到了一個程式包中。
讓我們動手用 POP 來驅動我們的動畫吧。因為我們自己的類中已經封裝了彈簧動畫,這些改變就非常簡單了。我們所要做的就是初始化一個 POP 動畫來代替我們剛才自己做的動畫,並將下面這段程式碼加入到 view 中:
1 2 3 4 5 6 7 8 9 10 11 |
- (void)animatePaneWithInitialVelocity:(CGPoint)initialVelocity { [self.pane pop_removeAllAnimations]; POPSpringAnimation *animation = [POPSpringAnimation animationWithPropertyNamed:kPOPViewCenter]; animation.velocity = [NSValue valueWithCGPoint:initialVelocity]; animation.toValue = [NSValue valueWithCGPoint:self.targetPoint]; animation.springSpeed = 15; animation.springBounciness = 6; [self.pane pop_addAnimation:animation forKey:@"animation"]; self.animation = animation; } |
你可以在 GitHub 中找到使用 POP 框架的完整例子。
讓其工作非常簡單,並且通過它我們可以實現很多更復雜的動畫。但是它真正強大的地方在於它能夠實現真正的可互動和可中斷的動畫,就像我們上面提到的 那樣,因為它直接支援以速度作為輸入引數。如果你打算從一開始到被中斷這過程中的任何時候都能互動,像 POP 這樣的框架就能幫你實現這些動畫,並且它能始終保證動畫一直很平滑。
如果你不滿足於用 POPSpringAnimation
和 POPDecayAnimation
的開箱即用的處理方式的話,POP 還提供了 POPCustomAnimation
類,它基本上是一個 display link 的方便的轉換,來在動畫的每一個 tick 的回撥 block 中驅動你自己的動畫。
展望未來
隨著 iOS7 中從對擬物化的視覺效果的遠離,以及對 UI 行為的關注,真實的互動式動畫通向未來的大道變得越來越明顯。它們也是將初代 iPhone 中滑動行為的魔力延續到互動的各個方面的一條康莊大道。為了讓這些魔力成為現實,我們就不能在開發過程中才想到這些動畫,而是應該在設計時就要考慮這些交 互,這一點非常重要。
非常感謝 Loren Brichter 給這篇文章提出的一些意見。