自定义 UIViewController 转换动画: 开始

原文:Custom UIViewController Transitions: Getting Started
作者:Richard Critz
译者:kmyhy

更新说明: 本教程由 Richard Critz 更新至 iOS11 和 Swift 4。原文作者是 József Vesza。

iOS 内置了一些好看的 View Controller 转换动画——push、pop、cover vertically——这些都是现成的,但创建自己的动画岂不更有趣呢?自定义 UIViewController 转换能大大地提高用户体验,并让你的 app 明显超出其它 app 一大截。如果你曾经因为这个过程太难而不愿意自定义转换动画,你会发现其实它并没有你想象中的那么难。

在本教程中,我们将为一个简单的猜谜游戏添加自定义 UIViewController 转换动画。在最后,你将学习到:

  • Transitioning API 的结构
  • 如何用自定义转换动画呈现、解散 View Controller
  • 如何构建交互式转换

注意:本教程中演示的转换动画使用的是 UIView 动画,你需要对此有所了解。如果你需要帮助,请参考我们的 iOS Animation 以便快速进入我们的主题。

开始

下载开始项目。Build & run,你会看到:

这个 app 用一个 page view controller 来展现几个不同的卡片。每张卡片显示一段关于宠物的描述,当你点击一张卡片显示它所描述的是什么样的宠物。

你的任务是猜猜这是什么宠物?猫、狗、还是鱼?试完一下 app,看看你猜得准不准?

导航逻辑是写好的,但 app 给人的感觉平淡无奇。我们将通过自定义转换动画来为它增添一些色彩。

介绍 Tansitioning API

Transitioning API 是一个协议集。它允许你为你的 app 选择最合适的一种实现方式:使用负责管理转换动画的现成对象或者创建专门的对象。这一节结束,你将了解每个协议的作用及其相互间的关联。下图显示了 API 的组成部分:

组成部分

尽管图很复杂,但一旦你理解如何将各部分组装起来之后就会变得很简单了。

Transitioning Delegate

每个 view controller 都有一个 transitioningDelegate 属性,这个对象实现了 UIViewControllerTransitioningDelegate 协议。

当你呈现或解散一个 view controller 时,UIKit 会询问 transitioning delegate 对象要使用哪一个 animation controller。要将默认的动画替换成你自己的动画,你必须实现一个 transitioning delegate 并通过它返回一个特定的动画控制器。

Animation Controller

transitioning delegate 对象所返回的 animation controller 对象则实现了 UIViewControllerAnimatedTransitioning 协议。它负责实现转换动画的“重体力活”。

Transitioning Context

Transitioning contenxt 对象实现了 UIViewControllerContextTransitioning 协议并负责转换过程中的一个重要角色:它封装了和动画相关的视图和视图控制器的信息。

如你在上图中所见,你不需要自己实现这个协议。UIKit 会为你创建和配置 transitioning context 并在动画发生时传递给你的 animation controller。

转换动画的工作流程

在呈现动画中包括:

  1. 触发动画,无论是以编码方式还是 segue 方式。
  2. UIKit 会询问 “to” view controlle(即将呈现的 view controller)它的 transitioning delegate 是谁。如果没有提供,UIKit 会使用标准的、内置的转换动画。
  3. 然后 UIKit 会通过 animationController(forPresented:presenting:source:) 方法向 transitioning delegate 对象索要一个 animation controller。如果返回 nil,转换过程将使用默认的动画。
  4. UIKit 会创建 transitioning context。
  5. UIKit 会通过 transitionDuration(using:) 方法向 animation controller 询问动画需要的时长。
  6. UIKit 调用 animation controller 的 animateTransition(using:) 方法,以执行转换动画。
  7. 最后,animation controller 调用 transitioning context 上的 completeTransition(_:) 方法,通知动画已经完成。

解散过程与此类似。只不过,UIKit 是向 “from” view controller(即将被解散的控制器)索要 transitioning delegate 对象。而 transitioning delegate 对象是通过 animationController(forDismissed:) 方法返回 animation controller。

创建自定义呈现动画

到了真枪实干的时候了!我们的目的是实现这个动画:

  • 当用户点击卡片,它会翻过第二个视图,第二个视图缩小到卡片的尺寸。
  • 翻转动作完成后,第二个视图放大到整屏。

创建 Animator

开始来创建 animation controller。

File\New\File…,选择 iOS\Source\Cocoa Touch Class 然后点 Next。文件命名为 FlipPresentAnimationController,继承 NSObject ,余元选 Swift。点 Next,勾上 Group to Animation Controllers。点击 Create。

Animation controllers 必须实现 UIViewControllerAnimatedTransitioning 协议。打开 FlipPresentAnimationController.swift 并适当修改类声明。

class FlipPresentAnimationController: NSObject,UIViewControllerAnimatedTransitioning {

}

Xcode 会报错,说 FlipPresentAnimationController 未实现 UIViewControllerAnimatedTransitioning 协议,点击 Fix to 添加对应的空方法。

我们在动画一开始会用到所点击的卡片的 frame。在类的实现中,添加一个属性来保存这个信息。

private let originFrame: CGRect

init(originFrame: CGRect) {
  self.originFrame = originFrame
}

然后,你需要在刚才新增的两个空方法中编写代码。将 transitionDuration(using:) 修改为:

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
  return 2.0
}

正如方法名所暗示的,这个方法用于返回动画时长。将其设置为 2 秒足以让你有足够的时间看到这个动画。

在 animateTransition(using:) 方法中添加:

// 1
guard let fromVC = transitionContext.viewController(forKey: .from),let toVC = transitionContext.viewController(forKey: .to),let snapshot = toVC.view.snapshotView(afterScreenUpdates: true)
  else {
    return
}

// 2
let containerView = transitionContext.containerView
let finalFrame = transitionContext.finalFrame(for: toVC)

// 3
snapshot.frame = originFrame
snapshot.layer.cornerRadius = CardViewController.cardCornerRadius
snapshot.layer.masksToBounds = true

这段代码中做了这些事情:

  • 获取两个 View controller 的引用:将要被替换的 view controller 和将被呈现的 view controller。然后对动画结束时的屏幕内容进行截图。
  • UIKit 将整个转封装到一个容器 view 中,以便简化对视图树和动画的管理。获得对容器视图的引用,然后计算新视图的最终框架 frame 有多大。
  • 配置屏幕截图的 frame 让它和 from 视图的 frame 一致并盖住卡片。

继续在 animateTransition(using:) 方法中添加:

// 1
containerView.addSubview(toVC.view)
containerView.addSubview(snapshot)
toVC.view.isHidden = true

// 2
AnimationHelper.perspectiveTransform(for: containerView)
snapshot.layer.transform = AnimationHelper.yRotation(.pi / 2)
// 3
let duration = transitionDuration(using: transitionContext)

container view 在刚刚被 UIKit 创建时,它只包含了 from 视图。你必须将动画中涉及到的其它视图添加进去。记住 addSubview(_:) 方法会将新的视图添加到视图树的最上面,因此视图树的顺序就是你添加它们的顺序。

  1. 将新的 to 视图添加到视图树然后隐藏它。将截图放到它的前面。
  2. 设置动画的开始状态,将截图的 y 轴旋转 90°,这会导致它以侧向的姿态面对观察者,也就是在动画的一开始它不可见。
  3. 获得动画时长。

注意:AnimatorHelper 是一个工具类,用于给视图添加透视和旋转变形。你可以看看它的实现。如果你想了解 perspectiveTransform 方法的原理,请在完成教程后为这个方法添加注释。

现在前期工作完成了,来执行动画吧!添加这个方法最后的代码:

// 1
UIView.animateKeyframes(
  withDuration: duration,delay: 0,options: .calculationModeCubic,animations: {
    // 2
    UIView.addKeyframe(withRelativeStartTime: 0.0,relativeDuration: 1/3) {
      fromVC.view.layer.transform = AnimationHelper.yRotation(-.pi / 2)
    }

    // 3
    UIView.addKeyframe(withRelativeStartTime: 1/3,relativeDuration: 1/3) {
      snapshot.layer.transform = AnimationHelper.yRotation(0.0)
    }

    // 4
    UIView.addKeyframe(withRelativeStartTime: 2/3,relativeDuration: 1/3) {
      snapshot.frame = finalFrame
      snapshot.layer.cornerRadius = 0
    }
},// 5
  completion: { _ in
    toVC.view.isHidden = false
    snapshot.removeFromSuperview()
    fromVC.view.layer.transform = CATransform3DIdentity
    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})

这是详细解释:

  1. 我们使用了标准的 keyframe 动画。动画时长必须必须和转换时长完全一致。
  2. 一开始沿 y 轴旋转 from 视图 90°,隐藏它。
  3. 显示截图,将它从侧向状态旋转回。
  4. 设置截图的框架大小填充全屏。
  5. 截图现在已经完全和 to 视图一致了,因此可以安全第显示真正的 to 视图了。从视图树中删除截图,因为它不再需要。然后将 from 视图恢复原有状态;否则当转换动画结束它会被隐藏。。调用 completeTransition(_:) 告诉 UIKit 动画已经完成。这将确保最终状态是一致的并从容器视图中移除 from 视图。

你的 animation controller 已经准备好了!

使用 animator

UIKit 需要一个 transitioning delegate 对象为它提供 animation controller。因此,你必须用某个对象来实现 UIViewControllerTransitioningDelegate 协议。在本例中,我们用 CardViewController 来充当这个 transitioning delegate>

打开 CardViewController.swift 在文件最后声明一个扩展。

extension CardViewController: UIViewControllerTransitioningDelegate {
  func animationController(forPresented presented: UIViewController,presenting: UIViewController,source: UIViewController)
    -> UIViewControllerAnimatedTransitioning? {
    return FlipPresentAnimationController(originFrame: cardView.frame)
  }
}

我们在这里返回一个自己定义的 animation controller 对象,用当前卡片的 frame 进行初始化。

最后是将 CardViewController 设置为 transitioning delegate。View Controller 有一个 transitioningDelegate 属性,UIKit 会通过它来判断是否要使用自定义的转换动画。

在 prepare(for:sender:) 方法中的对 card 赋值之后添加:

destinationViewController.transitioningDelegate = self

注意,是对被呈现的(presented) view controller 索要 transitioning delegate,而不是对触发呈现动作的(presenting) view controller 进行索要。

Build & run。点击一张卡片,你会看到:

这就是你的第一个自定义转换动画!

好棒!

解散视图控制器

你完成了一个漂亮的呈现动画,但这只完成了一半的工作。你的解散过程仍然是默认的。让我们来搞定它!

打开 File\New\File…,选择 iOS\Source\Cocoa Touch Class,然后点击 Next。文件名为 FlipDismissAnimationController,让它继承 NSObject 并指定语言为 Swift。点击 Next 并将文件夹指定到 Animation Controllers。点击 Create。
将类定义修改成:

class FlipDismissAnimationController: NSObject,UIViewControllerAnimatedTransitioning {

  private let destinationFrame: CGRect

  init(destinationFrame: CGRect) {
    self.destinationFrame = destinationFrame
  }

  func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return 0.6
  }

  func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

  }
}

这个 animation controller 的工作是对呈现动画进行逆向操作,这样 UI 上给人的感觉是对称的。要做到这一点,你需要:

  • 将正在显示的视图缩小到卡片大小;这个值保存在 destinationFrame 中。
  • 翻转视图,显示原来的卡片。

在 animateTransition(using:) 方法中添加代码。

// 1
guard let fromVC = transitionContext.viewController(forKey: .from),let toVC = transitionContext.viewController(forKey: .to),let snapshot = fromVC.view.snapshotView(afterScreenUpdates: false)
  else {
    return
}

snapshot.layer.cornerRadius = CardViewController.cardCornerRadius
snapshot.layer.masksToBounds = true

// 2
let containerView = transitionContext.containerView
containerView.insertSubview(toVC.view,at: 0)
containerView.addSubview(snapshot)
fromVC.view.isHidden = true

// 3
AnimationHelper.perspectiveTransform(for: containerView)
toVC.view.layer.transform = AnimationHelper.yRotation(-.pi / 2)
let duration = transitionDuration(using: transitionContext)

看起来眼熟啊。不同之处在于:

  1. 这次,你要操作的是 from 视图,因此你对它进行了截图。
  2. 再次强调图层顺序的重要性。从后到前,它们应当是:to 视图、from 视图、截屏视图。当然,在本例中这个顺序貌似也不重要,但在某些时候却很重要,尤其是动画可以被取消的情况下。
  3. 将 to 视图旋转到侧立状态,这样在旋转截屏视图时,它不会被马上看到。

然后开始真正的动画。在 animateTransition(using:) 继续编写代码。

UIView.animateKeyframes(
  withDuration: duration,animations: {
    // 1
    UIView.addKeyframe(withRelativeStartTime: 0.0,relativeDuration: 1/3) {
      snapshot.frame = self.destinationFrame
    }

    UIView.addKeyframe(withRelativeStartTime: 1/3,relativeDuration: 1/3) {
      snapshot.layer.transform = AnimationHelper.yRotation(.pi / 2)
    }

    UIView.addKeyframe(withRelativeStartTime: 2/3,relativeDuration: 1/3) {
      toVC.view.layer.transform = AnimationHelper.yRotation(0.0)
    }
},// 2
  completion: { _ in
    fromVC.view.isHidden = false
    snapshot.removeFromSuperview()
    if transitionContext.transitionWasCancelled {
      toVC.view.removeFromSuperview()
    }
    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})

这实际上是呈现动画的逆过程。

  1. 首先,缩小截屏视图,让它旋转 90° ,让它不可见。然后,将 to 视图从侧立状态旋转回 0°,以便显示它。
  2. 清除你对视图树所做的修改,移除截屏视图,恢复 from 视图的状态。如果转换动画被取消——对于本例而言这不支持,但对于后期来说这是可能的——有一点非常重要,就是在你通知动画完成之前,将你添加到视图中的东西删除。

最后,还要在宠物图片解散时让 transitioning delegate 返回这个 animation controller。

打开 CardViewController.swift 在 UIViewControllerTransitioningDelegate 扩展中添加下列方法。

func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
  guard let _ = dismissed as? RevealViewController else {
    return nil
  }
  return FlipDismissAnimationController(destinationFrame: cardView.frame)
}

确保被解散的 View controller 我们所期望的类型,然后创建 animation controller,提供一个正确的卡片显示时的 frame。

现在不需要将呈现动画的时长设置得那么慢了。打开 FlipPresentAnimationController.swift 将 duration 从 2.0 修改成 0.6,这样它就和你的解散动画相一致了。

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
  return 0.6
}

Build & run。测试一下 app,欣赏一下新的转换动画。

添加交互

你的自定义动画看起来不错。但是,你还可以更进一步,在解散动画中添加与用户交互的能力。iOS 的设置 app 是一个很好的交互式转换动画的例子:

在这一节中,我们的任务是通过从左轻扫的手势返回到卡片背面朝上的状态。转换动画的进度将跟随用户的手指而定。

交互式转换动画的工作机制

一个交互式控制器能够响应触摸事件或者程序输入,它能够加快、减慢甚至反向动画过程。为了使用交互式转换动画,transitioning delegate 必须提供一个交互式控制器。这是另外一种实现了 UIViewControllerInteractiveTransitioning 协议的对象。

你已经创建了一个转换动画。交互式控制器会根据手势的响应来管理动画,而不仅仅是播放一个视频。苹果提供了一个预置的 UIPercentDrivenInteractiveTransition 类,它就是一个交互式控制器的具体实现。你可以用这个类来创建自己的交互式转换动画。

点击 File\New\File…,选择 iOS\Source\Cocoa Touch Class,然后点击 Next。命名文件为 SwipeInteractionController,让它继承 UIPercentDrivenInteractiveTransition ,语言选择 Swift。点击 Next,将文件夹指定为 Interaction Controllers。点击 Create。

在类中编写代码:

var interactionInProgress = false

private var shouldCompleteTransition = false
private weak var viewController: UIViewController!

init(viewController: UIViewController) {
  super.init()
  self.viewController = viewController
  prepareGestureRecognizer(in: viewController.view)
}

这些定义非常易懂。

  • interactionInProgress,正如名称所暗示的,用于表示一个交互是否已经发生。
  • shouldCompleteTransition 用于在内部控制这个动画。后面你会看到。
  • viewController 引用了这个交互式控制器所属的 view controller。

然后是创建手势识别器。

private func prepareGestureRecognizer(in view: UIView) {
  let gesture = UIScreenEdgePanGestureRecognizer(target: self,action: #selector(handleGesture(_:)))
  gesture.edges = .left
  view.addGestureRecognizer(gesture)
}

这个手势识别器在用户从屏幕左边沿轻扫时触发,将它添加到视图中。

最后是 handleGesture(_:) 方法。在类中添加:

@objc func handleGesture(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) {
  // 1
  let translation = gestureRecognizer.translation(in: gestureRecognizer.view!.superview!)
  var progress = (translation.x / 200)
  progress = CGFloat(fminf(fmaxf(Float(progress),0.0),1.0))

  switch gestureRecognizer.state {

  // 2
  case .began:
    interactionInProgress = true
    viewController.dismiss(animated: true,completion: nil)

  // 3
  case .changed:
    shouldCompleteTransition = progress > 0.5
    update(progress)

  // 4
  case .cancelled:
    interactionInProgress = false
    cancel()

  // 5
  case .ended:
    interactionInProgress = false
    if shouldCompleteTransition {
      finish()
    } else {
      cancel()
    }
  default:
    break
  }
}

这是具体解释:

  1. 声明一个局部变量来跟踪轻扫的进度。首先获得视图中的 translation 并计算出进度。轻扫超过 200 个像素,我们就可以认为整个动画可以算作是完成了。
  2. 当手势开始,设置 interactionInProgress 为 ture,然后触发 view controller 的解散。
  3. 当手势还在移动中,我们不断调用 update(_:) 方法。这是 UIPercentDrivenInteractiveTransition 中的一个方法,它会根据你传入的百分数播放动画。
  4. 如果手势被取消,更新 interactionInProgress 并回滚动画。
  5. 当手势结束,根据当前动画的进度来决定是要 cancel() 还是要 finish() 动画。

现在,你必须来真正创建你的 SwipeInteractionController。打开 RevealViewController.swift 添加下列属性。

var swipeInteractionController: SwipeInteractionController?

然后,在 viewDidLoad() 方法最后添加:

swipeInteractionController = SwipeInteractionController(viewController: self)

当宠物卡片的照片显示时,会创建一个 interaction controller 并赋给这个属性。

打开 FlipDismissAnimationController.swift 在 destinationFrame 后添加属性。

let interactionController: SwipeInteractionController?

将 init(destinationFrame:) 修改成:

init(destinationFrame: CGRect,interactionController: SwipeInteractionController?) {
  self.destinationFrame = destinationFrame
  self.interactionController = interactionController
}

这个 animation controller 必须获得一个 interaction controller 的引用,这样它们两才能成为一对好基友。

打开 CardViewController.swift 将animationController(forDismissed:) 修改为:

func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
  guard let revealVC = dismissed as? RevealViewController else {
    return nil
  }
  return FlipDismissAnimationController(destinationFrame: cardView.frame,interactionController: revealVC.swipeInteractionController)
}

这里将 FlipDismissAnimationController 的创建改成和新的初始化方法相一致。

最后,UIKit 是通过调用 transitioning delegate 对象的interactionControllerForDismissal(using:) 方法来索要 interaction controller 的。在 UIViewConrollerTransitioningDelegate 扩展的最后添加zhege 方法:

func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
  guard let animator = animator as? FlipDismissAnimationController,let interactionController = animator.interactionController,interactionController.interactionInProgress
    else {
      return nil
  }
  return interactionController
}

这首先会检查 animation controller 是否是一个 FlipDismissAnimationController。如果是,获得一个对 interaction controller 的引用,并检查是否处于和用户交互的过程中。如果这些条件任何一个不满足,返回 nil,这样动画将以非交互的方式进行。否则,将 interaction controller 返回给 UIKit,以便它能够执行这种转换。

Build & run。点击一张卡片,然后从屏幕左边沿开始滑动,看看最终效果。

恭喜你!你创建了一个有趣和迷人的交互式转换动画!

接下来做什么?

你可以从这里下载已经完成的项目。

要学习更多动画,请阅读《iOS Animations by Tutorials》第17张“呈现控制器和方向动画” 。

本教程主要介绍了模式呈现和解散动画。有一点需要注意,自定义 UIViewController 转换动画也能用在 container view controller 上:

  • 当使用导航控制器时,负责提供 animation controller 的是它的 delegate,即一个实现了 UINavigationControllerDelegate 的对象。这个委托必须用 navigationController(_:animationControllerFor:from:to:) 方法来提供 animation controller。
  • Tab bar controller 需要用实现 UITabBarControllerDelegate 协议的对象来返回 animation controller,使用的是 tabBarController(_:animationControllerForTransitionFrom:to:) 方法。

希望你喜欢本教程。如果有任何问题和建议,请在论坛中留言。

相关文章

软件简介:蓝湖辅助工具,减少移动端开发中控件属性的复制和粘...
现实生活中,我们听到的声音都是时间连续的,我们称为这种信...
前言最近在B站上看到一个漂亮的仙女姐姐跳舞视频,循环看了亿...
【Android App】实战项目之仿抖音的短视频分享App(附源码和...
前言这一篇博客应该是我花时间最多的一次了,从2022年1月底至...
因为我既对接过session、cookie,也对接过JWT,今年因为工作...