剑客
关注科技互联网

iOS 10 by Tutorials 笔记(九)

Chapter 9: Property Animators

iOS 10 引入了一种全新的类 UIViewPropertyAnimator 来编写动画,它的目标并不是要替换现存的 API,而是给你在动画过程中更多的控制权。

本章我们将学习的新特性有:

  • 如何详细控制动画时间曲线(timing curves)
  • 更加强大的弹簧动画
  • 实时监视并修改动画的状态
  • 暂停、反转、消除动画、甚至在动画进行到途中取消掉(都算半路改变动画)

使用 Property Animator 可以对定义好的动画实现完全的控制权,比如想要一个进行中的动画实时响应用户的手势,或者随时能抓取动画对象,然后做一些其他事情。Property Animator 都是你的好伙伴。

本章我们要实现一个 iPad 应用

iOS 10 by Tutorials 笔记(九)

怎么样,有没有一种被钦定的感觉,逃~

点击那个 Animate! 按钮,青蛙会在屏幕上随机移动一段距离,这个动画简单地调用 UIView.animate(withDuration:) 来实现,

func animateAnimalTo(location: CGPoint) {  
  // TODO
  UIView.animate(withDuration: 3) {
    self.imageContainer.center = location
  }
}

如果你仔细观察青蛙的运行轨迹,会发现它一开始移动地很慢,然后突然加速,最后又慢了下来,直到停止状态。

这一切都是由动画的时间曲线( timing curve )决定的, UIView.animate(withDuration:) 使用流量内建的时间曲线叫做 curveEaseInOut ,用来表示:『慢/快/慢的行为』,苹果还提供了几个预设的时间曲线,但选择还是非常有限。

如果你想要精确控制动画的时间曲线,那么就要有请 Property Animators 出场了。不过在这之前,先来普及下什么是时间曲线( timing curve )。

Timing is everything

最简单的时间曲线是线性曲线,它完全没有速度上的变化,表现为一条直线

iOS 10 by Tutorials 笔记(九)

线性曲线在动画上用的不多,通常使用的是 ease-in (缓入), ease-out (缓出)的时间曲线

iOS 10 by Tutorials 笔记(九)

UIView animations 提供了四种预设的时间曲线:

  • linear 线性
  • ease-in-ease-out 缓入缓出
  • ease-in 从进入后开始加速直到嘎然而止
  • ease-out 进入时速度很快,逐渐减速到停止位置

UIViewPropertyAnimator 为我们提供了完全自定义 time curve 的能力。比如我们自创一个叫做 " naive " 的时间曲线。

开始前,先来看看苹果提供的四种曲线方法有什么特点:

iOS 10 by Tutorials 笔记(九)


linear

iOS 10 by Tutorials 笔记(九)


ease-in-ease-out

iOS 10 by Tutorials 笔记(九)


ease-in
ease-out

我们可以看出:时间曲线实际上是由 A,B,C,D 这四个点的位置控制的,所以把 " naive " 时间曲线定义如下:

iOS 10 by Tutorials 笔记(九)


naive

Controlling your frog

理论知识到此为止,现在开始来写代码,打开 ViewController.swift,找到 animateAnimalTo(location:) 方法,即定义随机移动动画的地方,替换如下内容:

func animateAnimalTo(location: CGPoint) {  
  imageMoveAnimator = UIViewPropertyAnimator(
    duration: 3,
    curve: .easeInOut) {
      self.imageContainer.center = location
  }
  imageMoveAnimator?.startAnimation()
}

这里创建了一个新的 UIViewPropertyAnimator 对象,然后运行。青蛙会以 .easeInOut 的时间曲线移动一段距离,和普通的 UIView 动画方式并没有什么不同,下面来使用自定义的动画曲线。

Property Animators 的强大特性在于它可以控制动画过程中的没一个节点

我们可以为青蛙设计一种如下图的时间曲线: iOS 10 by Tutorials 笔记(九)

注意观察 B 和 C 的位置,替换 animateAnimalTo(location:) 方法如下:

func animateAnimalTo(location: CGPoint) {  
  let controlPoint1 = CGPoint(x: 0.2, y: 0.8) // B on the diagram
  let controlPoint2 = CGPoint(x: 0.4, y: 0.9) // C on the diagram
  imageMoveAnimator = UIViewPropertyAnimator(
    duration: 3,
    controlPoint1: controlPoint1,
    controlPoint2: controlPoint2) {
      self.imageContainer.center = location
  }
  imageMoveAnimator?.startAnimation()
}

因为时间曲线总是从 A(0, 0)到 D(1, 1),其实我们能控制的也就剩下 B 和 C 了,因此 controlPoint1controlPoint2 所表示的也就是 B 和 C。

Spring animations

UIViewPropertyAnimator 对象还有一个初始化方法,它接收一个遵守 UITimingCurveProvider 协议的参数

init(duration: TimeInterval, timingParameters: UITimingCurveProvider)

UITimingCurveProvider协议提供了动画进度和时间的关系,这个协议并不能让你拥有完全控制动画的能力,但它提供了一个非常强大的特性: springs! (弹簧效果)

我们不是已经拥有弹簧动画了吗?但还不能完全定制它

苹果已经提供了一个遵守 UITimingCurveProvider 协议的对象,实现来创建弹簧时间曲线,它叫做 UISpringTimingParameters ,而你创建它需要提供三个值:

  • mass 质量
  • stiffness 刚度
  • damping 阻尼

而阻尼一般会产生三种结果:

  1. under-damped 在静止前会来回弹跳一段时间
  2. critically damped 尽可能的静止而没有任何弹跳
  3. over-damped 没有弹跳的静止,但要花费一点时间

通常我们需要第一种 under-damped 系统,而具体的阻尼值为:

iOS 10 by Tutorials 笔记(九)

创建一个 UISpringTimingParameters ,然后初始化 UIViewPropertyAnimator

func animateAnimalTo(location: CGPoint) {

  let mass: CGFloat = 1.0
  let stiffness: CGFloat = 10.0
  let criticalDamping = 2 * sqrt(mass * stiffness)
  let damping = criticalDamping * 0.5

  let parameters = UISpringTimingParameters(
    mass: mass,
    stiffness: stiffness,
    damping: damping,
    initialVelocity: .zero)

  imageMoveAnimator = UIViewPropertyAnimator(
    duration: 3,
    timingParameters: parameters)
  imageMoveAnimator?.addAnimations {
    self.imageContainer.center = location
  }
  imageMoveAnimator?.startAnimation()
}

UISpringTimingParameters还有一种初始化方法是:

init(dampingRatio: CGFloat, initialVelocity: CGVector)

dampingRatio参数为 1 是 critically damped spring ,小于 1 都算 under-damped

Initial velocity

我们为弹簧回弹时加一个初始速度,众所周知,速度是矢量(有大小,有方向),因此这里用 CGVector 类来表示。衡量它的大小通常用『瞬时速度/动画移动的距离』,比如动画以每秒 100 个点的速度移动了 100 个点,那么 vector 的值就为 1。

animateAnimalTo(location:) 方法加个参数

func animateAnimalTo(location: CGPoint, initialVelocity: CGVector = .zero)

最后我们在 handleDragImage(_:) 里的 .ended 条件下替换如下方法:

case .ended:  
  if let imageDragStartPosition = imageDragStartPosition {
    //1 返回一个 CGPoint,因为速度是矢量
    let animationVelocity = sender.velocity(in: view)
    //2
    let animationDistance = imageContainer.center.distance(toPoint:
    imageDragStartPosition)
//3
    let normalisedVelocity = animationVelocity.normalise(weight: animationDistance)
//4
    let initialVelocity = normalisedVelocity.toVector
    animateAnimalTo(
      location: imageDragStartPosition,
      initialVelocity: initialVelocity)
  }
  imageDragStartPosition = .none

用 CGPoint 表速度并不代表它是一个点,而是利用了它的特性存储了 x 和 y 的速度

Inspecting in-progress animations

除了定义时间曲线,Property Animator 还可以获取动画过程中任意节点的状态:

  • state 动画的状态
    • .inactive
    • .active
    • .stopped
  • isRunning 动画是否正在运行
  • isReversed 动画是否反转

本例中,state 已经通过 KVO 和 UISegmentedControl 进行了绑定,在 animateAnimalTo(location:initialVelocity:) 方法的一开始先移除观察者

removeAnimatorObservers(animator: imageMoveAnimator)

在执行动画 startAnimation() 前,再添加观察者

addAnimatorObservers(animator: imageMoveAnimator)

运行一下,这次就能看到青蛙的动画状态通过底部的 UISegmentedControl 实时显示了出来

iOS 10 by Tutorials 笔记(九)

Pausing and scrubbing

往常通过 UIView animations 设置完动画就不管了,最后也就是通过 completion block 来扫扫尾,但使用 Property Animators ,你可以触及到动画运行的任意节点,然后停止它。

这里我们来实现用户轻触动画中的青蛙来打断动画,找到 handleTapOnImage(_:) 方法添加如下代码:

@IBAction func handleTapOnImage(_ sender: UITapGestureRecognizer) {
  //1 确保 imageMoveAnimator 存在
  guard let imageMoveAnimator = imageMoveAnimator else {
    return
  }
  //2 每次开始前先重置隐藏 slider
  progressSlider.isHidden = true
  //3 判断动画的状态
  switch imageMoveAnimator.state {
  case .active:
    if imageMoveAnimator.isRunning {
      //4 暂停动画,并通过 slider 显示动画已经完成的百分比
      imageMoveAnimator.pauseAnimation()
      progressSlider.isHidden = false
      progressSlider.value = Float(imageMoveAnimator.fractionComplete)
  } else { 
      //5 如果已经是暂停状态了,就重新开启动画
      imageMoveAnimator.startAnimation()
    }
  default:
  break
}

为了实现拖动 slider 控制动画,在 handleProgressSliderChanged(_:) 方法中添加:

imageMoveAnimator?.fractionComplete = CGFloat(sender.value)

运行,尝试在动画过程中轻击青蛙,青蛙会暂停,再次轻击,动画又会继续。而在青蛙暂停过程中拖动 slider,也可以精确的控制动画的进程。 iOS 10 by Tutorials 笔记(九)

注意:拖动 slider 控制的是动画整体进度(progress),而不是时间尺度

Stopping

当 Property Animator 停止时(stop),它会在当前位置结束所有动画,更重要的是他会以目前的节点信息更新动画视图的属性。

如果你不想让动画继续了,可以选择停止动画,在 handleTapOnImage(_:) 方法的底部加入显示停止按钮的方法

stopButton.isHidden = progressSlider.isHidden

接着实现 stop 按钮的方法 handleStopButtonTapped(_:)

@IBAction func handleStopButtonTapped(_ sender: UIButton) {
  guard let imageMoveAnimator = imageMoveAnimator else {
    return
  }
  switch imageMoveAnimator.state {
  //1 对于激活状态,先停止。false 参数表示等待进一步指示,true 就立即结束
  不用执行后面的 finishAnimation 了
  case .active:
    imageMoveAnimator.stopAnimation(false)
  //2
  case .inactive:
    break
  //3 停止状态下就结束整个动画
  case .stopped:
    imageMoveAnimator.finishAnimation(at: .current)
}

再来看看这几种状态

  • Paused
    • 对应的 State 是 .active
    • 对应的 Running 是 true
  • Stopped
    • 对应的 State 是 .active
    • 对应的 Running 是 false
  • Finished
    • 对应的 State 是 .inactive
    • 对应的 Running 是 false

为什么 Property Animators 已经 stop 了,还要 finishAnimation 一下才算真正结束,这因为 Property Animators 能够被反转( reverse

Reversing

为什么需要反转动画呢,比如在 dismiss 的过程中用户临时决定中止了。同样的 Property Animator 替我们打理一切,并不需要手动去存储一些东西。

如果一个动画正在运行,我们通过再次点击 Animate 按钮来实现当前动画的反转。找到 handleAnimateButtonTapped(_:) 方法, 修改如下:

@IBAction func handleAnimateButtonTapped(_ sender: UIButton) {
  if let imageMoveAnimator = imageMoveAnimator, imageMoveAnimator.isRunning {
    imageMoveAnimator.isReversed = !imageMoveAnimator.isReversed
  } else {
    animateAnimalToRandomLocation()
  }
}

对于正在运行中的动画,我们反转一下

现在有三种方式结束动画:

  1. 正常结束
  2. 半路结束
  3. 反转到起始位置结束

所以我们可以添加一个 Completion Block 打印结束时的位置,找到 animateAnimalTo(location: initialVelocity:) 方法,在 addAnimations(_:) 的后面加入这个 block:

imageMoveAnimator?.addCompletion { position in  
  switch position {
  case .end: print("End")
  case .start: print("Start")
  case .current: print("Current")
  } 
}

Multiple animators

你也可以添加多个 Property Animator 在同一个 View 上让几个动画同时开始工作。这一节我们就再添加一个图片变换的动画(青蛙变成其他动物)

首先把资源库中的所有图片加入数组

let animalImages = [  
  #imageLiteral(resourceName: "bear"),
  #imageLiteral(resourceName: "frog"),
  #imageLiteral(resourceName: "wolf"),
  #imageLiteral(resourceName: "cat")
]

然后创建一个新的 Property Animator 负责图片变化动画

var imageChangeAnimator: UIViewPropertyAnimator?

接着添加新方法实现这个动画

func animateRandomAnimalChange() {  
  //1 取一个随机图片
  let randomIndex = Int(arc4random_uniform(UInt32(animalImages.count)))
  let randomImage = animalImages[randomIndex]
  //2 设置动画持续时间
  let duration = imageMoveAnimator?.duration ?? 3.0
  //3 用一个截图来存储先前的图片,然后设置当前图片为新的随机图片,设成完全透明的状态
  let snapshot = animalImageView.snapshotView(afterScreenUpdates: false)!
  imageContainer.addSubview(snapshot)
  animalImageView.alpha = 0
  animalImageView.image = randomImage
  //4 透明的随机图片开始显现,先前的截图变成透明
  imageChangeAnimator = UIViewPropertyAnimator(
      duration: duration,
      curve: .linear) {
        self.animalImageView.alpha = 1
        snapshot.alpha = 0
    }
  //5 动画结束时移除截图
  imageChangeAnimator?.addCompletion({ (position) in
    snapshot.removeFromSuperview()
  })
  //6 开始动画
  imageChangeAnimator?.startAnimation()
}

最后设置点击 Animate 按钮时让 imageChangeAnimatorimageMoveAnimator 同时开始动画。找到 handleAnimateButtonTapped(_:)animateAnimalToRandomLocation() 后调用上面这个方法

animateRandomAnimalChange()

运行一下动画,随着青蛙在屏幕上移动,它也会变成其他的动物 iOS 10 by Tutorials 笔记(九)

但当你轻击图片暂停的时候,发现 imageMoveAnimator 并没有暂停,下面我们来让两个动画尽量同步,找到 handleTapOnImage(_:) 加入 imageChangeAnimator 方法:

case .active:  
  if imageMoveAnimator.isRunning {
    imageMoveAnimator.pauseAnimation()
    imageChangeAnimator?.pauseAnimation()
    progressSlider.isHidden = false
    progressSlider.value = Float(imageMoveAnimator.fractionComplete)
  } else {
    imageMoveAnimator.startAnimation()
    imageChangeAnimator?.startAnimation()
  }

暂停的时候让 imageChangeAnimator 也暂停

滑动 slider 的时候也要能控制 imageChangeAnimatorhandleProgressSliderChanged(_:) 加入进度控制

imageChangeAnimator?.fractionComplete = CGFloat(sender.value)

反转也一样 handleAnimateButtonTapped(_:) 加入

imageChangeAnimator?.isReversed = imageMoveAnimator.isReversed

最后来处理停止操作, handleStopButtonTapped(_:) 如果当前是 active 状态,那么就简单地 pause 好了

case .active:  
  imageMoveAnimator.stopAnimation(false)
  imageChangeAnimator?.pauseAnimation()

但如果是已经停止的状态 stop,要完全结束,就要考虑到图片变换动画不同于图片移动动画,图片转换到一半结束看上去很奇怪。我们来修改一下,让它以新的参数继续执行完毕。

case .stopped:  
  imageMoveAnimator.finishAnimation(at: .current)
    if let imageChangeAnimator = imageChangeAnimator,
      let timing = imageChangeAnimator.timingParameters {
      imageChangeAnimator.continueAnimation(withTimingParameters: timing,
                                                  durationFactor: 0.2)
    }

运行,反转的时候发现一个 bug,动物图片直接消失了。还记得之前在动画结束时,我们移除了 snapshot,图片透明度设为 1(不透明),然而反转时图片变成了透明,但移除的 snapshot 却回不来了。

解决方法也很简单,在设置新图片前,先存储下旧图片

let originalImage = animalImageView.image

imageChangeAnimatorcompletion block 中加入

if position == .start {  
  self.animalImageView.image = originalImage
  self.animalImageView.alpha = 1
}

再次运行,一切完美

View controller transitions

Property Animators遵循了 UIViewImplicitlyAnimating 协议,因此还可以用在可交互的 view controller 转场动画上。提到可交互的转场动画,以前的版本就能实现。那么为什么又要搞一个呢?还是那句话:『新 API 提供了更多的控制性』。当你添加了 Property Animators,可以在交互与不可交互的模式下来回切换。

实例代码已经提供了转场动画细节,我们只需点击右下角的 Animals 按钮就能转场到另一个 View Controller 了

iOS 10 by Tutorials 笔记(九)

dismiss 靠手势向下拉

iOS 10 by Tutorials 笔记(九)

为了使转场动画能被中断, UIViewControllerAnimatedTransitioning 协议增加了一个新的方法

func interruptibleAnimator(using transitionContext:  
UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {  
  let animator = UIViewPropertyAnimator(
    duration: transitionDuration(using: transitionContext),
    curve: .easeInOut) {
      self.performAnimations(using: transitionContext)
  }
  return animator
}

它创建了一个 Property Animator 然后执行了同样的转场动画

本工程使用了 UIPercentDrivenInteractiveTransition 的子类来配合过渡时的百分比,这个类现在增加了一个 pause() 方法,它告诉 transition context 由不可交互到可交互之间切换。

如果在转场时想让第二个、或随后更多的触摸手势生效,打开 DropDownInteractionController.swift,这个类使用了 pan 手势来控制转场动画的进程。当手势结束/取消时,重新设置为不可交互(non-interactive)的模式

添加两个新属性

var hasStarted = false  
var interruptedPercent: CGFloat = 0

handle(pan:) 中更新 percent 属性

let percent = (translation / pan.view!.bounds.height) + interruptedPercent

在同样的手势处理方法中更新 .began case

case .began:  
  if !hasStarted {
    hasStarted = true
    isInteractive = true
    interruptedPercent = 0
    viewController?.dismiss(animated: true, completion: nil)
  } else {
    pause()
    interruptedPercent = percentComplete
  }

如果是还未开始,那么就设置各种属性,然后执行 dismiss;如果在 dismiss 时,这不是第一个手势,先暂停,然后再读取百分比。(不先暂停的话,读取到的数据不准)

最后一步,去 AppDelegate.swift 中,找到 animationController(forDismissed:) 方法,在动画的清理闭包 animationCleanup 中重置 hasStarted 属性

interactionController?.hasStarted = false

运行,当你下拉到一半准备 dismiss 时,松开手指,马上再次触屏之前的 VC,发现又能控制它了。


-EOF-

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址