实现Uber的启动动画

此篇为译文,若存在纰漏,请见谅。

原文:How To Create an Uber Splash Screen

一个完美的启动动画—通过有趣的动画让开发者不会再为app启动时依赖API返回核心数据而产生的延时问题抓狂。有趣的启动动画(启动画面不再是静态的,无动画的启动画面)会在app中起到十分重要的作用:让用户有耐心等待app的启动。

尽管我们能在很多app中见到启动动画,但是你很难找到一个比Uber漂亮的。在2016年的第一个季度,Uber推出了新版major rebranding strare gy led by its CEO。其中的一个变化就是带来了一个十分酷炫的启动画面。

此篇教程的目的是尽可能地还原Uber的启动动画。其中重度使用 CALayerCAAnimation 类,包括他们的子类。除了介绍这些这些类的概念,本教程会更注重如何使用这些类来构造高质量的动画。想要深入学习这些动画,看这里:Marin Todorov’s Intermediate iOS Animation video series

开始

因为在教程中需要实现大量的动画,所以你将从一个初始工程开始学习,我们已经在这个工程中创建了所有与动画相关的CALayer类的实例。
下载工程

初始项目为一个名为Fuber的app。(译者注:接下来这段话是原文作者卖萌)Fuber提供呼叫Segway(一种独轮电动自行车)司机来接载乘客到城市中的任意一个角落。Fuber发展迅速,现在已经在60多个国家为Segway乘客服务,但是遭到了许多国家政府的反对就像Segway工会反对用户使用Fuber联系Segway司机。:]

教程结束的时候,你会创建出一个如下图的启动动画:

打开并运行Fuber项目,看一看。

从UIViewController角度,app启动 SplashViewController 从父视图控制器-RootContainerViewController:负责控制它的子视图控制器。SplashViewController负责循环启动动画直到app准备好加载。一般这段时间内会去请求API以获取app启动所必须的数据。值得一提的是,在这个简单的示例项目中,启动动画拥有自己的模块。

这里有两个方法在 RootContainerViewController: showSplashViewController() 和 ShowSplashViewControllerNoPing()。此教程的大部分时间,你只需要调用 ShowSplashViewControllerNoPing(),它只会循环启动动画,这样你可以专注于在 SplashViewController中的动画,之后你再会调用 showSplashViewController() 用来模拟请求API的延迟并转场进入主视图控制器。

启动动画的Views与Layers组成

SplashViewController视图中包含两个subview,第一个subview是 TileGridView,它有一个名为“ripple grid”的背景图,它包含了一个格子布局的子视图实例 TileView。另外一个subview由动画视图 ‘U’ icon组成,名为 AnimatedULogoView

AnimatedULogoView包含了4个CAShapeLayer:

  • circleLayer 表示“U”的圆形白色背景。
  • lineLayer 是一条直线从 circleLayer 的中心延伸到它的边缘。
  • squareLayercircleLayer 中心的正方形。
  • maskLayer 当其他图层的边界改变时,在一个简单的动画中它被用来统一控制这些图层。

组合起来,这些 CAShaperLayer 创建了Fuber的“U”。

现在你知道了这些图层是怎么组合起来的,是时候去写动画代码让 AnimatedULogoView动起来。

白色圆形背景动画

在实现这些动画的过程中,最好排除外界干扰专注于正在实现的动画,点开 AnimatedULogoView.swift。在 init(frame:) 中,注释掉添加这些 sublayer 除了 circleLayer 的代码。当完成所有动画之后,便会取消这些注释。代码现在应该是这个样子:

override init(frame: CGRect) {
  super.init(frame: frame)
 
  circleLayer = generateCircleLayer()
  lineLayer = generateLineLayer()
  squareLayer = generateSquareLayer()
  maskLayer = generateMaskLayer()
 
//  layer.mask = maskLayer
  layer.addSublayer(circleLayer)
//  layer.addSublayer(lineLayer)
//  layer.addSublayer(squareLayer)
}

找到 generateCircleLayer() 去看看这个圆是怎么创建的。它是用 UIBezierPath 创建出来的 CAShapeLayer 图层。注意这一行代码:

layer.path = UIBezierPath(arcCenter: CGPointZero, radius: radius/2, startAngle: -CGFloat(M_PI_2), endAngle: CGFloat(3*M_PI_2), clockwise: true).CGPath

默认情况下,也就是 startAngle 参数为0,贝尔塞曲线(bezier)的路径会从右边开始(3点钟的位置)。当设置为 -M_PI_2 也就是-90°,这个曲线会从圆的正上方开始绘制,因为 endAngle 参数设置为270°及 3M_PI_2*,曲线会在圆的正上方结束绘制。因为你要动画展示这个绘制圆的过程,所以圆的半径 radius 与曲线的线宽 lineWidth 相同。

circleLayer 的动画需要3个 CAAnimation组合起来:一个关键帧动画 CAkeyframeAnimation 绘制圆键值为 strokeEnd,一个转换基础关键帧动画 CABasicAnimation 使圆的形态转换,最后一个为动画组 CAAnimationGroup 用来将前面两个动画组合起来。接下来让我们创建它们。

找到 animateCirleLayer() 添加以下代码:

 // strokeEnd
  let strokeEndAnimation = CAKeyframeAnimation(keyPath: "strokeEnd")
  strokeEndAnimation.timingFunction = strokeEndTimingFunction
  strokeEndAnimation.duration = kAnimationDuration - kAnimationDurationDelay
  strokeEndAnimation.values = [0.0, 1.0]
  strokeEndAnimation.keyTimes = [0.0, 1.0]

通过设置这个动画的 values 为 [0.0,1.0],你会看到一个很cool的类似时钟的动画。当 strokeEnd 的值增加的时候,贝塞尔曲线的长度也跟着圆的周长增加,最后这个圆就被“填满”了。举个特定的例子,假如你将 values 的值设置为 [0.0,0.5],这个动画将只会绘制到一个半圆便结束了,因为 strokeEnd 停止在圆的周长一半的位置。
(译者注:想要看到这一个小动画的效果,可以将这个动画加入 circleLayer 中,添加这一行代码:circleLayer.addAnimation(strokeEndAnimation, forKey: "looping") 后运行工程。)

现在来添加形态转换动画:

// transform
  let transformAnimation = CABasicAnimation(keyPath: "transform")
  transformAnimation.timingFunction = strokeEndTimingFunction
  transformAnimation.duration = kAnimationDuration - kAnimationDurationDelay
 
  var startingTransform = CATransform3DMakeRotation(-CGFloat(M_PI_4), 0, 0, 1)
  startingTransform = CATransform3DScale(startingTransform, 0.25, 0.25, 1)
  transformAnimation.fromValue = NSValue(CATransform3D: startingTransform)
  transformAnimation.toValue = NSValue(CATransform3D: CATransform3DIdentity)

这个动画包含两个部分,一部分是比例(scale)变化,另一部分为z轴上旋转变化。这样 circleLayer 再绘制圆的过程中还会顺时针旋转45°。旋转动画十分重要,它需要配合 lineLayer 图层动画的位置与速度。

最后,添加一个动画组 CAAnimationGroup,这个动画组包含了之前的两个动画,所以你只需要将这个动画组加入到 circleLayer图层即可。

// Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.animations = [strokeEndAnimation, transformAnimation]
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.duration = kAnimationDuration
  groupAnimation.beginTime = beginTime
  groupAnimation.timeOffset = startTimeOffset
 
  circleLayer.addAnimation(groupAnimation, forKey: "looping")

这个动画组 CAAnimationGroup 有两个值得关注的属性被设置:beginTimetimeOffset。如果你对它们都不熟悉,这里有一篇很赞的文章介绍它们以及它们的用途。

这个动画组 groupAnimationbeginTime 设置参照于它的父视图。

timeOffset是必须要设置的,因为这个动画不是从动画循环的起点开始的。当你完成了更多的动画之后,尝试去修改 startTimeOffset的值并观察动画发生了什么变化。(译者注:关于timeOffset可以这么理解,假如一段动画是一个环,持续时间为5秒,设置timeOffset的值为2秒,那么这个动画循环将从2秒开始到5秒,然后再从0秒到2秒,这样的一个流程)

将这个动画组加到 circleLayer 图层后,运行工程,动画的效果应该如图:

注意:尝试从 groupAnimation.animations 数组中移除 strokeEndAnimation 或者 transformAnimation,来看看每个动画究竟是什么样子的。尽量在本教程中对每一个你创建的动画采用这个方式来预览,你会惊讶于这些动画组合出了你意想不到的效果。

直线动画

已经完成了 circleLayer 动画,接下来我们来解决 lineLayer动画。还是在 AnimatedULogoView.swift,找到 startAnimating() 注释掉调用动画的代码除了 animateLineLayer()。代码看起来应该是如下的样子:

public func startAnimating() {
  beginTime = CACurrentMediaTime()
  layer.anchorPoint = CGPointZero
 
//  animateMaskLayer()
//  animateCircleLayer()
  animateLineLayer()
//  animateSquareLayer()
}

除此之外,改变 init(frame:) 中的内容,这样我们只添加了 circleLayerlineLayer

override init(frame: CGRect) {
  super.init(frame: frame)
 
  circleLayer = generateCircleLayer()
  lineLayer = generateLineLayer()
  squareLayer = generateSquareLayer()
  maskLayer = generateMaskLayer()
 
//  layer.mask = maskLayer
  layer.addSublayer(circleLayer)
  layer.addSublayer(lineLayer)
//  layer.addSublayer(squareLayer)
}

接下来找到 animateLineLayer() 在实现中添加下一组动画:

// lineWidth
  let lineWidthAnimation = CAKeyframeAnimation(keyPath: "lineWidth")
  lineWidthAnimation.values = [0.0, 5.0, 0.0]
  lineWidthAnimation.timingFunctions = [strokeEndTimingFunction, circleLayerTimingFunction]
  lineWidthAnimation.duration = kAnimationDuration
  lineWidthAnimation.keyTimes = [0.0, 1.0-kAnimationDurationDelay/kAnimationDuration, 1.0]

这个动画用来控制直线线宽由细到粗再到细的过程。

下一个转换动画,添加:

 // transform
  let transformAnimation = CAKeyframeAnimation(keyPath: "transform")
  transformAnimation.timingFunctions = [strokeEndTimingFunction, circleLayerTimingFunction]
  transformAnimation.duration = kAnimationDuration
  transformAnimation.keyTimes = [0.0, 1.0-kAnimationDurationDelay/kAnimationDuration, 1.0]
 
  var transform = CATransform3DMakeRotation(-CGFloat(M_PI_4), 0.0, 0.0, 1.0)
  transform = CATransform3DScale(transform, 0.25, 0.25, 1.0)
  transformAnimation.values = [NSValue(CATransform3D: transform),
                               NSValue(CATransform3D: CATransform3DIdentity),
                               NSValue(CATransform3D: CATransform3DMakeScale(0.15, 0.15, 1.0))]

circleLayer 转换动画很像,在这里你定义一个绕着z轴顺时针旋转。对直线而言,首先执行25%的比例变换,紧接着变换成15%(百分比相对于直线原始尺寸而言)。

将上面的两个动画使用一个 CAAnimationGroup 组合起来,并将这个组合动画添加到 lineLayer:

// Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.removedOnCompletion = false
  groupAnimation.duration = kAnimationDuration
  groupAnimation.beginTime = beginTime
  groupAnimation.animations = [lineWidthAnimation, transformAnimation]
  groupAnimation.timeOffset = startTimeOffset
 
  lineLayer.addAnimation(groupAnimation, forKey: "looping")

运行工程,看到这个prettiness(可爱?!)的动画:

请注意你使用 -M_PI_4 初始转换值与画圆动画配合起来。你还需要设置 keyTimes 为 [0.0, 1.0 -kAnimationDurationDelay/kAnimationDuration, 1.0]。这个数组的第一个和最后一个元素的含义很明显:0表示开始,1.0表示结束,中间的元素需要去计算画圆完成的时间紧接着开始缩小动画。用 kAnimationDurationDelaykAnimationDuration 获得准确的百分比,因为这是个延时动画,所以需要用1.0减去这个百分比才是延时时间。

你现在已经完成了 circleLayerlineLayer 动画,是时候实现圆中心的方块动画。

方块动画

接下来你应该很熟悉了,找到 startAnimating() 注释掉调用动画的方法除了 animateSquareLayer()。除此之外,修改 init(frame:) 如下:

override init(frame: CGRect) {
  super.init(frame: frame)
 
  circleLayer = generateCircleLayer()
  lineLayer = generateLineLayer()
  squareLayer = generateSquareLayer()
  maskLayer = generateMaskLayer()
 
//  layer.mask = maskLayer
  layer.addSublayer(circleLayer)
//  layer.addSublayer(lineLayer)
  layer.addSublayer(squareLayer)
}

完成之后,找到 animateSquareLayer() 然后开始实现下一个动画:

 // bounds
  let b1 = NSValue(CGRect: CGRect(x: 0.0, y: 0.0, width: 2.0/3.0 * squareLayerLength, height: 2.0/3.0  * squareLayerLength))
  let b2 = NSValue(CGRect: CGRect(x: 0.0, y: 0.0, width: squareLayerLength, height: squareLayerLength))
  let b3 = NSValue(CGRect: CGRectZero)
 
  let boundsAnimation = CAKeyframeAnimation(keyPath: "bounds")
  boundsAnimation.values = [b1, b2, b3]
  boundsAnimation.timingFunctions = [fadeInSquareTimingFunction, squareLayerTimingFunction]
  boundsAnimation.duration = kAnimationDuration
  boundsAnimation.keyTimes = [0, 1.0-kAnimationDurationDelay/kAnimationDuration, 1.0]

这个特别的动画改变了 CALayer 的边界(bound)。让这个方形的边长从3分之2的长度开始变化到原长最后变为0。

接下来,改变背景颜色的动画:

// backgroundColor
  let backgroundColorAnimation = CABasicAnimation(keyPath: "backgroundColor")
  backgroundColorAnimation.fromValue = UIColor.whiteColor().CGColor
  backgroundColorAnimation.toValue = UIColor.fuberBlue().CGColor
  backgroundColorAnimation.timingFunction = squareLayerTimingFunction
  backgroundColorAnimation.fillMode = kCAFillModeBoth
  backgroundColorAnimation.beginTime = kAnimationDurationDelay * 2.0 / kAnimationDuration
  backgroundColorAnimation.duration = kAnimationDuration / (kAnimationDuration - kAnimationDurationDelay)

注意 fillMode 属性,因为 beginTime 不为0,这个动画会固定住开始与结束的颜色,这样添加这个动画进入动画组的时候就不会出现闪烁。

说到这,是时候实现这个动画组了:

 // Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.animations = [boundsAnimation, backgroundColorAnimation]
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.duration = kAnimationDuration
  groupAnimation.removedOnCompletion = false
  groupAnimation.beginTime = beginTime
  groupAnimation.timeOffset = startTimeOffset
  squareLayer.addAnimation(groupAnimation, forKey: "looping")

运行工程,你现在可以看到如下方块动画的效果:

是时候组合以上实现的动画,看看这些动画组合起来的效果吧!

注意:这些动画在模拟器上显示可能会出现锯齿状边缘,因为是电脑模拟iOS设备的GPU。如果电脑无法实现这些动画效果,尝试切换到一个更小屏幕尺寸的模拟器或者在真机上运行程序。

MaskLayer

首先,取消 init(frame:) 以及 starAnimating() 中被注释的代码。

所有的动画都被添加之后,运行工程:


看起来还是差一点,是吧?有一个突然的闪烁当 circleLayer 的边界(bounds)缩小的时候。幸运的是,mask动画可以去掉这个闪烁,让边界的收缩更加平滑。

找到 animateMaskLayer() 添加以下代码:

// bounds
  let boundsAnimation = CABasicAnimation(keyPath: "bounds")
  boundsAnimation.fromValue = NSValue(CGRect: CGRect(x: 0.0, y: 0.0, width: radius * 2.0, height: radius * 2))
  boundsAnimation.toValue = NSValue(CGRect: CGRect(x: 0.0, y: 0.0, width: 2.0/3.0 * squareLayerLength, height: 2.0/3.0 * squareLayerLength))
  boundsAnimation.duration = kAnimationDurationDelay
  boundsAnimation.beginTime = kAnimationDuration - kAnimationDurationDelay
  boundsAnimation.timingFunction = circleLayerTimingFunction

这个是改变边界的动画。请记住当 maskLayer 的边界改变的时候,整个 AnimateULogoView 都会消失,因为 maskLayer 是最底层的图层。

现在来实现一个 cornerRadius 动画,来保持 maskLayer 边界是一个圆:

 // cornerRadius
  let cornerRadiusAnimation = CABasicAnimation(keyPath: "cornerRadius")
  cornerRadiusAnimation.beginTime = kAnimationDuration - kAnimationDurationDelay
  cornerRadiusAnimation.duration = kAnimationDurationDelay
  cornerRadiusAnimation.fromValue = radius
  cornerRadiusAnimation.toValue = 2
  cornerRadiusAnimation.timingFunction = circleLayerTimingFunction

将这两个动画加入动画组中,并将动画组添加到这个图层:

 // Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.removedOnCompletion = false
  groupAnimation.fillMode = kCAFillModeBoth
  groupAnimation.beginTime = beginTime
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.duration = kAnimationDuration
  groupAnimation.animations = [boundsAnimation, cornerRadiusAnimation]
  groupAnimation.timeOffset = startTimeOffset
  maskLayer.addAnimation(groupAnimation, forKey: "looping")

运行工程:


看起来非常好!

网格

一个虚拟边界,想象一连串的 UIView 快速穿过 TileGridView 的画面,好了...是时候停止引用Tron,接着往下看。(译者注:此处可去看看原文。)

这个背景网格包含这一系列的 TileView 并贴在它的父视图 TileGridView 上。想要更直接的理解这句话,打开 TileView.swift 找到 init(frame:) 。加入以下代码:

layer.borderWidth = 2.0

运行工程:

就像你所看到的,TileView们在网格中排列很整齐。实现他们的逻辑在 TileGridView.swiftrenderTileViews() 中。接下来你需要做的就是让它们动起来。

TileView的动画

TileGridView 只有一个子视图 containerView。它添加了所有子视图 TileView。除此之外,它有一个属性 tileViewRows ,它是个二维数组包含了所有的被加入到 container ViewtileView

找到 TileViewinit(frame:) 方法。删除那行用来显示边界的代码并取消注释添加 chimeSplashImage 到图层的代码。这个方法现在看起来是这样:

override init(frame: CGRect) {
  super.init(frame: frame)
  layer.contents = TileView.chimesSplashImage.CGImage
  layer.shouldRasterize = true
}

运行程序:


Coooooool...We're getting there!

无论如何,TileGridView(包括所有的 TileView) 需要一些动画。点开 TileView.swift,找到 startAnimatingWithDuration(_:beginTime:rippleDelay:rippleOffset:) 添加下一个动画:

 let timingFunction = CAMediaTimingFunction(controlPoints: 0.25, 0, 0.2, 1)
  let linearFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
  let easeOutFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
  let easeInOutTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
  let zeroPointValue = NSValue(CGPoint: CGPointZero)
 
  var animations = [CAAnimation]()

这一段代码声明了几个你即将用到的 TimingFunction变量。添加以下代码:

if shouldEnableRipple {
    // Transform.scale
    let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
    scaleAnimation.values = [1, 1, 1.05, 1, 1]
    scaleAnimation.keyTimes = TileView.rippleAnimationKeyTimes
    scaleAnimation.timingFunctions = [linearFunction, timingFunction, timingFunction, linearFunction]
    scaleAnimation.beginTime = 0.0
    scaleAnimation.duration = duration
    animations.append(scaleAnimation)
 
    // Position
    let positionAnimation = CAKeyframeAnimation(keyPath: "position")
    positionAnimation.duration = duration
    positionAnimation.timingFunctions = [linearFunction, timingFunction, timingFunction, linearFunction]
    positionAnimation.keyTimes = TileView.rippleAnimationKeyTimes
    positionAnimation.values = [zeroPointValue, zeroPointValue, NSValue(CGPoint:rippleOffset), zeroPointValue, zeroPointValue]
    positionAnimation.additive = true
 
    animations.append(positionAnimation)
  }

shouldEnableRipple 是一个布尔值,它决定着什么时候添加转换与位移动画进入你刚刚创建的 animations 数组中。当 TileViewTileGridView 边界以内它的值为 true(译者注:具体逻辑可以看 renderTileViews())。这个逻辑已经被实现,在 TileGridView.swiftrenderTileViews() 方法中。

接着添加一个 opacity(透明) 动画:

  // Opacity
  let opacityAnimation = CAKeyframeAnimation(keyPath: "opacity")
  opacityAnimation.duration = duration
  opacityAnimation.timingFunctions = [easeInOutTimingFunction, timingFunction, timingFunction, easeOutFunction, linearFunction]
  opacityAnimation.keyTimes = [0.0, 0.61, 0.7, 0.767, 0.95, 1.0]
  opacityAnimation.values = [0.0, 1.0, 0.45, 0.6, 0.0, 0.0]
  animations.append(opacityAnimation)

通过 keyTimes 可以很明确的知道这个动画是如何变换透明度的。

现在将上面的动画加入动画组中:

 // Group
  let groupAnimation = CAAnimationGroup()
  groupAnimation.repeatCount = Float.infinity
  groupAnimation.fillMode = kCAFillModeBackwards
  groupAnimation.duration = duration
  groupAnimation.beginTime = beginTime + rippleDelay
  groupAnimation.removedOnCompletion = false
  groupAnimation.animations = animations
  groupAnimation.timeOffset = kAnimationTimeOffset
 
  layer.addAnimation(groupAnimation, forKey: "ripple")

这里将这个动画组加入到 TileView 中,注意到这个动画组可能有一个或三个动画组成,这取决于 shouldEnableRipple 的值。

现在你已经实现了每一个 TileView 的动画,是时候在 TileGridView 中调用它。回过头来看 TileGridView.swift 把下面的代码加入到 startAnimatingWithBeginTime(_:)

private func startAnimatingWithBeginTime(beginTime: NSTimeInterval) {
  for tileRows in tileViewRows {
    for view in tileRows {
      view.startAnimatingWithDuration(kAnimationDuration, beginTime: beginTime, rippleDelay: 0, rippleOffset: CGPointZero)
    }
  }
}

运行工程:

Hum...现在看起来更赞了,但是缺一点东西,那就是 AnimatedULogView 放大的时候 TileGridView 要有一种波纹往外阔的效果(译者注:就像扔一个石头进入水中激起的波纹效果)。这意味着每一个 TileView 的动画需要有个延迟,延迟的大小由它与屏幕中心的距离决定。(译者注:这里是译者关于采用延时策略实现简单波浪效果的文章:])

找到 startAnimatingWithBeginTime(_:) ,加入下面这个方法:

private func distanceFromCenterViewWithView(view: UIView)->CGFloat {
  guard let centerTileView = centerTileView else { return 0.0 }
 
  let normalizedX = (view.center.x - centerTileView.center.x)
  let normalizedY = (view.center.y - centerTileView.center.y)
  return sqrt(normalizedX * normalizedX + normalizedY * normalizedY)
}

这个工具方法用来获取 TileView 与中心那个 TileView 中心的距离。

回到 startAnimatingWithBeginTime(_:) ,用以下代码替换掉原来的代码:

 for tileRows in tileViewRows {
    for view in tileRows {
      let distance = self.distanceFromCenterViewWithView(view)
 
      view.startAnimatingWithDuration(kAnimationDuration, beginTime: beginTime, rippleDelay: kRippleDelayMultiplier * NSTimeInterval(distance), rippleOffset: CGPointZero)
    }
  }

这里使用 distanceFromCenterViewWithView(_:) 方法来计算每个小动画的延时时间。

运行工程:


更赞了!这个启动动画看起有那么一回事了,但是还是存在一些瑕疵。这个波浪动画看起来不是那么波浪,很僵硬不够自然。

现在最好重新拿起你的高中数学(不用担心,很简单的内容),用向量来表示 TileView 与中心的位置关系。

distanceFromCenterViewWithView(_:) 加入另外一个方法:

private func normalizedVectorFromCenterViewToView(view: UIView)->CGPoint {
  let length = self.distanceFromCenterViewWithView(view)
  guard let centerTileView = centerTileView where length != 0 else { return CGPointZero }
 
  let deltaX = view.center.x - centerTileView.center.x
  let deltaY = view.center.y - centerTileView.center.y
  return CGPoint(x: deltaX / length, y: deltaY / length)
}

回到 startAnimatingWithBeginTime(_:),修改代码如下:

private func startAnimatingWithBeginTime(beginTime: NSTimeInterval) {
  for tileRows in tileViewRows {
    for view in tileRows {
 
      let distance = self.distanceFromCenterViewWithView(view)
      var vector = self.normalizedVectorFromCenterViewToView(view)
 
      vector = CGPoint(x: vector.x * kRippleMagnitudeMultiplier * distance, y: vector.y * kRippleMagnitudeMultiplier * distance)
 
      view.startAnimatingWithDuration(kAnimationDuration, beginTime: beginTime, rippleDelay: kRippleDelayMultiplier * NSTimeInterval(distance), rippleOffset: vector)
    }
  }
}

这里计算每一个 TileView 与中心的向量,并赋值给 rippleOffset 参数。

运行工程:

Very cool!现在只剩最后一步啦:实现背景似乎要冲出屏幕的动感画面(如下图),一个放大动画需要在 mask 的边界发生变化之前启动。

startAnimatingWithBeginTime(_:) 最上方插入以下代码:

let linearTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
 
  let keyframe = CAKeyframeAnimation(keyPath: "transform.scale")
  keyframe.timingFunctions = [linearTimingFunction, CAMediaTimingFunction(controlPoints: 0.6, 0.0, 0.15, 1.0), linearTimingFunction]
  keyframe.repeatCount = Float.infinity;
  keyframe.duration = kAnimationDuration
  keyframe.removedOnCompletion = false
  keyframe.keyTimes = [0.0, 0.45, 0.887, 1.0]
  keyframe.values = [0.75, 0.75, 1.0, 1.0]
  keyframe.beginTime = beginTime
  keyframe.timeOffset = kAnimationTimeOffset
 
  containerView.layer.addAnimation(keyframe, forKey: "scale")

运行程序:


Beautiful! You have now created a production-quality animation that many Fuber users will complain about on Twitter. Great job! :](翻译略.....)

提示:去尝试修改 kRippleMagnitudeMultiplierkRippleDelayMultiplier 的值并看看会有哪些变化。

为了完成整个启动流程,点开 RootContainerViewController.swift。在 viewDidLoad() 中,将最后一行代码 showSplashViewControllerNoPing() 改为 showSplashViewController()

再次运行工程,欣赏下你的成果吧:


是不是很cool...一个完美的启动动画!

后话

你可以在这里下载完整的工程。

如果你想要学习更多关于动画的知识,看iOS Animations by Tutorials

译者注:整个教程还是比较清晰易懂的,有什么纰漏及疑惑的地方可以撩下我哈!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,265评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,274评论 1 288
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,087评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,479评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,782评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,218评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,594评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,316评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,955评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,274评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,803评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,177评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,732评论 3 229
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,953评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,687评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,263评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,189评论 2 258

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,534评论 25 707
  • 在iOS中随处都可以看到绚丽的动画效果,实现这些动画的过程并不复杂,今天将带大家一窥ios动画全貌。在这里你可以看...
    每天刷两次牙阅读 8,318评论 6 30
  • 在iOS中随处都可以看到绚丽的动画效果,实现这些动画的过程并不复杂,今天将带大家一窥iOS动画全貌。在这里你可以看...
    F麦子阅读 4,992评论 5 13
  • 这是一篇转载的译文,非常感谢译者的分享,原译文地址 可以在这下载到由本人所写的OC版实现代码,欢迎指正,欢迎Sta...
    Durand阅读 1,236评论 3 12
  • 日精进【打卡第182天】: 姓名:余成杰 公司:贞观电器 盛和塾《六项精进》224期学员 【知-学习】 《六项精进...
    余成杰阅读 194评论 0 0