原文鏈接 : How To Implement A Circular Image Loader Animation with CAShapeLayer
原文作者 : Rounak Jain
譯文出自 : 開發技術前線 www.devtf.cn
譯者 : Sam Lau
校對者: Lollypo
狀態 : 校正完
幾個星期之前,Michael Villar在Motion試驗中創建一個非常有趣的加載動畫。
下面的GIF圖片展示這個加載動畫,它將一個圓形進度指示器和圓形漸現動畫結合。這個組合的效果有趣,獨一無二和有點迷人。
這個教程將會教你如何使用Swift和Core Animatoin來重新創建這個效果。讓我們開始吧!
基礎
首先下載這個教程的啟動項目,然後編譯和運行。過一會之後,你應該看到一個簡單的image顯示:
這個啟動項目已經預先在恰當的位置將views和加載邏輯編寫好了。花一分鐘來浏覽來快速了解這個項目;那裡有一個ViewController,ViewController裡有一個命名為CustomImageView的UIImageView子類, 還有一個SDWebImage的方法被調用來加載image。
你可能注意到當你第一次運行這個app的時候,當image下載時這個app似乎會暫停幾秒,然後image會顯示在屏幕。當然,此刻沒有圓形進度指示器 – 你將會在這個教程中創建它!
你會在兩個步驟中創建這個動畫:
圓形進度。首先,你會畫一個圓形進度指示器,然後根據下載進度來更新它。
擴展圓形圖片。第二,你會通過擴展的圓形窗口來揭示下載圖片。
緊跟著下面步驟來逐步實現!
創建圓形指示器
想一下關於進度指示器的基本設計。這個指示器一開始是空來展示0%進度,然後逐漸填滿直到image完成下載。通過設置CAShapeLayer的path為circle來實現是相當簡單。
注意:如果你不熟悉CAShapeLayer(或CALayers)的基本概念,可以查看Scott Gardner的CALayer in iOS with Swift文章。
你可以通過CAShapeLayer的strokeStart和strokeEnd屬性來控制開始和結束位置的外觀。通過改變strokeEnd的值在0到1之間,你可以恰當地填充下載進度。
讓我們試一下。通過iOS\Source\Cocoa Touch Class template來創建一個新的文件,文件名為CircularLoaderView。設置它為UIView的子類。
點擊Next和Create。新的子類UIView將用來保存動畫的代碼。
打開CircularLoaderView.swift和添加以下屬性和常量到這個類:
let circlePathLayer = CAShapeLayer() let circleRadius: CGFloat = 20.0
circlePathLayer表示這個圓形路徑,而circleRadius表示這個圓形路徑的半徑。
添加以下初始化代碼到CircularLoaderView.swift來配置這個shape layer:
override init(frame: CGRect) { super.init(frame: frame) configure() } required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder) configure() } func configure() { circlePathLayer.frame = bounds circlePathLayer.lineWidth = 2 circlePathLayer.fillColor = UIColor.clearColor().CGColor circlePathLayer.strokeColor = UIColor.redColor().CGColor layer.addSublayer(circlePathLayer) backgroundColor = UIColor.whiteColor() }
兩個初始化方法都調用configure方法,configure方法設置一個shape layer的line width為2,fill color為clear,stroke color為red。將添加circlePathLayer添加到view’s main layer。然後設置view的 backgroundColor 為white,那麼當image加載時,屏幕的其余部分就忽略掉。
添加路徑
你會注意到你還沒賦值一個path給layer。為了做到這點,添加以下方法(還是在CircularLoaderView.swift文件):
func circleFrame() -> CGRect { var circleFrame = CGRect(x: 0, y: 0, width: 2*circleRadius, height: 2*circleRadius) circleFrame.origin.x = CGRectGetMidX(circlePathLayer.bounds) - CGRectGetMidX(circleFrame) circleFrame.origin.y = CGRectGetMidY(circlePathLayer.bounds) - CGRectGetMidY(circleFrame) return circleFrame }
上面那個方法返回一個CGRect的實例來界定指示器的路徑。這個邊框是2*circleRadius寬和2*circleRadius高,放在這個view的正中心。
每次這個view的size改變時,你會需要都重新計算circleFrame,所以你可能將它放在一個獨立的方法。
現在添加以下方法來創建你的路徑:
func circlePath() -> UIBezierPath { return UIBezierPath(ovalInRect: circleFrame()) }
這只是根據circleFrame限定來返回圓形的UIBezierPath。由於circleFrame()返回一個正方形,在這種情況下”橢圓“會最終成為一個圓形。
由於layers沒有autoresizingMask這個屬性,你需要在layoutSubviews方法更新circlePathLayer的frame來恰當地響應view的size變化。
下一步,覆蓋layoutSubviews()方法:
override func layoutSubviews() { super.layoutSubviews() circlePathLayer.frame = bounds circlePathLayer.path = circlePath().CGPath }
由於改變了frame,你要在這裡調用circlePath()方法來觸發重新計算路徑。
現在打開CustomImageView.swift文件和添加以下CircularLoaderView實例作為一個屬性:
let progressIndicatorView = CircularLoaderView(frame: CGRectZero)
下一步,在之前下載圖片的代碼添加這幾行代碼到init(coder:)方法:
addSubview(self.progressIndicatorView) progressIndicatorView.frame = bounds progressIndicatorView.autoresizingMask = .FlexibleWidth | .FlexibleHeight
上面代碼添加進度指示器作為一個subview添加到自定義的image view。autoresizingMask確保進度指示器view保持與image view的size一樣。
編譯和運行你的項目;你會看到一個紅的、空心的圓形出現,就像這樣:
好的 – 你已經有進度指示器畫在屏幕上。你的下一個任務就是根據下載進度變化來stroke。
修改Stroke長度
回到CircularLoaderView.swift文件和在這個文件的其他屬性直接添加以下代碼:
var progress: CGFloat { get { return circlePathLayer.strokeEnd } set { if (newValue > 1) { circlePathLayer.strokeEnd = 1 } else if (newValue < 0) { circlePathLayer.strokeEnd = 0 } else { circlePathLayer.strokeEnd = newValue } } }
以上代碼創建一個computed property – 也就是一個屬性沒有任何後背的變量 – 它有一個自定義的setter和getter。這個getter只是返回circlePathLayer.strokeEnd,setter驗證輸入值要在0到1之間,然後恰當地設置layer的strokeEnd屬性。
在第一次運行的時候,添加下面這行代碼到configure()來初始化進度:
progress = 0
編譯和運行工程;除了一個空白的屏幕,你應該什麼也沒看到。相信我,這是一個好消息。設置progress為0,反過來會設置strokeEnd也為0,這就意味著shape layer什麼也沒畫。
唯一剩下要做的就是你的指示器在image下載回調方法中更新progress。
回到CustomImageView.swift文件和用以下代碼來代替注釋Update progress here:
self!.progressIndicatorView.progress = CGFloat(receivedSize)/CGFloat(expectedSize)
這主要通過receivedSize除以expectedSize來計算進度。
注意:你會注意到block使用weak self引用 – 這樣能夠避免retain cycle。
編譯和運行你的工程;你會看到進度指示器像這樣開始移動:
即使你自己沒有添加任何動畫代碼,CALayer在layer輕松地發現任何animatable屬性和當屬性改變時平滑地animate。
上面已經完成第一個階段。現在進入第二和最後階段。
創建Reveal動畫
reveal階段在window顯示image然後逐漸擴展圓形環的形狀。如果你已經讀過前面教程,那個教程主要講創建一個Ping風格的view controller動畫,你就會知道這是一個很好的關於CALayer的mask屬性的使用案例。
添加以下方法到CircularLoaderView.swift文件:
func reveal() { // 1 backgroundColor = UIColor.clearColor() progress = 1 // 2 circlePathLayer.removeAnimationForKey("strokeEnd") // 3 circlePathLayer.removeFromSuperlayer() superview?.layer.mask = circlePathLayer }
這是一個很重要的方法需要理解,讓我們逐段看一遍:
設置view的背景色為clear,那麼在view後面的image不再隱藏,然後設置progress為1或100%。
使用strokeEnd屬性來移除任何待定的implicit animations,否則干擾reveal animation。關於implicit animations的更多信息,請查看iOS Animations by Tutorials.
從它的superLayer移除circlePathLayer,然後賦值給superView的layer maks,借助circular mask “hole”,image是可見的。這樣讓你復用已存在的layer和避免重復代碼。
現在你需要在某個地方調用reveal()。在CustomImageView.swift文件用以下代碼替換Reveal image here注釋:
self!.progressIndicatorView.reveal()
編譯和運行你的app;一旦image開始下載,你會看見一部分小的ring在顯示。
你能在背景看到你的image – 但幾乎什麼也沒有!
擴展環
你的下一步就是在內外擴展這個環。你可以兩個分離的、同軸心的UIBezierPath來做到,但你也可以一個更加有效的方法,只是使用一個Bezier path來完成。
怎樣做呢?你只是增加圓的半徑(path屬性)來向外擴展,同時增加line的寬度(lineWidth屬性)來使環更加厚和向內擴展。最終,兩個值都增長到足夠時就在下面顯示整個image。
回到CircularLoaderView.swift文件和添加以下代碼到reveal()方法的最後:
// 1 let center = CGPoint(x: CGRectGetMidX(bounds), y: CGRectGetMidY(bounds)) let finalRadius = sqrt((center.x*center.x) + (center.y*center.y)) let radiusInset = finalRadius - circleRadius let outerRect = CGRectInset(circleFrame(), -radiusInset, -radiusInset) let toPath = UIBezierPath(ovalInRect: outerRect).CGPath // 2 let fromPath = circlePathLayer.path let fromLineWidth = circlePathLayer.lineWidth // 3 CATransaction.begin() CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions) circlePathLayer.lineWidth = 2*finalRadius circlePathLayer.path = toPath CATransaction.commit() // 4 let lineWidthAnimation = CABasicAnimation(keyPath: "lineWidth") lineWidthAnimation.fromValue = fromLineWidth lineWidthAnimation.toValue = 2*finalRadius let pathAnimation = CABasicAnimation(keyPath: "path") pathAnimation.fromValue = fromPath pathAnimation.toValue = toPath // 5 let groupAnimation = CAAnimationGroup() groupAnimation.duration = 1 groupAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) groupAnimation.animations = [pathAnimation, lineWidthAnimation] groupAnimation.delegate = self circlePathLayer.addAnimation(groupAnimation, forKey: "strokeWidth")
現在逐段解釋以上代碼是究竟做了什麼:
確定圓形的半徑之後就能完全限制image view。然後計算CGRect來完全限制這個圓形。toPath表示CAShapeLayer mask的最終形狀。
設置lineWidth和path初始值來匹配當前layer的值。
設置lineWidth和path的最終值;這樣能防止它們當動畫完成時跳回它們的原始值。CATransaction設置kCATransactionDisableActions鍵對應的值為true來禁用layer的implicit animations。
創建一個兩個CABasicAnimation的實例,一個是路徑動畫,一個是lineWidth動畫,lineWidth必須增加到兩倍跟半徑增長速度一樣快,這樣圓形向內擴展與向外擴展一樣。
將兩個animations添加到一個CAAnimationGroup,然後添加animation group到layer。將self賦值給delegate,等下你會使用到它。
編譯和運行你的工程;你會看到一旦image完成下載,reveal animation就會彈出來。但即使reveal animation完成,部分圓形還是會保持在屏幕上。
為了修復這種情況,添加以下實現animationDidStop(_:finished:) 到 CircularLoaderView.swift:
override func animationDidStop(anim: CAAnimation!, finished flag: Bool) { superview?.layer.mask = nil }
這些代碼從super layer上移除mask,這會完全地移除圓形。
再次編譯和運行你的工程,和你會看到整個動畫的效果:
恭喜你,你已經完成創建圓形圖像加載動畫!
下一步
你可以在這裡下載整個工程。
基於本教程,你可以進一步來微調動畫的時間、曲線和顏色來滿足你的需求和個人設計美學。一個可能需要改進就是設置shape layer的lineCap屬性值為kCALineCapRound來四捨五入圓形進度指示器的尾部。你自己思考還有什麼可以改進的地方。
如果你喜歡這個教程和願意學習怎樣創建更多像這樣的動畫,請查看Marin Todorov的書iOS Animations by Tutorials。它是從基本的動畫開始,然後逐步講解layer animations, animating constraints, view controller transitions和更多
如果你有什麼關於這個教程的問題或評論,請在下面參與討論。我很樂意看到你在你的App中添加這麼酷的動畫。