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