[譯] 使用 Swift 實現原型動畫

關於開發移動應用,我最喜歡做的事情之一就是讓設計師的創做活躍起來。我想成爲 iOS 開發者的緣由之一就是可以利用 iPhone 的力量,創造出友好的用戶體驗。所以,當 s23NYC 的設計團隊帶着 SNKRS Pass 的動畫原型來到我面前時,我既興奮同時又很是懼怕:前端

應該從哪裏開始呢?當看到一個複雜的動畫模型時,這多是一個使人頭疼的問題。在這篇文章中,咱們將分解一個動畫和原型迭代來開發一個可複用的動畫波浪視圖。android


在 Playground 中的原型設計

在咱們開始以前,咱們須要創建一個環境,在這個環境中,咱們能夠迅速設計咱們的動畫原型,而沒必要不斷地構建和運行咱們所作的每個細微的變化。幸運的是,蘋果給咱們提供了 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)
}
複製代碼

咱們將 durationtimeOffset 做爲參數傳給 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 來更新它的 startPointendPoint。Cagradientlayer 是一個線性漸變,所以圍繞中心的旋轉 startPointendPoint 將有效地旋轉漸變。讓咱們把 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

爲了可以讓漸變層旋轉,咱們須要將設備旋轉的角度轉換爲旋轉弧的起點和終點:漸變的 startPointendPoint。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))
    }
}
複製代碼

拿出一些三角學的知識!如今,咱們已經將度數轉換會新的 startPointendPoint

這沒什麼……但咱們能作得更好嗎?那是必須的。讓咱們進入下一個階段……

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()
    }
}
複製代碼

如今讓咱們回到 makeWavesaddGradientLayer 方法,並確保全部工做準備就緒:

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)
}
複製代碼

下面激動的時刻來臨了……

此處視頻請到原文查看。

如今,是很是順暢的!

附件是最後一個示例項目的完整工程,全部的代碼處於最終狀態。我推薦你試着在設備上運行,好好地玩下!


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索