使用CADisplayLink實現UILabel動畫特效
在開發時,我們有時候會遇到需要定時對UIView進行重繪的需求,進而讓view產生不同的動畫效果。
本文 ofollow,noindex">專案
效果圖

typewritter

shine

fade

wave
初探 CADisplayLink
定時對View進行定時重繪可能會第一時間想到使用 NSTimer
,但是這樣的動畫實現起來是 不流暢 的,因為在timer所處的 runloop
中要處理多種不同的輸入,導致timer的最小週期是在50到100毫秒之間,一秒鐘之內最多隻能跑20次左右。
但如果我們希望在螢幕上看到流暢的動畫,我們就要維持60幀的重新整理頻率,也就意味著每一幀的間隔要在 0.016 秒左右, NSTimer
是無法實現的。所以要用到 Core Animation
的另一個timer, CADisplayLink
。
在 CADisplayLink
的標頭檔案中,我們可以看到它的使用方法跟 NSTimer
是十分類似的,其同樣也是需要註冊到RunLoop中,但不同於 NSTimer
的是,它在 螢幕需要進行重繪時 就會讓RunLoop呼叫 CADisplayLink
指定的selector,用於準備下一幀顯示的資料。而 NSTimer
是需要在 上一次RunLoop整個完成之後 才會呼叫制定的selector,所以在呼叫頻率與上比 NSTimer
要頻繁得多。
另外和 NSTimer
不同的是, NSTimer
可以指定 timeInterval
,對應的是selector呼叫的間隔,但如果 NSTimer
觸發的時間到了,而RunLoop處於阻塞狀態,其觸發時間就會 推遲 到下一個RunLoop。而 CADisplayLink
的timer間隔是 不能調整 的,固定就是一秒鐘發生 60 次,不過可以通過設定其 frameInterval
屬性,設定呼叫一次selector之間的 間隔幀數 。另外需要注意的是如果selector執行的程式碼超過了 frameInterval
的持續時間,那麼 CADisplayLink
就會 直接忽略 這一幀,在下一次的更新時候再接著執行。
配置 RunLoop
在建立CADisplayLink的時候,我們需要指定一個RunLoop和 RunLoopMode
,通常RunLoop我們都是選擇使用主執行緒的RunLoop,因為所有UI更新的操作都必須放到主執行緒來完成,而在模式的選擇就可以用 NSDefaultRunLoopMode
,但是不能保證動畫平滑的執行,所以就可以用 NSRunLoopCommonModes
來替代。但是要小心,因為如果動畫在一個 高幀率 情況下執行,會導致一些別的類似於定時器的任務或者類似於滑動的其他iOS動畫會暫停,直到動畫結束。
private func setup() { _displayLink = CADisplayLink(target: self, selector: #selector(update)) _displayLink?.isPaused = true _displayLink?.add(to: RunLoop.main, forMode: .commonModes) }
實現不同的字元變換動畫
在成功建立 CADisplayLink
計時器後,就可以著手對字串進行各類動畫操作了。在這裡我們會使用 NSAttributedString
來實現效果
在 setupAnimatedText(from labelText: String?)
這個方法中,我們需要使用到兩個陣列,一個是 durationArray
,一個是 delayArray
,通過配置這兩個陣列中的數值,我們可以實現對字串中各個字元的 出現時間 和 出現時長 的控制。
打字機效果的配置
- 每個字元出現所需時間相同
- 下一個字元等待上一個字元出現完成後再出現
- 通過修改
NSAttributedStringKey.baselineOffset
調整 字元位置
case .typewriter: attributedString.addAttribute(.baselineOffset, value: -label.font.lineHeight, range: NSRange(location: 0, length: attributedString.length)) let displayInterval = duration / TimeInterval(attributedString.length) for index in 0..<attributedString.length { durationArray.append(displayInterval) delayArray.append(TimeInterval(index) * displayInterval) }
閃爍效果的配置
- 每個字元出現所需時間隨機
- 確保所有字元能夠在
duration
內均完成出現 - 修改
NSAttributedStringKey.foregroundColor
的 透明度 來實現字元的出現效果
case .shine: attributedString.addAttribute(.foregroundColor, value: label.textColor.withAlphaComponent(0), range: NSRange(location: 0, length: attributedString.length)) for index in 0..<attributedString.length { delayArray.append(TimeInterval(arc4random_uniform(UInt32(duration) / 2 * 100) / 100)) let remain = duration - Double(delayArray[index]) durationArray.append(TimeInterval(arc4random_uniform(UInt32(remain) * 100) / 100)) }
漸現效果的配置
- 每個字元出現所需時間漸減
- 修改
NSAttributedStringKey.foregroundColor
的 透明度 來實現字元的出現效果
case .fade: attributedString.addAttribute(.foregroundColor, value: label.textColor.withAlphaComponent(0), range: NSRange(location: 0, length: attributedString.length)) let displayInterval = duration / TimeInterval(attributedString.length) for index in 0..<attributedString.length{ delayArray.append(TimeInterval(index) * displayInterval) durationArray.append(duration - delayArray[index]) }
完善每一幀的字串更新效果
接下來就需要完善剛才在 CADisplayLink
中配置的 update
方法了,在這個方法中我們會根據我們剛才配置的兩個陣列中的相關資料對字串進行變換。
核心程式碼
- 通過 開始時間 與 當前時間 獲取 動畫進度
- 根據 字元位置 對應
duationArray
與delayArray
中的資料 - 根據
durationArray
與delayArray
中的資料計算當前字元的 顯示進度
var percent = (CGFloat(currentTime - beginTime) - CGFloat(delayArray[index])) / CGFloat(durationArray[index]) percent = fmax(0.0, percent) percent = fmin(1.0, percent) attributedString.addAttribute(.baselineOffset, value: (percent - 1) * label!.font.lineHeight, range: range)
隨後便可以將處理完的 NSAttributedString
返回給label進行更新
番外:利用正弦函式實現波紋進度
波紋路徑
首先介紹一下正弦函式: y = A * sin(ax + b)
- 在 x 軸方向平移 b 個單位(左加右減)
- 橫座標伸長(0 < a < 1)或者縮短(a > 1) 1/a 倍
- 縱座標伸長(A > 1)或者縮短(0 < A < 1)A 倍
在簡單瞭解了這些知識後,我們回到 wavePath()
方法中,在這個方法我們使用正弦函式來繪製一段 UIBezierPath
:
let originY = (label.bounds.size.height + label.font.lineHeight) / 2 let path = UIBezierPath() path.move(to: CGPoint(x: 0, y: _waveHeight!)) var yPosition = 0.0 for xPosition in 0..<Int(label.bounds.size.width) { yPosition = _zoom! * sin(Double(xPosition) / 180.0 * Double.pi - 4 * _translate! / Double.pi) * 5 + _waveHeight! path.addLine(to: CGPoint(x: Double(xPosition), y: yPosition)) } path.addLine(to: CGPoint(x: label.bounds.size.width, y: originY)) path.addLine(to: CGPoint(x: 0, y: originY)) path.addLine(to: CGPoint(x: 0, y: _waveHeight!)) path.close()
波紋高度與動畫的更新
- 隨著進度高度不斷升高
- 隨著進度波紋不斷波動
在 CADisplayLink
註冊的 update
的方法中,我們對承載了波紋路徑的Layer進行更新
_waveHeight! -= duration / Double(label!.font.lineHeight) _translate! += 0.1 if !_reverse { _zoom! += 0.02 if _zoom! >= 1.2 { _reverse = true } } else { _zoom! -= 0.02 if _zoom! <= 1.0 { _reverse = false } } shapeLayer.path = wavePath()
結語
以上就是我對 CADisplayLink
的一些運用,其實它的使用方法還有很多,可以利用它實現更多更復雜而精美的動畫,同時希望各位如果有更好的改進也能與我分享。
如果你喜歡這個 專案 ,歡迎到GitHub上給我一個star。
參考
- RQShineLabel
- Apple Developer Document - CADisplayLink
- iOS核心動畫高階技巧