- 原文地址:Prototyping Animations in Swift
- 原文作者:Jason Wilkin
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:ALVINYEH
- 校對者:talisk、melon8
關於開發移動應用,我最喜歡作的事情之一就是讓設計師的創作活躍起來。我想成為 iOS 開發者的原因之一就是能夠利用 iPhone 的力量,創造出友好的使用者體驗。因此,當 s23NYC 的設計團隊帶著 SNKRS Pass 的動畫原型來到我面前時,我既興奮同時又非常害怕:
應該從哪裡開始呢?當看到一個複雜的動畫模型時,這可能是一個令人頭疼的問題。在這篇文章中,我們將分解一個動畫和原型迭代來開發一個可複用的動畫波浪檢視。
在 Playground 中的原型設計
在我們開始之前,我們需要建立一個環境,在這個環境中,我們可以迅速設計我們的動畫原型,而不必不斷地構建和執行我們所做的每一個細微的變化。幸運的是,蘋果給我們提供了 Swift Playground,這是一個很好的能夠快速草擬前端程式碼的理想場所,而無需使用完整的應用容器。
通過選單欄中選擇 File > New > Playground…,讓我們在 Xcode 建立一個新的 Playground。選擇單檢視 Playground 模板,裡面寫好了一個 live view 的模版程式碼。我們需要確保選擇 Assistant Editor,以便我們的程式碼能夠實時更新。
水波動畫
我們正在製作的這個動畫是 SNKRS Pass 體驗的最後部分之一,這是一種新的方式,可以在零售店預定最新和最熱門的耐克鞋。當使用者去拿他們的鞋子時,我們想給他們一張數字通行證,感覺就像一張金色的門票。背景動畫的目的是模仿立體物品的真實性。當使用者傾斜該裝置時,動畫會作出反應並四處移動,就像光線從裝置上反射出來一樣。
讓我們從簡單地建立一些同心圓開始:
final class AnimatedWaveView: UIView {
public func makeWaves() {
var i = 1
let baseDiameter = 25
var rect = CGRect(x: 0, y: 0, width: baseDiameter, height: baseDiameter)
// Continue adding waves until the next wave would be outside of our frame
while self.frame.contains(rect) {
let waveLayer = buildWave(rect: rect)
self.layer.addSublayer(waveLayer)
i += 1
// Increase size of rect with each new wave layer added
rect = CGRect(x: 0, y: 0, width: baseDiameter * i, height: baseDiameter * i)
}
}
private func buildWave(rect: CGRect) -> CAShapeLayer {
let circlePath = UIBezierPath(ovalIn: rect)
let waveLayer = CAShapeLayer()
waveLayer.bounds = rect
waveLayer.frame = rect
waveLayer.position = self.center
waveLayer.strokeColor = UIColor.black.cgColor
waveLayer.fillColor = UIColor.clear.cgColor
waveLayer.lineWidth = 2.0
waveLayer.path = circlePath.cgPath
waveLayer.strokeStart = 0
waveLayer.strokeEnd = 1
return waveLayer
}
}
複製程式碼
這非常簡單。現在如何將同心圓不停地向外擴大呢?我們將使用 CAAnimation 和 Timer 不斷新增 CAShape,並讓它們動起來。這個動畫有兩個部分:縮放形狀的路徑和增加形狀的邊界。重要的是,通過縮放變換對邊界做動畫,使圓圈移動最終充滿螢幕。如果我們沒有執行邊界的動畫,圓圈將不斷擴大,但會保持其檢視的原點在檢視的中心(向右下角擴充套件)。因此,讓我們將這兩個動畫新增到一個動畫組,以便同時執行它們。記住,CAShape 和 CAAnimation 需要將 UIKit 的值轉換為它們的 CGPath 和 CGColor 計數器。否則,動畫就會悄無聲息地失敗!我們還將使用 CAAnimation 放入委託方法 animationDidStop 在動畫完成後從檢視中刪除形狀圖層。
final class AnimatedWaveView: UIView {
private let baseRect = CGRect(x: 0, y: 0, width: 25, height: 25)
public func makeWaves() {
DispatchQueue.main.async {
Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(self.addAnimatedWave), userInfo: nil, repeats: true)
}
}
@objc private func addAnimatedWave() {
let waveLayer = self.buildWave(rect: baseRect)
self.layer.addSublayer(waveLayer)
self.animateWave(waveLayer: waveLayer)
}
private func buildWave(rect: CGRect) -> CAShapeLayer {
let circlePath = UIBezierPath(ovalIn: rect)
let waveLayer = CAShapeLayer()
waveLayer.bounds = rect
waveLayer.frame = rect
waveLayer.position = self.center
waveLayer.strokeColor = UIColor.black.cgColor
waveLayer.fillColor = UIColor.clear.cgColor
waveLayer.lineWidth = 2.0
waveLayer.path = circlePath.cgPath
waveLayer.strokeStart = 0
waveLayer.strokeEnd = 1
return waveLayer
}
private let scaleFactor: CGFloat = 1.5
private func animateWave(waveLayer: CAShapeLayer) {
// 縮放動畫
let finalRect = self.bounds.applying(CGAffineTransform(scaleX: scaleFactor, y: scaleFactor))
let finalPath = UIBezierPath(ovalIn: finalRect)
let animation = CABasicAnimation(keyPath: "path")
animation.fromValue = waveLayer.path
animation.toValue = finalPath.cgPath
// 邊界動畫
let posAnimation = CABasicAnimation(keyPath: "bounds")
posAnimation.fromValue = waveLayer.bounds
posAnimation.toValue = finalRect
// 動畫組
let scaleWave = CAAnimationGroup()
scaleWave.animations = [animation, posAnimation]
scaleWave.duration = 10
scaleWave.setValue(waveLayer, forKey: "waveLayer")
scaleWave.delegate = self
scaleWave.isRemovedOnCompletion = true
waveLayer.add(scaleWave, forKey: "scale_wave_animation")
}
}
extension AnimatedWaveView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if let waveLayer = anim.value(forKey: "waveLayer") as? CAShapeLayer {
waveLayer.removeFromSuperlayer()
}
}
}
複製程式碼
接下來,我們將為自定義路徑更替圓形。為了生成自定義路徑,我們可以使用 PaintCode 來幫助生成程式碼。在這篇文章中,我們將使用一個星形的波紋路徑:
struct StarBuilder {
static func buildStar() -> UIBezierPath {
let starPath = UIBezierPath()
starPath.move(to: CGPoint(x: 12.5, y: 0))
starPath.addLine(to: CGPoint(x: 14.82, y: 5.37))
starPath.addLine(to: CGPoint(x: 19.85, y: 2.39))
starPath.addLine(to: CGPoint(x: 18.57, y: 8.09))
starPath.addLine(to: CGPoint(x: 24.39, y: 8.64))
starPath.addLine(to: CGPoint(x: 20, y: 12.5))
starPath.addLine(to: CGPoint(x: 24.39, y: 16.36))
starPath.addLine(to: CGPoint(x: 18.57, y: 16.91))
starPath.addLine(to: CGPoint(x: 19.85, y: 22.61))
starPath.addLine(to: CGPoint(x: 14.82, y: 19.63))
starPath.addLine(to: CGPoint(x: 12.5, y: 25))
starPath.addLine(to: CGPoint(x: 10.18, y: 19.63))
starPath.addLine(to: CGPoint(x: 5.15, y: 22.61))
starPath.addLine(to: CGPoint(x: 6.43, y: 16.91))
starPath.addLine(to: CGPoint(x: 0.61, y: 16.36))
starPath.addLine(to: CGPoint(x: 5, y: 12.5))
starPath.addLine(to: CGPoint(x: 0.61, y: 8.64))
starPath.addLine(to: CGPoint(x: 6.43, y: 8.09))
starPath.addLine(to: CGPoint(x: 5.15, y: 2.39))
starPath.addLine(to: CGPoint(x: 10.18, y: 5.37))
starPath.close()
return starPath
}
}
複製程式碼
(不按比例)
使用自定義路徑的棘手之處在於,我們現在需要擴充套件這條路徑,而不是從 AnimatedWaveView 的邊界生成一個最終的圓路徑。因為我們希望這個檢視是可以重用的,所以我們需要計算基於最終目標 rect 的形狀的路徑和邊界的大小。我們可以根據路徑最終邊界與其最初邊界的比例來建立 CGAffineTransform。我們還將這個比例乘以 2.25 的比例因子,以便在完成之前路徑擴充套件大於檢視。我們還需要將形狀完全填充我們檢視的每個角落,而不是一旦到達檢視的大小就消失。讓我們在初始化期間構建初始路徑和最終路徑,並在檢視的框架發生改變時,更新最終路徑:
private let initialPath: UIBezierPath = StarBuilder.buildStar()
private var finalPath: UIBezierPath = StarBuilder.buildStar()
let scaleFactor: CGFloat = 2.25
override var frame: CGRect {
didSet {
self.finalPath = calculateFinalPath()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
self.finalPath = calculateFinalPath()
}
private func calculateFinalPath() -> UIBezierPath {
let path = StarBuilder.buildStar()
let scaleTransform = buildScaleTransform()
path.apply(scaleTransform)
return path
}
private func buildScaleTransform() -> CGAffineTransform {
// Grab initial and final shape diameter
let initialDiameter = self.initialPath.bounds.height
let finalDiameter = self.frame.height
// Calculate the factor by which to scale the shape.
let transformScaleFactor = finalDiameter / initialDiameter * scaleFactor
// Build the transform
return CGAffineTransform(scaleX: transformScaleFactor, y: transformScaleFactor)
}
複製程式碼
在更新動畫組後,使用新的 finalPath 屬性、 initialPath 和內部的 buildWave() 方法,我們會得到一個更新的路徑動畫:
確保我們可以在不同的大小能重用水波動畫的最後一步是:重構定時器方法。而不是一直建立新的水波,我們可以一次性建立所有的波紋,同時用 CAAnimation 錯開時間來執行動畫。這可以通過 CAAnimation 組中設定 timeoffset 來實現。通過給每個動畫組一個稍微不同的 timeoffset,我們可以從不同的起點同時執行所有動畫。我們將用動畫的總持續時間除以螢幕上的波數來計算偏移量:
// 每波之間 7 個畫素點
fileprivate let waveIntervals: CGFloat = 7
// 當直徑為 667 時,定時比為 40 秒。
fileprivate let timingRatio: CFTimeInterval = 40.0 / 667.0
public func makeWaves() {
// 獲得較大的寬度或高度值
let diameter = self.bounds.width > self.bounds.height ? self.bounds.width : self.bounds.height
// 計算半徑減去初始 rect 的寬度
let radius = (diameter - baseRect.width) / 2
// 把半徑除以每個波的長度
let numberOfWaves = Int(radius / waveIntervals)
// 持續時間需要根據直徑來進行更改,以便在任何檢視大小下動畫速度都是相同的。
let animationDuration = timingRatio * Double(diameter)
for i in 0 ..< numberOfWaves {
let timeOffset = Double(i) * (animationDuration / Double(numberOfWaves))
self.addAnimatedWave(timeOffset: timeOffset, duration: animationDuration)
}
}
private func addAnimatedWave(timeOffset: CFTimeInterval, duration: CFTimeInterval) {
let waveLayer = self.buildWave(rect: baseRect, path: initialPath.cgPath)
self.layer.addSublayer(waveLayer)
self.animateWave(waveLayer: waveLayer, duration: duration, offset: timeOffset)
}
複製程式碼
我們將 duration 和 timeOffset 作為引數傳給 animateWave() 方法。讓我們新增一個淡入動畫作為組合的一部分,讓動畫變得更加流暢:
private func animateWave(waveLayer: CAShapeLayer, duration: CFTimeInterval, offset: CFTimeInterval) {
// 淡入動畫
let fadeInAnimation = CABasicAnimation(keyPath: "opacity")
fadeInAnimation.fromValue = 0
fadeInAnimation.toValue = 0.9
fadeInAnimation.duration = 0.5
// 路徑動畫
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.fromValue = waveLayer.path
pathAnimation.toValue = finalPath.cgPath
// 邊界動畫
let boundsAnimation = CABasicAnimation(keyPath: "bounds")
let scaleTransform = buildScaleTransform()
boundsAnimation.fromValue = waveLayer.bounds
boundsAnimation.toValue = waveLayer.bounds.applying(scaleTransform)
// 動畫組合
let scaleWave = CAAnimationGroup()
scaleWave.animations = [fadeInAnimation, boundsAnimation, pathAnimation]
scaleWave.duration = duration
scaleWave.isRemovedOnCompletion = false
scaleWave.repeatCount = Float.infinity
scaleWave.fillMode = kCAFillModeForwards
scaleWave.timeOffset = offset
waveLayer.add(scaleWave, forKey: waveAnimationKey)
}
複製程式碼
現在,我們可以在呼叫 makewaves() 方法來同時繪製每個波形並新增動畫。讓我們來看看效果:
喔呼!我們現在有一個可複用的動畫波浪檢視!
新增漸變
下一步是通過新增一個漸變來改進我們的水波動畫。我們還希望漸變能隨裝置移動感測器一起變化,因此我們將建立一個漸變層並保持對它的引用。我將半透明的水波層放在漸變的上面,但最好的解決方案是將所有水波層邊加到一個父層裡,並將這個父層其設定為漸變層的遮罩。通過這種方法,父層會自己去繪製漸變,這看起來更有效:
private func buildWaves() -> [CAShapeLayer] {
// 獲得較大的寬度或高度值
let diameter = self.bounds.width > self.bounds.height ? self.bounds.width : self.bounds.height
// 計算半徑減去初始 rect 的寬度
let radius = (diameter - baseRect.width) / 2
// 把半徑除以每個波的長度
let numberOfWaves = Int(radius / waveIntervals)
// 持續時間需要根據直徑來進行更改,以便在任何檢視大小下動畫速度都是相同的。
let animationDuration = timingRatio * Double(diameter)
var waves: [CAShapeLayer] = []
for i in 0 ..< numberOfWaves {
let timeOffset = Double(i) * (animationDuration / Double(numberOfWaves))
let wave = self.buildAnimatedWave(timeOffset: timeOffset, duration: animationDuration)
waves.append(wave)
}
return waves
}
public func makeWaves() {
let waves = buildWaves()
let maskLayer = CALayer()
maskLayer.backgroundColor = UIColor.clear.cgColor
waves.forEach { maskLayer.addSublayer($0) }
self.addGradientLayer(withMask: maskLayer)
self.setNeedsDisplay()
}
private func addGradientLayer(withMask maskLayer: CALayer) {
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [UIColor.black.cgColor, UIColor.lightGray.cgColor, UIColor.white.cgColor]
gradientLayer.mask = maskLayer
gradientLayer.frame = self.frame
gradientLayer.bounds = self.bounds
self.layer.addSublayer(gradientLayer)
}
private func buildAnimatedWave(timeOffset: CFTimeInterval, duration: CFTimeInterval) -> CAShapeLayer {
let waveLayer = self.buildWave(rect: baseRect, path: initialPath.cgPath)
self.animateWave(waveLayer: waveLayer, duration: duration, offset: timeOffset)
return waveLayer
}
複製程式碼
運動追蹤
下一步是要將漸變動畫化,使之與裝置運動跟蹤。我們想要創造一種全息效果,當你將它傾斜在手中時,它能模仿反射在檢視表面的光。為此,我們將新增一個圍繞檢視中心旋轉的漸變。我們將使用 CoreMotion 和 CMMotionManager 跟蹤加速度計的實時更新,並將此資料用於互動式動畫。如果你想深入瞭解 CoreMotion 所提供的內容,NSHipster 上有一篇很棒的關於 CMDeviceMotion 的文章。對於我們的 AnimatedWaveView,我們只需 CMDeviceMoving 中的 gravity 屬性(CMAcceleration),它將返回裝置的加速度。當使用者水平和垂直地傾斜裝置時,我們只需要跟蹤 X 和 Y 軸:
developer.apple.com/documentati…
X 和 Y 會是從 -1 到 +1 之間的值,以(0,0)為原點(裝置平放在桌子上,面朝上)。現在我們要如何使用這些資料?
起初,我嘗試使用 CAGradientLayer,並認為旋轉漸變後會產生這種閃光效果。我們可以根據 CMDeviceMotion 的 gravity 來更新它的 startPoint 和 endPoint。Cagradientlayer 是一個線性漸變,因此圍繞中心的旋轉 startPoint 和 endPoint 將有效地旋轉漸變。讓我們把 x 和 y 值從 gravity 轉換成我們用來旋轉漸變的程度值:
fileprivate let motionManager = CMMotionManager()
func trackMotion() {
if motionManager.isDeviceMotionAvailable {
// 設定動作回撥觸發的頻率(秒為單位)
motionManager.deviceMotionUpdateInterval = 2.0 / 60.0
let motionQueue = OperationQueue()
motionManager.startDeviceMotionUpdates(to: motionQueue, withHandler: { [weak self] (data: CMDeviceMotion?, error: Error?) in
guard let data = data else { return }
// 水平傾斜裝置會對閃爍效果影響更大
let xValBooster: Double = 3.0
// 將 x 和 y 值轉換為弧度
let radians = atan2(data.gravity.x * xValBooster, data.gravity.y)
// 將弧度轉換為度數
var angle = radians * (180.0 / Double.pi)
while angle < 0 {
angle += 360
}
self?.rotateGradient(angle: angle)
})
}
}
複製程式碼
注意:我們不能在模擬器或 Playground 中模擬運動跟蹤,因此要在 Xcode 專案中用真機進行測試。
在進行一些初步的設計測試之後,我們覺得有必要通過增加一個 booster 變數來改變 gravity 返回的 X 值,這樣漸變層就會以更快的速度旋轉。因此,在轉換成弧度之前,我們要先乘以 gravity.x。
為了能夠讓漸變層旋轉,我們需要將裝置旋轉的角度轉換為旋轉弧的起點和終點:漸變的 startPoint 和 endPoint。StackOverflow 上有一個非常棒的解決方法,我們可以用來實現一下:
fileprivate func rotateGradient(angle: Float) {
DispatchQueue.main.async {
// https://stackoverflow.com/questions/26886665/defining-angle-of-the-gradient-using-cagradientlayer
let alpha: Float = angle / 360
let startPointX = powf(
sinf(2 * Float.pi * ((alpha + 0.75) / 2)),
2
)
let startPointY = powf(
sinf(2 * Float.pi * ((alpha + 0) / 2)),
2
)
let endPointX = powf(
sinf(2 * Float.pi * ((alpha + 0.25) / 2)),
2
)
let endPointY = powf(
sinf(2 * Float.pi * ((alpha + 0.5) / 2)),
2
)
self.gradientLayer.endPoint = CGPoint(x: CGFloat(endPointX),y: CGFloat(endPointY))
self.gradientLayer.startPoint = CGPoint(x: CGFloat(startPointX), y: CGFloat(startPointY))
}
}
複製程式碼
拿出一些三角學的知識!現在,我們已經將度數轉換會新的 startPoint 和 endPoint 。
這沒什麼……但我們能做得更好嗎?那是必須的。讓我們進入下一個階段……
CAGradientLayer 不支援徑向漸變……但這並不意味著這是不可能的!我們可以使用 CGGradient 建立我們自己的 CALayer 類 RadialGradientLayer。這裡棘手的部分就是要確保在 CGGradient 初始化期間需要將一個 CGColor 陣列強制轉換為一個 CFArray。這需要一直反覆的嘗試,才能準確地找出需要將哪種型別的陣列轉換為 CFArray,並且這些位置可能只是一個用來滿足 UnaspectPoint<CGFloat>?
型別的 CGFloat 陣列。
class RadialGradientLayer: CALayer {
var colors: [CGColor] = []
var center: CGPoint = CGPoint.zero
override init() {
super.init()
needsDisplayOnBoundsChange = true
}
init(colors: [CGColor], center: CGPoint) {
self.colors = colors
self.center = center
super.init()
}
required init(coder aDecoder: NSCoder) {
super.init()
}
override func draw(in ctx: CGContext) {
ctx.saveGState()
let colorSpace = CGColorSpaceCreateDeviceRGB()
// 為每種顏色建立從 0 到 1 的一系列的位置(CGFloat 型別)。
let step: CGFloat = 1.0 / CGFloat(colors.count)
var locations = [CGFloat]()
for i in 0 ..< colors.count {
locations.append(CGFloat(i) * step)
}
// 建立 CGGradient
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations) else {
ctx.restoreGState()
return
}
let gradRadius = min(self.bounds.size.width, self.bounds.size.height)
// 在 context 中繪製徑向漸變,從中心開始,在檢視邊界結束。
ctx.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: gradRadius, options: [])
ctx.restoreGState()
}
}
複製程式碼
我們終於把所有的東西都準備好了!現在我們可以把 CAGradientLayer 替換成我們新的 RadialGradientLayer,並計算裝置重力 x 和 y 到梯度座標位置的對映。我們將重力值轉換為在 0.0 和 1.0 之間浮點數,以計算如何移動漸變層。
private func trackMotion() {
if motionManager.isDeviceMotionAvailable {
// 設定動作回撥觸發的頻率(秒為單位)
motionManager.deviceMotionUpdateInterval = 2.0 / 60.0
let motionQueue = OperationQueue()
motionManager.startDeviceMotionUpdates(to: motionQueue, withHandler: { [weak self] (data: CMDeviceMotion?, error: Error?) in
guard let data = data else { return }
// 將漸變層移動到新位置
self?.moveGradient(x: data.gravity.x, y: data.gravity.y)
})
}
}
private func moveGradient(gravityX: Double, gravityY: Double) {
DispatchQueue.main.async {
// 使用重力作為檢視垂直或水平邊界的百分比來計算新的 x 和 y
let x = (CGFloat(gravityX + 1) * self.bounds.width) / 2
let y = (CGFloat(-gravityY + 1) * self.bounds.height) / 2
// 更新漸變層的中心位置
self.gradientLayer.center = CGPoint(x: x, y: y)
self.gradientLayer.setNeedsDisplay()
}
}
複製程式碼
現在讓我們回到 makeWaves 和 addGradientLayer 方法,並確保所有工作準備就緒:
private var gradientLayer = RadialGradientLayer()
public func makeWaves() {
let waves = buildWaves()
let maskLayer = CALayer()
maskLayer.backgroundColor = UIColor.clear.cgColor
waves.forEach({ maskLayer.addSublayer($0) })
addGradientLayer(withMask: maskLayer)
trackMotion()
}
private func addGradientLayer(withMask maskLayer: CALayer) {
let colors = gradientColors.map({ $0.cgColor })
gradientLayer = RadialGradientLayer(colors: colors, center: self.center)
gradientLayer.mask = maskLayer
gradientLayer.frame = self.frame
gradientLayer.bounds = self.bounds
self.layer.addSublayer(gradientLayer)
}
複製程式碼
下面激動的時刻來臨了……
此處視訊請到原文檢視。
現在,是非常順暢的!
附件是最後一個示例專案的完整工程,所有的程式碼處於最終狀態。我推薦你試著在裝置上執行,好好地玩下!
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。