如何创建一个翻书动画(Part2)[译]

欢迎回到 iOS 翻书动画教程系列!在该系列的第一部分(译文,你已经知道如何创建自定义的 layout 以及如何在 app 中使用阴影效果来创建景深和模拟现实。在这篇教程中,你将学到如何创建一个自定义的转场以及如何使用 pinch 手势来打开书本。

2019.11.21 update 本文中图片和资源相关链接可能已失效,如需查阅,请查看原文 感谢Attila Hegedüs创建了这个棒棒哒示例工程。

原文:How to Create an iOS Book Open Animation: Part 2

开始

这篇教程基于第一部分。如果你不了解第一部分或者想重新开始,可以下载上一教程的完整代码

在 Xcode 中打开工程。现在你可以选择一本书,然后书本从右滑出(即 push),这是 UINavigationController 的默认转场行为。但是在这片教程结束的时候,自定义转场看起来像这样:

自定义转场将在书本打开和合上的状态转换中进行丝滑的动画过渡,这种方式非常自然,深得我心。 来吧,骚年,开撸!

创建自定义导航控制器

想要实现自定义转场必须创建一个自定义的导航控制器然后实现 UINavigationControllerDelegate 协议。 右键 App 分组创建一个继承自 UINavigationController 名为 CustomNavigationController 的类。语言设置为 Swift。

打开CustomNavigationController.swift,用下面代码替换其内容:

import UIKit
 
class CustomNavigationController: UINavigationController, UINavigationControllerDelegate {
 
  override func viewDidLoad() {
    super.viewDidLoad()
    //1
    delegate = self
  }
 
  //2
  func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    if operation == .Push {
      return nil
    }
 
    if operation == .Pop {
      return nil
    }
 
    return nil
  }
}

上面代码主要做了两件事:

  • 1.在 viewDidLoad 方法中将代理设置为自身
  • 2.navigationController(_:animationControllerForOperation:fromViewController:toViewController:) 方法是协议方法中的一个。这个方法在每次 push 或者 pop 的时候被调用,你可以在此返回对应的转场类型动画。现在这个方法返回 nil 使得其使用默认的标准转场。马上你就会用你自定义的转场类来替换它。

既然导航控制器已经准备就绪,那么让我们开始来替换 storyboard 中默认的导航控制器

设置如下图所示:

运行一下,确保能正常运行,一切正常,因为你在代理方法中返回 nil,导致控制器使用默认转场行为。

创建自定义转场

终于来到重头戏环节————撸一个自定义转场! 在自定义转场类中,必须遵循 UIViewControllerAnimatedTransitioning 协议,特别是需要实现下面几个方法:

  • transitionDuration:必须实现。返回转场动画时间,以及同步交互转场动画
  • animateTransition:必须实现。提供转场过程中的源控制器和目的控制器。自定义转场的工作重心主要是在这个方法中完成
  • animationEnded:可选实现。在转场结束时调用。可以在该方法中还原之前的设置

设置你的转场

右键 App 分组新建一个继承自 NSObject 名为 BookOpeningTransition 的类,设置语言为 Swift。

打开它,然后用下面代码来替换其所有内容:

import UIKit
 
//1
class BookOpeningTransition: NSObject, UIViewControllerAnimatedTransitioning {
 
  // MARK: Stored properties
  var transforms = [UICollectionViewCell: CATransform3D]() //2
  var toViewBackgroundColor: UIColor? //3
  var isPush = true //4
 
  //5
  // MARK: UIViewControllerAnimatedTransitioning
  func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval {
    return 1
  }
 
  func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
 
  }
}

每个数字标号注释的解释:

  • 1.BookOpeningTransition 实现了 UIViewControllerAnimatedTransitioning 协议
  • 2.字典 transforms 存储了键为 UICollectionViewCell 值为 CATransform3D类型的键值对。当书本打开时,它存储了每个 cell 的 transform
  • 3.定义了目的控制器的背景色,使得渐变看起来更加清爽
  • 4.isPush 决定了转场为 push 还是 pop
  • 5.添加了协议中必须实现的方法避免编译器报错。紧接着就是要实现这些方法

一切变量设置就绪,是时候来实现协议方法了。

用下面代码来替换 transitionDuration(_:) 中的内容:

if isPush {
  return 1
} else {
  return 1
}

该方法返回了转场动画持续的时间,这里 pop 和 push都返回1秒。这个方法可以轻松改变转场动画的持续时间。 接下来需要实现第二个必须是闲的方法——animateTransition,这个方法让一切变得皆有可能。你将分两部分来实现:

写两个工具方法来分别实现 push 和 pop 的animateTransition。

创建 push 转场

回想一下实际生活中,你翻书的场景:

看起来很复杂,但是你只需要关心动画的两种状态,然后让 UIView 的 animateWithDuration 方法来实现两种状态之间的过渡:

  • 1.第一阶段:书被合上
  • 2.第二阶段:书被打开

在实现 animateTransition(_:) 前,首先写一个工具方法来处理两种状态。还是在 BookOpeningTransition.swift 中,在最后添加:

// MARK: Helper Methods
func makePerspectiveTransform() -> CATransform3D {
  var transform = CATransform3DIdentity
  transform.m34 = 1.0 / -2000
  return transform
}

该方法返回一个 transform,以及添加一个 z 轴上的透视。后面动画过程中你将会用到它来改变 view。

第一阶段——书本合起

接着在上述方法后面添加:

func closePageCell(cell : BookPageCell) {
  // 1
  var transform = self.makePerspectiveTransform()
  // 2
  if cell.layer.anchorPoint.x == 0 {
    // 3
    transform = CATransform3DRotate(transform, CGFloat(0), 0, 1, 0)
    // 4
    transform = CATransform3DTranslate(transform, -0.7 * cell.layer.bounds.width / 2, 0, 0)
    // 5
    transform = CATransform3DScale(transform, 0.7, 0.7, 1)
   }
   // 6
   else {
     // 7
     transform = CATransform3DRotate(transform, CGFloat(-M_PI), 0, 1, 0)
     // 8
     transform = CATransform3DTranslate(transform, 0.7 * cell.layer.bounds.width / 2, 0, 0)
     // 9
     transform = CATransform3DScale(transform, 0.7, 0.7, 1)
    }
 
    //10
    cell.layer.transform = transform
}

我们对每一个页面做了转换使其与书脊对齐,然后翻页时围绕着个轴做旋转来达到真实的翻阅效果。首先你想要书本是合上状态。这个方法使得每个页面平铺在封面的底部。如下图所示:

我们来解释一下上面的代码:

  • 1.使用之前创建的工具方法初始化一个新的 transform
  • 2.判断页面是否在书脊右侧
  • 3.如果是右侧页面,设其角度为0,使其呈平铺状态
  • 4.将页面居中并位于封面之下
  • 5.使页面的x,y 均乘以0.7.如果你不知道为什么要乘以0.7,回想上一篇教程中你曾将封面缩小到0.7。
  • 6.如果不是右侧页面,那就是左侧页面
  • 7.设置左侧页面角度为180度。
  • 8.使其位于封面之下,并居中
  • 9.同5
  • 10.设置 cell 的 transform

现在添加如下代码到上面方法之前:

func setStartPositionForPush(fromVC: BooksViewController, toVC: BookViewController) {
  // 1
  toViewBackgroundColor = fromVC.collectionView?.backgroundColor
  toVC.collectionView?.backgroundColor = nil
 
  //2
  fromVC.selectedCell()?.alpha = 0
 
  //3
  for cell in toVC.collectionView!.visibleCells() as! [BookPageCell] {
    //4
    transforms[cell] = cell.layer.transform
    //5
    closePageCell(cell)
    cell.updateShadowLayer()
    //6
    if let indexPath = toVC.collectionView?.indexPathForCell(cell) {
      if indexPath.row == 0 {
        cell.shadowLayer.opacity = 0
      }
    }
  }
}

该方法设置了第一阶段的转场。它同时使用两个 VC 来做动画:

  • fromVC:即书单 VC
  • toVC:书页 VC

相关解释:

  • 1.存储 BooksViewController 的 collectionView 的背景色,设置 BookViewController 中 collectionView 的背景色为 nil
  • 2.隐藏选中书籍的封面,toVC 将会处理封面图片的呈现
  • 3.遍历书本页面
  • 4.保存每个 cell 打开状态下的transform
  • 5.因为书本一开始是合上的,所以需要合上所有页面然后更新阴影层
  • 6.最后忽略封面的阴影

第二阶端——打开书籍

我们已经完成第一阶段的过渡转场,是时候撸一撸第二阶段的了。第二阶段是有闭合到打开的状态。

在 setStartPositionForPush(_:toVC:)) 方法下添加如下代码:

func setEndPositionForPush(fromVC: BooksViewController, toVC: BookViewController) {
  //1
  for cell in fromVC.collectionView!.visibleCells() as! [BookCoverCell] {
    cell.alpha = 0
  }
 
  //2
  for cell in toVC.collectionView!.visibleCells() as! [BookPageCell] {
    cell.layer.transform = transforms[cell]!
    cell.updateShadowLayer(animated: true)
  }
}

分析一下上面的代码:

  • 1.隐藏所有书的封面,因为我们将展示选中书籍的页面。
  • 2.遍历所有页面然后加载之前保存的打开状态下的 transform

当你从 BooksViewController push 到 BookViewController 之后,还原之前的一些设置。

加入以下代码:

func cleanupPush(fromVC: BooksViewController, toVC: BookViewController) {
  // Add background back to pushed view controller
  toVC.collectionView?.backgroundColor = toViewBackgroundColor
}

push 完成后将 BookViewController 的背景色设置为你之前保存的背景色,将下面所有东西都隐藏起来。

实现开书转场

上面所有工具方法已经整装待发,接着我们来实现 push 动画。将下面代码加到 animateTransition(_:) 中:

//1
let container = transitionContext.containerView()
//2
if isPush {
  //3
  let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) as! BooksViewController
  let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as! BookViewController
  //4
  container.addSubview(toVC.view)
 
  // Perform transition
  //5
  self.setStartPositionForPush(fromVC, toVC: toVC)
 
  UIView.animateWithDuration(self.transitionDuration(transitionContext), delay: 0.0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.7, options: nil, animations: {
    //6
    self.setEndPositionForPush(fromVC, toVC: toVC)
    }, completion: { finished in
      //7
      self.cleanupPush(fromVC, toVC: toVC)
      //8
      transitionContext.completeTransition(finished)
  })
} else {
  //POP
}

下面解释一下上面代码做了哪些事:

  • 1.获取容器视图,它在转场过程中充当父视图角色。
  • 2.判断当前是执行 push 操作
  • 3.获取 fromVC 和 toVC
  • 4.将 toVC 加入到当前容器视图
  • 5.设置闭合状态下 toVC 和 fromVC 的起始位置
  • 6.从起始位置做动画,直到终点位置
  • 7.还原设置
  • 8.告知系统转场已完成

在导航控制器中使用 push 转场

上面我们已经实现了 push 转场动画,是时候来使用它了。打开 BooksViewController.swift 将下面属性添加到类声明之后:

var transition: BookOpeningTransition?

这个属性是转场类实例,它帮助你判断当前转场是 push 还是 pop。添加如下扩展:

extension BooksViewController {
func animationControllerForPresentController(vc: UIViewController) -> UIViewControllerAnimatedTransitioning? {
  // 1
  var transition = BookOpeningTransition()
  // 2
  transition.isPush = true
  // 3
  self.transition = transition
  // 4
  return transition
  }
}
  • 1.创建一个 transition
  • 2.设置 isPush 为 true
  • 3&4.保存当前 transition,返回 transition

接着打开 CustomNavigationController.swift 用下面代码替换 push 的 if 判断

if operation == .Push {
  if let vc = fromVC as? BooksViewController {
    return vc.animationControllerForPresentController(toVC)
  }
}

这一步判断判断是否从 BooksViewController 中 push 过来的,然后用你创建的 BookOpeningTransition 来做转场展示你的 BookViewController。

运行,选中某本书你会看到,书本在开、合之间的动画非常顺畅。

WTF...这货看起来没有动画?!

它直接从闭合状态跳转到打开状态,不要慌,这是因为你还没有加载页面 cell。 导航控制器从 BooksViewController 过渡到 BookViewController,他们两个都是继承自 UICollectionViewController。UICollectionViewCell 没有在主线程中加载,所以没有动画过程。 你需要给 collectionView 足够的时间让它来加载所有的 cell。 打开 BooksViewController.swift 然后使用下面代码替换 openBook(_:):

func openBook(book: Book?) {
  let vc = storyboard?.instantiateViewControllerWithIdentifier("BookViewController") as! BookViewController
  vc.book = selectedCell()?.book
  //1
  vc.view.snapshotViewAfterScreenUpdates(true)
  //2
  dispatch_async(dispatch_get_main_queue(), { () -> Void in
    self.navigationController?.pushViewController(vc, animated: true)
    return
  })
}

下面说下是如何解决这个问题的:

  • 1.当转场要发生时告诉 BookViewController 去截取当前视图
  • 2.确定是在主线程中 push,来给 cell 足够的时间进行加载

运行程序,应该和下图类似:

看起来更完美了。至此 push 的转场已经完成,继续撸 pop 的转场。

实现 Pop 转场的工具方法

pop 的过程和 push 过程刚好相反。第一阶段是书打开状态,第二阶段是书本闭合状态。

打开 BookOpeningTransition.swift 添加如下代码:

// MARK: Pop methods
func setStartPositionForPop(fromVC: BookViewController, toVC: BooksViewController) {
  // Remove background from the pushed view controller
  toViewBackgroundColor = fromVC.collectionView?.backgroundColor
  fromVC.collectionView?.backgroundColor = nil
}

该方法存储了 BookViewController 的背景色然后移除了 BooksViewController 中 collectionView 的背景色。我们不需要设置任何的 transform,因为书本当前状态就是打开状态。 接下来添加如下代码到上述代码之后:

func setEndPositionForPop(fromVC: BookViewController, toVC: BooksViewController) {
  //1
  let coverCell = toVC.selectedCell()
  //2
  for cell in toVC.collectionView!.visibleCells() as! [BookCoverCell] {
    if cell != coverCell {
      cell.alpha = 1
    }
  }      
  //3
  for cell in fromVC.collectionView!.visibleCells() as! [BookPageCell] {
    closePageCell(cell)
  }
}

该方法设置 pop 转场的最终状态:

  • 1.获取当前选中书本的封面
  • 2.在书本闭合状态,遍历 BooksViewController 所有书本封面,然后渐显
  • 3.遍历 BookViewController 中所有的 cell,然后将它们转换成闭合态

然后加入以下代码:

func cleanupPop(fromVC: BookViewController, toVC: BooksViewController) {
  // Add background back to pushed view controller
  fromVC.collectionView?.backgroundColor = self.toViewBackgroundColor
  // Unhide the original book cover
  toVC.selectedCell()?.alpha = 1
}

该方法在 pop 转场结束后做了一些还原工作。主要是将 BooksViewController 的 collectionView 的背景色还原成之前的状态,以及展示之前的书本封面。 把下面代码加到代理方法 animateTransition(_:) 中的 带有 //POP 注释的 else 大括号内。

//1
let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) as! BookViewController
let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as! BooksViewController
 
//2
container.insertSubview(toVC.view, belowSubview: fromVC.view)
 
//3
setStartPositionForPop(fromVC, toVC: toVC)
UIView.animateWithDuration(self.transitionDuration(transitionContext), animations: {
  //4
  self.setEndPositionForPop(fromVC, toVC: toVC)
}, completion: { finished in
  //5
  self.cleanupPop(fromVC, toVC: toVC)
  //6
  transitionContext.completeTransition(finished)
})

下面解释下 pop 转场动画的工作原理:

  • 1.获取转场过程中的控制器。fromVC 现在变成了 BookViewController,toVC 变成了 BooksViewController。
  • 2.在 containerView 中把 BooksViewController 的视图放置到 BookViewController 视图下面。
  • 3.setStartPositionForPop(_:toVC) 方法存储了背景色
  • 4.用动画形式将书本有打开状态转换到闭合状态
  • 5.动画完成则做还原设置,将背景色设置为之前保存的,然后显示书本封面
  • 6.通知转场完成

在导航控制器中使用 pop 转场

现在让我们像之前添加自定义 push 动画那样将 pop 动画也加入到代理方法中去。 打开 BooksViewController.swift 然后在 animationControllerForPresentController(_:) 方法后添加如下代码:

func animationControllerForDismissController(vc: UIViewController) -> UIViewControllerAnimatedTransitioning? {
  var transition = BookOpeningTransition()
  transition.isPush = false
  self.transition = transition
  return transition
}

这个方法同样创建一个 BookOpeningTransition 实例,唯一不同的是其 transition 设置为 pop。 打开 CustomNavigationController.swift 用下面代码替换之前的 if 逻辑:

if operation == .Pop {
  if let vc = toVC as? BooksViewController {
    return vc.animationControllerForDismissController(vc)
  }
}

它返回一个 transition,然后执行 pop 动画来把书合上。 运行程序,选中一本书,看下它的打开和闭合状态,应该和下图类似:

创建一个可交互的导航控制器

打开和关闭转场动画看起来非常屌,但是你可以做得更好。你可以使用 pinch 手势来控制书的开、合。 首先打开 BookOpeningTransition.swift 添加如下属性:

// MARK: Interaction Controller
var interactionController: UIPercentDrivenInteractiveTransition?

接着打开 CustomNavigationController.swift 然后添加如下代码:

func navigationController(navigationController: UINavigationController, interactionControllerForAnimationController animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
  if let animationController = animationController as? BookOpeningTransition {
    return animationController.interactionController
  }
  return nil
}

上述方法返回一个可交互的动画对象。它使得导航控制器控制着整个动画过程,这样用户就可以使用 pinch 手势来控制书本的开、合。 打开 BooksViewController.swift 在transition 变量下添加如下属性:

//1
var interactionController: UIPercentDrivenInteractiveTransition?
//2
var recognizer: UIGestureRecognizer? {
  didSet {
    if let recognizer = recognizer {
      collectionView?.addGestureRecognizer(recognizer)
    }
  }
}

下面解释为什么要添加这几个变量:

  • 1.interactionController 是一个 UIPercentDrivenInteractiveTransition 实例,它管理 VC 转场过程中自定义动画的出现和消失。这个可交互控制器同样依赖一个 transition animator。这个 animator 实现了 UIViewControllerAnimatorTransitioning 协议,你刚才创建的 BookOpeningTransition 就是干这件事的。iteractionController 可以控制 push 和 pop 的过程,如果想要了解更多细节可以参考苹果官方文档
  • 2.recognizer 是一个 UIGestureRecognizer 实例。你可以使用它来控制书本的开、合。

在 BooksViewController 扩展中的 animationControllerForPresentController(_:) 方法中添加如下代码,将其放在 transition.isPush = true 这一行之后:

transition.interactionController = interactionController

这行代码让自定义导航控制器知道使用那一个交互控制器。 同样把上面在添加到 animationControllerForDismissController(_:) 方法中 transition.isPush = false 之后。 紧接着在 viewDidLoad() 中加入下面一行代码:

recognizer = UIPinchGestureRecognizer(target: self, action: "handlePinch:")

它初始化了一个 UIPinchGestureRecognizer 实例,这个 pinch 手势的 action 是 handlePinch(_:)。

现在我们来实现 handlePinch(_:) 这个方法:

// MARK: Gesture recognizer action
func handlePinch(recognizer: UIPinchGestureRecognizer) {
  switch recognizer.state {
    case .Began:
      //1
      interactionController = UIPercentDrivenInteractiveTransition()
      //2
      if recognizer.scale >= 1 {
        //3
        if recognizer.view == collectionView {
          //4
          var book = self.selectedCell()?.book
          //5
          self.openBook(book)
        }
      //6
      } else {
        //7
        navigationController?.popViewControllerAnimated(true)
      }        
    case .Changed:
      //8
      if transition!.isPush {
        //9
        var progress = min(max(abs((recognizer.scale - 1)) / 5, 0), 1)
        //10
	interactionController?.updateInteractiveTransition(progress)
	//11
      } else {
        //12
	var progress = min(max(abs((1 - recognizer.scale)), 0), 1)
        //13
	interactionController?.updateInteractiveTransition(progress)
      } 
    case .Ended:
      //14
      interactionController?.finishInteractiveTransition()
      //15
      interactionController = nil
    default:
      break
  }
}

对于 UIPinchGestureRecognizer,我们关心三种不同的状态:began,changed,end。

begin状态

  • 1.初始化一个 UIPercentDrivenInteractiveTransition 对象
  • 2.判断 scale, 也就是 pinch 手势移动的距离,看起是否大于等于1
  • 3.如果是,确保手势发生在 colletionView 当中
  • 4.获取当前手势所作用的书脊
  • 5.执行 push 转场动画,显示书籍页面
  • 6.如果小于1
  • 7.执行 pop 动画来展示书本封面

changed 状态

  • 8.判断当前转场是否为 push
  • 9.如果正 push 到 BookViewController,获取用户 pinch 手势的百分比。将 pinch 手势缩小为其原始值的 1/5,这样用户更加容易控制转场过程
  • 10.根据之前计算的白封闭更新 transition 完成状态的百分比。
  • 11.如果当前转场不是 push,那肯定是 pop
  • 12.当使用 pinch 手势控制书本关闭时,缩放比一定是从1变到0
  • 13.最后更新 transition 的进度

end 状态

  • 14.通知系统用户转场交互已完成
  • 15.将交互 controller 置为 nil

最后,你需要实现 pinch-to-closed 状态。因此你需要将手势传递给 BookViewController,这样他就能自发进行 pop。

var recognizer: UIGestureRecognizer? {
  didSet {
    if let recognizer = recognizer {
      collectionView?.addGestureRecognizer(recognizer)
    }
  }
}

当你在 BookViewController 中设置好手势时,它会马上被加到 collectionView 中区,这样我们就可以在用户合上书本的时候追踪 pinch 手势。 下面需要在 BooksViewController 和 BookViewController 之间进行手势的传递。 打开 BookOpeningTransition.swift 添加下面一行代码到 cleanUpPush(_:toVC) 方法中,并且将它放在设置背景色之后:

// Pass the gesture recognizer
toVC.recognizer = fromVC.recognizer

当从 BooksViewController push 到 BookViewController后,你需要将手势回传。 加入下面代码到 cleanUpPop(_:toVC) 方法中,同样是放在设置背景色之后:

// Pass the gesture recognizer
toVC.recognizer = fromVC.recognizer

运行程序,选中任意一本书然后使用 pinch 手势来控制书本的开、合。

用 pinch 收拾来控制书本的开合显得非常自然,同样可以让界面更加简洁,我们不再需要导航栏上的返回按钮,是时候来清理它了。

如下图设置即可:

继续运行,可以看到导航栏不复存在画面变得更加简洁!:]

何去何从

你可以在此下载完整代码。在这系列教程中,你学会了如何使用自定义布局、自定义转场、使用手势来控制转场交互。 我希望你喜欢这篇教程并从中受益,我想在此感谢Attila Hegedüs创建了这个碉堡的项目。 如有任何疑问,请在下面留言指出。

本博客所有文章除特别声明外,均采用CC 4.0许可协议。转载请注明出处和作者。

关注微信公共号Vong或在微博上关注@Vong_HUST,永远不会错过新内容! 您的支持和鼓励将为我的博客写作增添更多的动力!

动态更新