[译] 使用 Swift 实现原型动画

9,202 阅读8分钟

关于开发移动应用,我最喜欢作的事情之一就是让设计师的创作活跃起来。我想成为 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)
}

我们将 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏