啟動畫面(Splash Screen)——不但給開發者們提供了一個盡情發揮、創建有趣動畫的機會,也填補了App啟動時從終端慢吞吞地下載數據的時間。啟動畫面(動態的)對於App至關重要:它可以讓用戶不失興趣地耐心等待應用完成加載。
盡管現在的啟動畫面多種多樣,但很少有像Uber這般精美的。2016年第一季度,Uber的CEO發表了關於重塑品牌的策略,其中之一就是現在這個超酷的啟動畫面。
這篇教程的目的是盡可能真實地再現Uber的動畫。我們會大量地使用到CALayers
、CAAnimations
,以及它們的子類。我不會從頭介紹這些類的基本概念,而是把重點放在如何應用這些類,創建高質量的動畫。如果你想要了解動畫背後的基本原理,可以參考Marin Todorove的iOS動畫中級教程。
由於有非常多的動畫要實現,我們不妨在這個初始項目的基礎上進行修改。初始項目裡已經為你創建好了所有需要的CALayer
,我們給它們添加動畫即可。
譯者注:為了保持教程簡潔,刪除了原文裡一些與教學無關的文字。如有興趣可通過文章最後的鏈接閱讀原文相關內容。
先來看一眼最終效果:
打開初始項目看看裡面的文件。
從控制器的角度分析,項目中的SplashViewController通過它的父視圖控制器RootContainerViewController生成。SplashViewController會不停循環播放啟動動畫,直到App完全加載完成,即與終端API握手成功並獲取了必要的數據。值得一提的是,在這個示例項目裡,啟動動畫抽象成了一個單獨的模塊(譯者注:可以直接集成到其他項目裡)。
RootContainerViewController裡有兩個方法:showSplashViewController()
和showSplashViewControllerNoPing()
。我們主要使用第二個方法,它只會不停循環播放動畫(不會進入主界面),便於我們把精力集中在SplashViewController的子視圖上。當然,最後我們還是會切換回第一個方法,模擬API延遲並過渡到主界面。
SplashViewController包含了兩個子視圖。其一是“波紋格子”背景,我們把它叫做TileGridView, 由一系列TileView組成。另一個是帶有動畫效果的“U”字Logo,我們把它叫做AnimatedULogoView。
AnimatedULogoView裡有4個CAShapeLayer:
circleLayer
:“U”型Logo的圓形白色背景
lineLayer
:circleLayer
中心到邊界的一條直線
squareLayer
:circleLayer
中心位置的正方形
maskLayer
:遮罩層,當它的邊界隨著動畫改變的時會遮蓋其他層
這些CAShapeLayer組合在一起,構成了Fuber標志性的“U”。
既然已經知道這些層的組合方式,那我們可以開始創建動畫,讓AnimatedULogoView動起來了。
制作動畫的時候,最好過濾掉其他視覺“噪音”,只關注當前實現的動畫。打開AnimatedULogoView.swift,在init(frame:)
方法裡,把除了cricleLayer
的其他層全都注釋掉,實現完動畫後我們會重新添加回來。注釋後的代碼應該像下面一樣:
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)}
定位到generateCricleLayer()
方法,試著理解一下這裡的圓是怎麼繪制的。其實它只不過是用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,圓弧會從右側開始繪制(3點鐘方向)。如果設置為-M_PI_2
,也就是-90°的話則會從上方開始繪制,endAngle最終是270°,或者說3*M_PI_2
,同樣也是圓的正上方。另外要注意的是,在這裡我們把弧線的寬度lineWidth
設置為圓的半徑radius
,因為我們想讓它動起來(畫圓的過程)。
circleLayer
的動畫是由三個CAAnimation組成的:一個描繪筆端動畫的CAKeyframeAnimation,一個進行圖形變換的CABasicAnimation,以及一個CAAnimationGroup把它們合成在一起。
定位到animateCricleLayer()
,添加下面代碼:
// 筆畫變化的動畫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,我們告訴Core Animation,從startAngle開始,到endAngle結束,創建像時鐘一樣的動畫。隨社storkeEnd的值變大,沿著周長的弧線長度也逐漸增加,整個圓逐漸被填滿。對於這個例子,如果你把values
改為[0.0, 0.5],那麼整個動畫只會填滿半個圓。
接著添加形變動畫:
let transformAnimation = CABasicAnimation(keyPath: "transform") transformAnimation.timingFunction = strokeEndTimingFunction transformAnimation.duration = kAnimationDuration - kAnimationDurationDelay// 旋轉放大的動畫// 起始時:逆時針旋轉45°,x、y為正常大小的0.25倍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)
這個動畫既包括了圖形的縮放也包括了沿z軸的旋轉。其結果是circleLayer
在順時針旋轉45°的同時逐漸放大。這裡旋轉的參數設置非常重要,因為和其它層的動畫組合的時候,它需要和lineLayer
的位置及速度相匹配。
最後,在方法的末尾添加一個CAAnimationGroup,它負責把前面兩個動畫合成在一起,這樣你只需給cricleLayer
添加一個動畫即可。
// 把兩個動畫合成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設定了兩個重要的屬性:beginTime
和timeOffset
,如果你對它們不熟悉的話可以參考這篇文章,裡面有這兩個屬性的描述以及用法。
這裡的groupAnimation
的beginTime
屬性是根據父視圖的時間設定的。timeOffset
在這裡也需要設定,因為這個動畫在第一次運行的時候,實際上是從中途開始的。當我們完成更多動畫時,你可以回到這裡,嘗試改變startTimeOffset
的值並觀察效果的差別。
把groupAnimation
添加給circleLayer
,編譯運行一下看看目前的效果:
提示:試著刪除
strokeEndAnimation
或者transformAnimation
,看看單獨的每一個動畫是什麼樣的。在這篇教程裡,你可以嘗試不同動畫的效果。你可能會驚奇地發現,不同是動畫組合竟能創建出如此意想不到的獨特視覺效果。
現在我們已經完成circleLayer
的動畫了,該開始說說lineLayer
了。還是在AnimatedULogoView.swift裡,定位到startAnimating()
把除了animateLineLayer()
以外的方法全部注釋掉。注釋完的代碼應該如下面所示:
public func startAnimating() { beginTime = CACurrentMediaTime() layer.anchorPoint = CGPointZero // animateMaskLayer() // animateCircleLayer() animateLineLayer() // animateSquareLayer()}
此外還需要調整一下init(frame:)
,只顯示circleLayer
和lineLayer
:
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()
方法,實現下一組動畫效果:
// 線段寬度動畫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]
這個動畫會先增加lineLayer
的寬度,隨後變回來。
添加下面代碼實現下一個動畫:
// 變形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
裡:
// 合成動畫let groupAnimation = CAAnimationGroup() groupAnimation.repeatCount = Float.infinity groupAnimation.removedOnCompletion = falsegroupAnimation.duration = kAnimationDuration groupAnimation.beginTime = beginTime groupAnimation.animations = [lineWidthAnimation, transformAnimation] groupAnimation.timeOffset = startTimeOffset lineLayer.addAnimation(groupAnimation, forKey: "looping")
編譯運行,觀察一下效果。
注意,在這裡我們把線條的初始位置設置為了-M_PI_4
,同時把keyTimes
設置為了[0.0, 1.0 - kAnimationDurationDelay/kAnimationDuration, 1.0]
。數組的第一個和最後一個元素顯而易見:0.0代表起始,1.0代表終止。為了得到中間時間點,我們需要計算出圓形動畫完成、後半部分動畫開始(縮小的動畫)的時間。用kAnimationDurationDelay
除以kAnimationDuration
可以得到我們需要的結果。但因為它是個延遲動畫,所以我們應該用1.0減去它,我們需要從末尾往前倒,減去延遲時間。
現在我們已經完成了circleLayer
和lineLayer
的動畫了,接下來該處理中間的方形了。
現在你應該已經輕車熟路了。定位到startAnimation()
方法,注釋掉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()
,開始解決下一個動畫:
// 邊框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)// 邊框從原始長度的2/3開始放大,到原始大小後再逐漸縮小到0let 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
的邊框。我們創建了一個關鍵幀動畫,從邊長的2/3開始,放大到完整尺寸,再縮小到0。
接下來是背景顏色的動畫:
// 背景顏色的變化let backgroundColorAnimation = CABasicAnimation(keyPath: "backgroundColor") backgroundColorAnimation.fromValue = UIColor.whiteColor().CGColorbackgroundColorAnimation.toValue = UIColor.fuberBlue().CGColorbackgroundColorAnimation.timingFunction = squareLayerTimingFunction backgroundColorAnimation.fillMode = kCAFillModeBoth backgroundColorAnimation.beginTime = kAnimationDurationDelay * 2.0 / kAnimationDuration backgroundColorAnimation.duration = kAnimationDuration / (kAnimationDuration - kAnimationDurationDelay)
注意這裡的fillMode
屬性。由於beginTime
不是零,動畫會把開始和結束時的CGColor包含進去。因此,當我們把動畫添加到父CAAnimationGroup裡的時候不會閃現不同顏色。
說到CAAnimationGroup,該實現它了:
// 合成動畫let groupAnimation = CAAnimationGroup() groupAnimation.animations = [boundsAnimation, backgroundColorAnimation] groupAnimation.repeatCount = Float.infinity groupAnimation.duration = kAnimationDuration groupAnimation.removedOnCompletion = falsegroupAnimation.beginTime = beginTime groupAnimation.timeOffset = startTimeOffset squareLayer.addAnimation(groupAnimation, forKey: "looping")
編譯運行,檢查一下我們的進度,嗯看來方形動畫已經順利完成了。
是時候把之前的動畫合並到一起看看效果了!
提示:在iOS模擬器裡的動畫可能會有些卡頓,因為我們需要在Mac上模擬平時由iOS的GPU完成的工作。如果你的電腦不能流暢運行動畫,試著縮小模擬器的屏幕大小,或者在真機上測試。
首先取消init(frame:)
和startAnimating()
裡所有注釋。
把所有動畫組合到一起,我們重新編譯運行一下Fuber。
看起來好像還是差點意思?cricleLayer
的縮小消失太過突然。幸運的是,遮罩層動畫可以修正這個問題,讓它平滑地縮小。
定位到animateMaskLayer()
添加下面的代碼:
// 邊框縮小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
上面的代碼用於設定遮罩層邊界動畫。別忘了,當遮罩層的邊界改變時,整個AnimatedULogoView都會被遮擋,因為它作用於所有子層。
現在我們來實現圓角動畫,保持遮罩是圓形的:
// 邊角弧度let cornerRadiusAnimation = CABasicAnimation(keyPath: "cornerRadius") cornerRadiusAnimation.beginTime = kAnimationDuration - kAnimationDurationDelay cornerRadiusAnimation.duration = kAnimationDurationDelay cornerRadiusAnimation.fromValue = radius cornerRadiusAnimation.toValue = 2cornerRadiusAnimation.timingFunction = circleLayerTimingFunction
把這兩個動畫合成為一個CAAnimationGroup,這個層就完成了:
// 合成動畫let groupAnimation = CAAnimationGroup() groupAnimation.removedOnCompletion = falsegroupAnimation.fillMode = kCAFillModeBoth groupAnimation.beginTime = beginTime groupAnimation.repeatCount = Float.infinity groupAnimation.duration = kAnimationDuration groupAnimation.animations = [boundsAnimation, cornerRadiusAnimation] groupAnimation.timeOffset = startTimeOffset maskLayer.addAnimation(groupAnimation, forKey: "looping")
編譯運行。
看起來不錯!
教程的上半部分至此結束,關於背景網格的水波效果會在下一篇教程中介紹。
原文鏈接:How To Create an Uber Splash Screen