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

相关文章
相关标签/搜索