使用CAShapeLayer來實現圓形圖片加載動畫[譯]

幾個星期之前,Michael Villar在Motion試驗中創建一個非常有趣的加載動畫。

下面的GIF圖片展示這個加載動畫,它將一個圓形進度指示器和圓形漸現動畫結合。這個組合的效果有趣,獨一無二和有點迷人。

這個教程將會教你如何使用Swift和Core Animatoin來重新創建這個效果。讓我們開始吧!

基礎

首先下載這個教程的啟動項目,然后編譯和運行。過一會之后,你應該看到一個簡單的image顯示:

這個啟動項目已經預先在恰當的位置將views和加載邏輯編寫好了。花一分鐘來瀏覽來快速了解這個項目;那里有一個ViewControllerViewController里有一個命名為CustomImageViewUIImageView子類, 還有一個SDWebImage的方法被調用來加載image。

你可能注意到當你第一次運行這個app的時候,當image下載時這個app似乎會暫停幾秒,然后image會顯示在屏幕。當然,此刻沒有圓形進度指示器 - 你將會在這個教程中創建它!

你會在兩個步驟中創建這個動畫:

  1. 圓形進度。首先,你會畫一個圓形進度指示器,然后根據下載進度來更新它。
  2. 擴展圓形圖片。第二,你會通過擴展的圓形窗口來揭示下載圖片。

緊跟著下面步驟來逐步實現!

創建圓形指示器

想一下關于進度指示器的基本設計。這個指示器一開始是空來展示0%進度,然后逐漸填滿直到image完成下載。通過設置CAShapeLayerpath為circle來實現是相當簡單。

注意:如果你不熟悉CAShapeLayer(或CALayers)的基本概念,可以查看Scott Gardner的CALayer in iOS with Swift文章。

你可以通過CAShapeLayerstrokeStartstrokeEnd屬性來控制開始和結束位置的外觀。通過改變strokeEnd的值在0到1之間,你可以恰當地填充下載進度。

讓我們試一下。通過iOS\Source\Cocoa Touch Class template來創建一個新的文件,文件名為CircularLoaderView。設置它為UIView的子類。

點擊NextCreate。新的子類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的實例來界定指示器的路徑。這個邊框是2circleRadius寬和2circleRadius**高,放在這個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動畫,你就會知道這是一個很好的關于CALayermask屬性的使用案例。

添加以下方法到CircularLoaderView.swift文件:

func reveal() {
 
  // 1
  backgroundColor = UIColor.clearColor()
  progress = 1
  // 2
  circlePathLayer.removeAnimationForKey("strokeEnd")
  // 3
  circlePathLayer.removeFromSuperlayer()
  superview?.layer.mask = circlePathLayer
}

這是一個很重要的方法需要理解,讓我們逐段看一遍:

  1. 設置view的背景色為clear,那么在view后面的image不再隱藏,然后設置progress為1或100%。

  2. 使用strokeEnd屬性來移除任何待定的implicit animations,否則干擾reveal animation。關于implicit animations的更多信息,請查看iOS Animations by Tutorials.

  3. 從它的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")

現在逐段解釋以上代碼是究竟做了什么:

  1. 確定圓形的半徑之后就能完全限制image view。然后計算CGRect來完全限制這個圓形。toPath表示CAShapeLayer mask的最終形狀。

  2. 設置lineWidthpath初始值來匹配當前layer的值。

  3. 設置lineWidthpath的最終值;這樣能防止它們當動畫完成時跳回它們的原始值。CATransaction設置kCATransactionDisableActions鍵對應的值為true來禁用layer的implicit animations。

  4. 創建一個兩個CABasicAnimation的實例,一個是路徑動畫,一個是lineWidth動畫,lineWidth必須增加到兩倍跟半徑增長速度一樣快,這樣圓形向內擴展與向外擴展一樣。

  5. 將兩個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中添加這么酷的動畫。

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

推薦閱讀更多精彩內容

  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,175評論 4 61
  • 嗯哼嗯哼蹦擦擦~~~ 轉載自:https://github.com/Tim9Liu9/TimLiu-iOS 目錄 ...
    philiha閱讀 4,986評論 0 6
  • 身上的每一塊肥肉都是對生命的妥協,而我不愿妥協。 來來回回減肥也快兩年了,得到沒有瘦下來反倒是增了20斤,也著實...
    ____________雪凱閱讀 781評論 0 1
  • 皇帝:帝國最高統治者。 符節:中央政權向官員授權,允許其代行天子軍政職權(或是代天巡狩)的憑證與象征,授予符節、節...
    長衫趙紫龍閱讀 967評論 0 1
  • 赤壁之戰的結果就是孫劉從曹操手上獲取了荊州的部分要害關口(曹操仍有荊州部分土地,可已經很難南下)。劉備雖然也取得了...
    嘆誰逍遙閱讀 240評論 0 0