- 原文地址:Prototyping Animations in Swift
- 原文做者:Jason Wilkin
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:ALVINYEH
- 校對者:talisk、melon8
關於開發移動應用,我最喜歡做的事情之一就是讓設計師的創做活躍起來。我想成爲 iOS 開發者的緣由之一就是可以利用 iPhone 的力量,創造出友好的用戶體驗。所以,當 s23NYC 的設計團隊帶着 SNKRS Pass 的動畫原型來到我面前時,我既興奮同時又很是懼怕:前端
應該從哪裏開始呢?當看到一個複雜的動畫模型時,這多是一個使人頭疼的問題。在這篇文章中,咱們將分解一個動畫和原型迭代來開發一個可複用的動畫波浪視圖。android
在咱們開始以前,咱們須要創建一個環境,在這個環境中,咱們能夠迅速設計咱們的動畫原型,而沒必要不斷地構建和運行咱們所作的每個細微的變化。幸運的是,蘋果給咱們提供了 Swift Playground,這是一個很好的可以快速草擬前端代碼的理想場所,而無需使用完整的應用容器。ios
經過菜單欄中選擇 File > New > Playground…,讓咱們在 Xcode 建立一個新的 Playground。選擇單視圖 Playground 模板,裏面寫好了一個 live view 的模版代碼。咱們須要確保選擇 Assistant Editor,以便咱們的代碼可以實時更新。git
咱們正在製做的這個動畫是 SNKRS Pass 體驗的最後部分之一,這是一種新的方式,能夠在零售店預約最新和最熱門的耐克鞋。當用戶去拿他們的鞋子時,咱們想給他們一張數字通行證,感受就像一張金色的門票。背景動畫的目的是模仿立體物品的真實性。當用戶傾斜該設備時,動畫會做出反應並四處移動,就像光線從設備上反射出來同樣。github
讓咱們從簡單地建立一些同心圓開始:swift
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
}
}
複製代碼
(不按比例)bash
使用自定義路徑的棘手之處在於,咱們如今須要擴展這條路徑,而不是從 AnimatedWaveView 的邊界生成一個最終的圓路徑。由於咱們但願這個視圖是能夠重用的,因此咱們須要計算基於最終目標 rect 的形狀的路徑和邊界的大小。咱們能夠根據路徑最終邊界與其最初邊界的比例來建立 CGAffineTransform。咱們還將這個比例乘以 2.25 的比例因子,以便在完成以前路徑擴展大於視圖。咱們還須要將形狀徹底填充咱們視圖的每一個角落,而不是一旦到達視圖的大小就消失。讓咱們在初始化期間構建初始路徑和最終路徑,並在視圖的框架發生改變時,更新最終路徑:app
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、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。