使用CADisplayLink實現UILabel動畫特效

杜瑋發表於2019-03-04

在開發時,我們有時候會遇到需要定時對UIView進行重繪的需求,進而讓view產生不同的動畫效果。

本文專案

效果圖

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方法了,在這個方法中我們會根據我們剛才配置的兩個陣列中的相關資料對字串進行變換。

核心程式碼

  • 通過開始時間當前時間獲取動畫進度
  • 根據字元位置對應duationArraydelayArray中的資料
  • 根據durationArraydelayArray中的資料計算當前字元的顯示進度
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。

參考

相關文章