實現Uber的啟動動畫

此篇為譯文,若存在紕漏,請見諒。

原文:How To Create an Uber Splash Screen

一個完美的啟動動畫—通過有趣的動畫讓開發者不會再為app啟動時依賴API返回核心數據而產生的延時問題抓狂。有趣的啟動動畫(啟動畫面不再是靜態的,無動畫的啟動畫面)會在app中起到十分重要的作用:讓用戶有耐心等待app的啟動。

盡管我們能在很多app中見到啟動動畫,但是你很難找到一個比Uber漂亮的。在2016年的第一個季度,Uber推出了新版major rebranding strare gy led by its CEO。其中的一個變化就是帶來了一個十分酷炫的啟動畫面。

此篇教程的目的是盡可能地還原Uber的啟動動畫。其中重度使用 CALayerCAAnimation 類,包括他們的子類。除了介紹這些這些類的概念,本教程會更注重如何使用這些類來構造高質量的動畫。想要深入學習這些動畫,看這里:Marin Todorov’s Intermediate iOS Animation video series

開始

因為在教程中需要實現大量的動畫,所以你將從一個初始工程開始學習,我們已經在這個工程中創建了所有與動畫相關的CALayer類的實例。
下載工程

初始項目為一個名為Fuber的app。(譯者注:接下來這段話是原文作者賣萌)Fuber提供呼叫Segway(一種獨輪電動自行車)司機來接載乘客到城市中的任意一個角落。Fuber發展迅速,現在已經在60多個國家為Segway乘客服務,但是遭到了許多國家政府的反對就像Segway工會反對用戶使用Fuber聯系Segway司機。:]

教程結束的時候,你會創建出一個如下圖的啟動動畫:

打開并運行Fuber項目,看一看。

從UIViewController角度,app啟動 SplashViewController 從父視圖控制器-RootContainerViewController:負責控制它的子視圖控制器。SplashViewController負責循環啟動動畫直到app準備好加載。一般這段時間內會去請求API以獲取app啟動所必須的數據。值得一提的是,在這個簡單的示例項目中,啟動動畫擁有自己的模塊。

這里有兩個方法在 RootContainerViewController: showSplashViewController() 和 ShowSplashViewControllerNoPing()。此教程的大部分時間,你只需要調用 ShowSplashViewControllerNoPing(),它只會循環啟動動畫,這樣你可以專注于在 SplashViewController中的動畫,之后你再會調用 showSplashViewController() 用來模擬請求API的延遲并轉場進入主視圖控制器。

啟動動畫的Views與Layers組成

SplashViewController視圖中包含兩個subview,第一個subview是 TileGridView,它有一個名為“ripple grid”的背景圖,它包含了一個格子布局的子視圖實例 TileView。另外一個subview由動畫視圖 ‘U’ icon組成,名為 AnimatedULogoView

AnimatedULogoView包含了4個CAShapeLayer:

  • circleLayer 表示“U”的圓形白色背景。
  • lineLayer 是一條直線從 circleLayer 的中心延伸到它的邊緣。
  • squareLayercircleLayer 中心的正方形。
  • maskLayer 當其他圖層的邊界改變時,在一個簡單的動畫中它被用來統一控制這些圖層。

組合起來,這些 CAShaperLayer 創建了Fuber的“U”。

現在你知道了這些圖層是怎么組合起來的,是時候去寫動畫代碼讓 AnimatedULogoView動起來。

白色圓形背景動畫

在實現這些動畫的過程中,最好排除外界干擾專注于正在實現的動畫,點開 AnimatedULogoView.swift。在 init(frame:) 中,注釋掉添加這些 sublayer 除了 circleLayer 的代碼。當完成所有動畫之后,便會取消這些注釋。代碼現在應該是這個樣子:

override init(frame: CGRect) {
  super.init(frame: frame)
 
  circleLayer = generateCircleLayer()
  lineLayer = generateLineLayer()
  squareLayer = generateSquareLayer()
  maskLayer = generateMaskLayer()
 
//  layer.mask = maskLayer
  layer.addSublayer(circleLayer)
//  layer.addSublayer(lineLayer)
//  layer.addSublayer(squareLayer)
}

找到 generateCircleLayer() 去看看這個圓是怎么創建的。它是用 UIBezierPath 創建出來的 CAShapeLayer 圖層。注意這一行代碼:

layer.path = UIBezierPath(arcCenter: CGPointZero, radius: radius/2, startAngle: -CGFloat(M_PI_2), endAngle: CGFloat(3*M_PI_2), clockwise: true).CGPath

默認情況下,也就是 startAngle 參數為0,貝爾塞曲線(bezier)的路徑會從右邊開始(3點鐘的位置)。當設置為 -M_PI_2 也就是-90°,這個曲線會從圓的正上方開始繪制,因為 endAngle 參數設置為270°及 3M_PI_2*,曲線會在圓的正上方結束繪制。因為你要動畫展示這個繪制圓的過程,所以圓的半徑 radius 與曲線的線寬 lineWidth 相同。

circleLayer 的動畫需要3個 CAAnimation組合起來:一個關鍵幀動畫 CAkeyframeAnimation 繪制圓鍵值為 strokeEnd,一個轉換基礎關鍵幀動畫 CABasicAnimation 使圓的形態轉換,最后一個為動畫組 CAAnimationGroup 用來將前面兩個動畫組合起來。接下來讓我們創建它們。

找到 animateCirleLayer() 添加以下代碼:

 // strokeEnd
  let strokeEndAnimation = CAKeyframeAnimation(keyPath: "strokeEnd")
  strokeEndAnimation.timingFunction = strokeEndTimingFunction
  strokeEndAnimation.duration = kAnimationDuration - kAnimationDurationDelay
  strokeEndAnimation.values = [0.0, 1.0]
  strokeEndAnimation.keyTimes = [0.0, 1.0]

通過設置這個動畫的 values 為 [0.0,1.0],你會看到一個很cool的類似時鐘的動畫。當 strokeEnd 的值增加的時候,貝塞爾曲線的長度也跟著圓的周長增加,最后這個圓就被“填滿”了。舉個特定的例子,假如你將 values 的值設置為 [0.0,0.5],這個動畫將只會繪制到一個半圓便結束了,因為 strokeEnd 停止在圓的周長一半的位置。
(譯者注:想要看到這一個小動畫的效果,可以將這個動畫加入 circleLayer 中,添加這一行代碼:circleLayer.addAnimation(strokeEndAnimation, forKey: "looping") 后運行工程。)

現在來添加形態轉換動畫:

// transform
  let transformAnimation = CABasicAnimation(keyPath: "transform")
  transformAnimation.timingFunction = strokeEndTimingFunction
  transformAnimation.duration = kAnimationDuration - kAnimationDurationDelay
 
  var startingTransform = CATransform3DMakeRotation(-CGFloat(M_PI_4), 0, 0, 1)
  startingTransform = CATransform3DScale(startingTransform, 0.25, 0.25, 1)
  transformAnimation.fromValue = NSValue(CATransform3D: startingTransform)
  transformAnimation.toValue = NSValue(CATransform3D: CATransform3DIdentity)

這個動畫包含兩個部分,一部分是比例(scale)變化,另一部分為z軸上旋轉變化。這樣 circleLayer 再繪制圓的過程中還會順時針旋轉45°。旋轉動畫十分重要,它需要配合 lineLayer 圖層動畫的位置與速度。

最后,添加一個動畫組 CAAnimationGroup,這個動畫組包含了之前的兩個動畫,所以你只需要將這個動畫組加入到 circleLayer圖層即可。

// Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.animations = [strokeEndAnimation, transformAnimation]
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.duration = kAnimationDuration
  groupAnimation.beginTime = beginTime
  groupAnimation.timeOffset = startTimeOffset
 
  circleLayer.addAnimation(groupAnimation, forKey: "looping")

這個動畫組 CAAnimationGroup 有兩個值得關注的屬性被設置:beginTimetimeOffset。如果你對它們都不熟悉,這里有一篇很贊的文章介紹它們以及它們的用途。

這個動畫組 groupAnimationbeginTime 設置參照于它的父視圖。

timeOffset是必須要設置的,因為這個動畫不是從動畫循環的起點開始的。當你完成了更多的動畫之后,嘗試去修改 startTimeOffset的值并觀察動畫發生了什么變化。(譯者注:關于timeOffset可以這么理解,假如一段動畫是一個環,持續時間為5秒,設置timeOffset的值為2秒,那么這個動畫循環將從2秒開始到5秒,然后再從0秒到2秒,這樣的一個流程)

將這個動畫組加到 circleLayer 圖層后,運行工程,動畫的效果應該如圖:

注意:嘗試從 groupAnimation.animations 數組中移除 strokeEndAnimation 或者 transformAnimation,來看看每個動畫究竟是什么樣子的。盡量在本教程中對每一個你創建的動畫采用這個方式來預覽,你會驚訝于這些動畫組合出了你意想不到的效果。

直線動畫

已經完成了 circleLayer 動畫,接下來我們來解決 lineLayer動畫。還是在 AnimatedULogoView.swift,找到 startAnimating() 注釋掉調用動畫的代碼除了 animateLineLayer()。代碼看起來應該是如下的樣子:

public func startAnimating() {
  beginTime = CACurrentMediaTime()
  layer.anchorPoint = CGPointZero
 
//  animateMaskLayer()
//  animateCircleLayer()
  animateLineLayer()
//  animateSquareLayer()
}

除此之外,改變 init(frame:) 中的內容,這樣我們只添加了 circleLayerlineLayer

override init(frame: CGRect) {
  super.init(frame: frame)
 
  circleLayer = generateCircleLayer()
  lineLayer = generateLineLayer()
  squareLayer = generateSquareLayer()
  maskLayer = generateMaskLayer()
 
//  layer.mask = maskLayer
  layer.addSublayer(circleLayer)
  layer.addSublayer(lineLayer)
//  layer.addSublayer(squareLayer)
}

接下來找到 animateLineLayer() 在實現中添加下一組動畫:

// lineWidth
  let lineWidthAnimation = CAKeyframeAnimation(keyPath: "lineWidth")
  lineWidthAnimation.values = [0.0, 5.0, 0.0]
  lineWidthAnimation.timingFunctions = [strokeEndTimingFunction, circleLayerTimingFunction]
  lineWidthAnimation.duration = kAnimationDuration
  lineWidthAnimation.keyTimes = [0.0, 1.0-kAnimationDurationDelay/kAnimationDuration, 1.0]

這個動畫用來控制直線線寬由細到粗再到細的過程。

下一個轉換動畫,添加:

 // transform
  let transformAnimation = CAKeyframeAnimation(keyPath: "transform")
  transformAnimation.timingFunctions = [strokeEndTimingFunction, circleLayerTimingFunction]
  transformAnimation.duration = kAnimationDuration
  transformAnimation.keyTimes = [0.0, 1.0-kAnimationDurationDelay/kAnimationDuration, 1.0]
 
  var transform = CATransform3DMakeRotation(-CGFloat(M_PI_4), 0.0, 0.0, 1.0)
  transform = CATransform3DScale(transform, 0.25, 0.25, 1.0)
  transformAnimation.values = [NSValue(CATransform3D: transform),
                               NSValue(CATransform3D: CATransform3DIdentity),
                               NSValue(CATransform3D: CATransform3DMakeScale(0.15, 0.15, 1.0))]

circleLayer 轉換動畫很像,在這里你定義一個繞著z軸順時針旋轉。對直線而言,首先執行25%的比例變換,緊接著變換成15%(百分比相對于直線原始尺寸而言)。

將上面的兩個動畫使用一個 CAAnimationGroup 組合起來,并將這個組合動畫添加到 lineLayer:

// Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.removedOnCompletion = false
  groupAnimation.duration = kAnimationDuration
  groupAnimation.beginTime = beginTime
  groupAnimation.animations = [lineWidthAnimation, transformAnimation]
  groupAnimation.timeOffset = startTimeOffset
 
  lineLayer.addAnimation(groupAnimation, forKey: "looping")

運行工程,看到這個prettiness(可愛?!)的動畫:

請注意你使用 -M_PI_4 初始轉換值與畫圓動畫配合起來。你還需要設置 keyTimes 為 [0.0, 1.0 -kAnimationDurationDelay/kAnimationDuration, 1.0]。這個數組的第一個和最后一個元素的含義很明顯:0表示開始,1.0表示結束,中間的元素需要去計算畫圓完成的時間緊接著開始縮小動畫。用 kAnimationDurationDelaykAnimationDuration 獲得準確的百分比,因為這是個延時動畫,所以需要用1.0減去這個百分比才是延時時間。

你現在已經完成了 circleLayerlineLayer 動畫,是時候實現圓中心的方塊動畫。

方塊動畫

接下來你應該很熟悉了,找到 startAnimating() 注釋掉調用動畫的方法除了 animateSquareLayer()。除此之外,修改 init(frame:) 如下:

override init(frame: CGRect) {
  super.init(frame: frame)
 
  circleLayer = generateCircleLayer()
  lineLayer = generateLineLayer()
  squareLayer = generateSquareLayer()
  maskLayer = generateMaskLayer()
 
//  layer.mask = maskLayer
  layer.addSublayer(circleLayer)
//  layer.addSublayer(lineLayer)
  layer.addSublayer(squareLayer)
}

完成之后,找到 animateSquareLayer() 然后開始實現下一個動畫:

 // bounds
  let b1 = NSValue(CGRect: CGRect(x: 0.0, y: 0.0, width: 2.0/3.0 * squareLayerLength, height: 2.0/3.0  * squareLayerLength))
  let b2 = NSValue(CGRect: CGRect(x: 0.0, y: 0.0, width: squareLayerLength, height: squareLayerLength))
  let b3 = NSValue(CGRect: CGRectZero)
 
  let boundsAnimation = CAKeyframeAnimation(keyPath: "bounds")
  boundsAnimation.values = [b1, b2, b3]
  boundsAnimation.timingFunctions = [fadeInSquareTimingFunction, squareLayerTimingFunction]
  boundsAnimation.duration = kAnimationDuration
  boundsAnimation.keyTimes = [0, 1.0-kAnimationDurationDelay/kAnimationDuration, 1.0]

這個特別的動畫改變了 CALayer 的邊界(bound)。讓這個方形的邊長從3分之2的長度開始變化到原長最后變為0。

接下來,改變背景顏色的動畫:

// backgroundColor
  let backgroundColorAnimation = CABasicAnimation(keyPath: "backgroundColor")
  backgroundColorAnimation.fromValue = UIColor.whiteColor().CGColor
  backgroundColorAnimation.toValue = UIColor.fuberBlue().CGColor
  backgroundColorAnimation.timingFunction = squareLayerTimingFunction
  backgroundColorAnimation.fillMode = kCAFillModeBoth
  backgroundColorAnimation.beginTime = kAnimationDurationDelay * 2.0 / kAnimationDuration
  backgroundColorAnimation.duration = kAnimationDuration / (kAnimationDuration - kAnimationDurationDelay)

注意 fillMode 屬性,因為 beginTime 不為0,這個動畫會固定住開始與結束的顏色,這樣添加這個動畫進入動畫組的時候就不會出現閃爍。

說到這,是時候實現這個動畫組了:

 // Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.animations = [boundsAnimation, backgroundColorAnimation]
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.duration = kAnimationDuration
  groupAnimation.removedOnCompletion = false
  groupAnimation.beginTime = beginTime
  groupAnimation.timeOffset = startTimeOffset
  squareLayer.addAnimation(groupAnimation, forKey: "looping")

運行工程,你現在可以看到如下方塊動畫的效果:

是時候組合以上實現的動畫,看看這些動畫組合起來的效果吧!

注意:這些動畫在模擬器上顯示可能會出現鋸齒狀邊緣,因為是電腦模擬iOS設備的GPU。如果電腦無法實現這些動畫效果,嘗試切換到一個更小屏幕尺寸的模擬器或者在真機上運行程序。

MaskLayer

首先,取消 init(frame:) 以及 starAnimating() 中被注釋的代碼。

所有的動畫都被添加之后,運行工程:


看起來還是差一點,是吧?有一個突然的閃爍當 circleLayer 的邊界(bounds)縮小的時候。幸運的是,mask動畫可以去掉這個閃爍,讓邊界的收縮更加平滑。

找到 animateMaskLayer() 添加以下代碼:

// bounds
  let boundsAnimation = CABasicAnimation(keyPath: "bounds")
  boundsAnimation.fromValue = NSValue(CGRect: CGRect(x: 0.0, y: 0.0, width: radius * 2.0, height: radius * 2))
  boundsAnimation.toValue = NSValue(CGRect: CGRect(x: 0.0, y: 0.0, width: 2.0/3.0 * squareLayerLength, height: 2.0/3.0 * squareLayerLength))
  boundsAnimation.duration = kAnimationDurationDelay
  boundsAnimation.beginTime = kAnimationDuration - kAnimationDurationDelay
  boundsAnimation.timingFunction = circleLayerTimingFunction

這個是改變邊界的動畫。請記住當 maskLayer 的邊界改變的時候,整個 AnimateULogoView 都會消失,因為 maskLayer 是最底層的圖層。

現在來實現一個 cornerRadius 動畫,來保持 maskLayer 邊界是一個圓:

 // cornerRadius
  let cornerRadiusAnimation = CABasicAnimation(keyPath: "cornerRadius")
  cornerRadiusAnimation.beginTime = kAnimationDuration - kAnimationDurationDelay
  cornerRadiusAnimation.duration = kAnimationDurationDelay
  cornerRadiusAnimation.fromValue = radius
  cornerRadiusAnimation.toValue = 2
  cornerRadiusAnimation.timingFunction = circleLayerTimingFunction

將這兩個動畫加入動畫組中,并將動畫組添加到這個圖層:

 // Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.removedOnCompletion = false
  groupAnimation.fillMode = kCAFillModeBoth
  groupAnimation.beginTime = beginTime
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.duration = kAnimationDuration
  groupAnimation.animations = [boundsAnimation, cornerRadiusAnimation]
  groupAnimation.timeOffset = startTimeOffset
  maskLayer.addAnimation(groupAnimation, forKey: "looping")

運行工程:


看起來非常好!

網格

一個虛擬邊界,想象一連串的 UIView 快速穿過 TileGridView 的畫面,好了...是時候停止引用Tron,接著往下看。(譯者注:此處可去看看原文。)

這個背景網格包含這一系列的 TileView 并貼在它的父視圖 TileGridView 上。想要更直接的理解這句話,打開 TileView.swift 找到 init(frame:) 。加入以下代碼:

layer.borderWidth = 2.0

運行工程:

就像你所看到的,TileView們在網格中排列很整齊。實現他們的邏輯在 TileGridView.swiftrenderTileViews() 中。接下來你需要做的就是讓它們動起來。

TileView的動畫

TileGridView 只有一個子視圖 containerView。它添加了所有子視圖 TileView。除此之外,它有一個屬性 tileViewRows ,它是個二維數組包含了所有的被加入到 container ViewtileView

找到 TileViewinit(frame:) 方法。刪除那行用來顯示邊界的代碼并取消注釋添加 chimeSplashImage 到圖層的代碼。這個方法現在看起來是這樣:

override init(frame: CGRect) {
  super.init(frame: frame)
  layer.contents = TileView.chimesSplashImage.CGImage
  layer.shouldRasterize = true
}

運行程序:


Coooooool...We're getting there!

無論如何,TileGridView(包括所有的 TileView) 需要一些動畫。點開 TileView.swift,找到 startAnimatingWithDuration(_:beginTime:rippleDelay:rippleOffset:) 添加下一個動畫:

 let timingFunction = CAMediaTimingFunction(controlPoints: 0.25, 0, 0.2, 1)
  let linearFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
  let easeOutFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
  let easeInOutTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
  let zeroPointValue = NSValue(CGPoint: CGPointZero)
 
  var animations = [CAAnimation]()

這一段代碼聲明了幾個你即將用到的 TimingFunction變量。添加以下代碼:

if shouldEnableRipple {
    // Transform.scale
    let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
    scaleAnimation.values = [1, 1, 1.05, 1, 1]
    scaleAnimation.keyTimes = TileView.rippleAnimationKeyTimes
    scaleAnimation.timingFunctions = [linearFunction, timingFunction, timingFunction, linearFunction]
    scaleAnimation.beginTime = 0.0
    scaleAnimation.duration = duration
    animations.append(scaleAnimation)
 
    // Position
    let positionAnimation = CAKeyframeAnimation(keyPath: "position")
    positionAnimation.duration = duration
    positionAnimation.timingFunctions = [linearFunction, timingFunction, timingFunction, linearFunction]
    positionAnimation.keyTimes = TileView.rippleAnimationKeyTimes
    positionAnimation.values = [zeroPointValue, zeroPointValue, NSValue(CGPoint:rippleOffset), zeroPointValue, zeroPointValue]
    positionAnimation.additive = true
 
    animations.append(positionAnimation)
  }

shouldEnableRipple 是一個布爾值,它決定著什么時候添加轉換與位移動畫進入你剛剛創建的 animations 數組中。當 TileViewTileGridView 邊界以內它的值為 true(譯者注:具體邏輯可以看 renderTileViews())。這個邏輯已經被實現,在 TileGridView.swiftrenderTileViews() 方法中。

接著添加一個 opacity(透明) 動畫:

  // Opacity
  let opacityAnimation = CAKeyframeAnimation(keyPath: "opacity")
  opacityAnimation.duration = duration
  opacityAnimation.timingFunctions = [easeInOutTimingFunction, timingFunction, timingFunction, easeOutFunction, linearFunction]
  opacityAnimation.keyTimes = [0.0, 0.61, 0.7, 0.767, 0.95, 1.0]
  opacityAnimation.values = [0.0, 1.0, 0.45, 0.6, 0.0, 0.0]
  animations.append(opacityAnimation)

通過 keyTimes 可以很明確的知道這個動畫是如何變換透明度的。

現在將上面的動畫加入動畫組中:

 // Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.fillMode = kCAFillModeBackwards
  groupAnimation.duration = duration
  groupAnimation.beginTime = beginTime + rippleDelay
  groupAnimation.removedOnCompletion = false
  groupAnimation.animations = animations
  groupAnimation.timeOffset = kAnimationTimeOffset
 
  layer.addAnimation(groupAnimation, forKey: "ripple")

這里將這個動畫組加入到 TileView 中,注意到這個動畫組可能有一個或三個動畫組成,這取決于 shouldEnableRipple 的值。

現在你已經實現了每一個 TileView 的動畫,是時候在 TileGridView 中調用它。回過頭來看 TileGridView.swift 把下面的代碼加入到 startAnimatingWithBeginTime(_:)

private func startAnimatingWithBeginTime(beginTime: NSTimeInterval) {
  for tileRows in tileViewRows {
    for view in tileRows {
      view.startAnimatingWithDuration(kAnimationDuration, beginTime: beginTime, rippleDelay: 0, rippleOffset: CGPointZero)
    }
  }
}

運行工程:

Hum...現在看起來更贊了,但是缺一點東西,那就是 AnimatedULogView 放大的時候 TileGridView 要有一種波紋往外闊的效果(譯者注:就像扔一個石頭進入水中激起的波紋效果)。這意味著每一個 TileView 的動畫需要有個延遲,延遲的大小由它與屏幕中心的距離決定。(譯者注:這里是譯者關于采用延時策略實現簡單波浪效果的文章:])

找到 startAnimatingWithBeginTime(_:) ,加入下面這個方法:

private func distanceFromCenterViewWithView(view: UIView)->CGFloat {
  guard let centerTileView = centerTileView else { return 0.0 }
 
  let normalizedX = (view.center.x - centerTileView.center.x)
  let normalizedY = (view.center.y - centerTileView.center.y)
  return sqrt(normalizedX * normalizedX + normalizedY * normalizedY)
}

這個工具方法用來獲取 TileView 與中心那個 TileView 中心的距離。

回到 startAnimatingWithBeginTime(_:) ,用以下代碼替換掉原來的代碼:

 for tileRows in tileViewRows {
    for view in tileRows {
      let distance = self.distanceFromCenterViewWithView(view)
 
      view.startAnimatingWithDuration(kAnimationDuration, beginTime: beginTime, rippleDelay: kRippleDelayMultiplier * NSTimeInterval(distance), rippleOffset: CGPointZero)
    }
  }

這里使用 distanceFromCenterViewWithView(_:) 方法來計算每個小動畫的延時時間。

運行工程:


更贊了!這個啟動動畫看起有那么一回事了,但是還是存在一些瑕疵。這個波浪動畫看起來不是那么波浪,很僵硬不夠自然。

現在最好重新拿起你的高中數學(不用擔心,很簡單的內容),用向量來表示 TileView 與中心的位置關系。

distanceFromCenterViewWithView(_:) 加入另外一個方法:

private func normalizedVectorFromCenterViewToView(view: UIView)->CGPoint {
  let length = self.distanceFromCenterViewWithView(view)
  guard let centerTileView = centerTileView where length != 0 else { return CGPointZero }
 
  let deltaX = view.center.x - centerTileView.center.x
  let deltaY = view.center.y - centerTileView.center.y
  return CGPoint(x: deltaX / length, y: deltaY / length)
}

回到 startAnimatingWithBeginTime(_:),修改代碼如下:

private func startAnimatingWithBeginTime(beginTime: NSTimeInterval) {
  for tileRows in tileViewRows {
    for view in tileRows {
 
      let distance = self.distanceFromCenterViewWithView(view)
      var vector = self.normalizedVectorFromCenterViewToView(view)
 
      vector = CGPoint(x: vector.x * kRippleMagnitudeMultiplier * distance, y: vector.y * kRippleMagnitudeMultiplier * distance)
 
      view.startAnimatingWithDuration(kAnimationDuration, beginTime: beginTime, rippleDelay: kRippleDelayMultiplier * NSTimeInterval(distance), rippleOffset: vector)
    }
  }
}

這里計算每一個 TileView 與中心的向量,并賦值給 rippleOffset 參數。

運行工程:

Very cool!現在只剩最后一步啦:實現背景似乎要沖出屏幕的動感畫面(如下圖),一個放大動畫需要在 mask 的邊界發生變化之前啟動。

startAnimatingWithBeginTime(_:) 最上方插入以下代碼:

let linearTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
 
  let keyframe = CAKeyframeAnimation(keyPath: "transform.scale")
  keyframe.timingFunctions = [linearTimingFunction, CAMediaTimingFunction(controlPoints: 0.6, 0.0, 0.15, 1.0), linearTimingFunction]
  keyframe.repeatCount = Float.infinity;
  keyframe.duration = kAnimationDuration
  keyframe.removedOnCompletion = false
  keyframe.keyTimes = [0.0, 0.45, 0.887, 1.0]
  keyframe.values = [0.75, 0.75, 1.0, 1.0]
  keyframe.beginTime = beginTime
  keyframe.timeOffset = kAnimationTimeOffset
 
  containerView.layer.addAnimation(keyframe, forKey: "scale")

運行程序:


Beautiful! You have now created a production-quality animation that many Fuber users will complain about on Twitter. Great job! :](翻譯略.....)

提示:去嘗試修改 kRippleMagnitudeMultiplierkRippleDelayMultiplier 的值并看看會有哪些變化。

為了完成整個啟動流程,點開 RootContainerViewController.swift。在 viewDidLoad() 中,將最后一行代碼 showSplashViewControllerNoPing() 改為 showSplashViewController()

再次運行工程,欣賞下你的成果吧:


是不是很cool...一個完美的啟動動畫!

后話

你可以在這里下載完整的工程。

如果你想要學習更多關于動畫的知識,看iOS Animations by Tutorials

譯者注:整個教程還是比較清晰易懂的,有什么紕漏及疑惑的地方可以撩下我哈!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,702評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,615評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,606評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,044評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,826評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,227評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,307評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,447評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,992評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,807評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,001評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,550評論 5 361
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,243評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,667評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,930評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,709評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,996評論 2 374

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,665評論 25 708
  • 在iOS中隨處都可以看到絢麗的動畫效果,實現這些動畫的過程并不復雜,今天將帶大家一窺ios動畫全貌。在這里你可以看...
    每天刷兩次牙閱讀 8,543評論 6 30
  • 在iOS中隨處都可以看到絢麗的動畫效果,實現這些動畫的過程并不復雜,今天將帶大家一窺iOS動畫全貌。在這里你可以看...
    F麥子閱讀 5,131評論 5 13
  • 這是一篇轉載的譯文,非常感謝譯者的分享,原譯文地址 可以在這下載到由本人所寫的OC版實現代碼,歡迎指正,歡迎Sta...
    Durand閱讀 1,292評論 3 12
  • 日精進【打卡第182天】: 姓名:余成杰 公司:貞觀電器 盛和塾《六項精進》224期學員 【知-學習】 《六項精進...
    余成杰閱讀 199評論 0 0