动画示例(八) —— UIViewController间转场动画的实现 (一)

版本记录

版本号 时间
V1.0 2018.08.24

前言

如果你细看了我前面写的有关动画的部分,就知道前面介绍了CoreAnimation、序列帧以及LOTAnimation等很多动画方式,接下来几篇我们就以动画示例为线索,进行动画的讲解。相关代码已经上传至GitHub - 刀客传奇。感兴趣的可以看我写的前面几篇。
1. 动画示例(一) —— 一种外扩的简单动画
2. 动画示例(二) —— 一种抖动的简单动画
3. 动画示例(三) —— 仿头条一种LOTAnimation动画
4. 动画示例(四) —— QuartzCore之CAEmitterLayer下雪❄️动画
5. 动画示例(五) —— QuartzCore之CAEmitterLayer烟花动画
6. 动画示例(六) —— QuartzCore之CAEmitterLayer、CAReplicatorLayer和CAGradientLayer简单动画
7. 动画示例(七) —— 基于CAShapeLayer图像加载过程的简单动画(一)

开始

本文写作环境:Swift 4, iOS 11, Xcode 9

iOS支持视图控制器之间的自定义转换,在本文中,您将实现应用程序的UIViewController转场动画。

首先看一个效果。

不久前,发布了一款名为Ping的应用程序,该应用程序允许用户接收有关他们感兴趣的主题的通知。

除了不可预测的推荐之外,关于Ping的一个突出的事情是主屏幕和菜单之间的圆形转场过渡,如上面的动画所示。

当然,当你看到一些很酷的东西时,你想看看你是否能弄清楚它们是如何做到的。 在本文中,您将学习如何使用UIViewController transition animation在Swift中实现这个很酷的动画。 在此过程中,您将学习如何使用形状图层(shape layer),蒙版,UIViewControllerAnimatedTransitioning协议,UIPercentDrivenInteractiveTransition类等。

在Ping中,当您从一个视图控制器转到另一个视图控制器时,会发生UIViewController转场动画。

在iOS中,您可以通过将两个视图控制器放在UINavigationController中来实现视图控制器之间的自定义转场,并实现iOS的UIViewControllerAnimatedTransitioning协议来设置转换动画。

在开始之前要记住的一件事是,您可以使用您想要的任何方法实现这些动画,无论是pop,UIView,UIKit Dynamics还是较低级别的Core Animation API。

在本文中,您将专注于标准的UIView和Core Animation API。
既然您知道编码操作发生在哪里,那么就该考虑如何实际实现转场过渡。

仅仅看一下,对动画实现的一个很好的猜测如下:

  • 1)有一个圆圈来自右上角的按钮,它充当了出现的视图的视口。
  • 2)您要离开的视图控制器的文本会增长,并在屏幕左侧动画。
  • 3)您正在移动的视图控制器的文本从右侧开始增长和淡入,在扩展圈的可见空间内。

现在您已经模糊地了解了您的目标,现在是时候开始了。

下载下来工程,如果您Build并运行,您将看到一个已创建的应用程序,继续这样做,然后点击圆形按钮几次。

正如你所看到的,你手上有一个无聊的旧版默认push和pop动画,下面我们做的就是优化这个转场效果。


Navigation Controller Delegates - 导航VC代理

UINavigationController实例具有delegate属性,该属性可以是实现UINavigationControllerDelegate协议的任何对象。

此协议中的其他四种方法与对显示的视图控制器作出反应并指定支持哪些方向有关,但有两种方法允许您指定负责实现自定义转场的对象。

在您转场离开之前,您将需要创建一个可以为您的应用程序成为此委托的新类。

启动应用程序打开并选择Pong后,按⌘+ N开始添加新文件。 从选项中选择Cocoa Touch Class,然后单击Next。 将新类命名为TransitionCoordinator,并确保将其设置为Subclass:NSObjectLanguage:Swift。 点击Next,然后点击Create。

该类需要遵守UINavigationControllerDelegate协议。 将类定义行更改为:

class TransitionCoordinator: NSObject, UINavigationControllerDelegate {

到目前为止一切顺利,接下来你将实现你唯一关心的代理方法。 将以下方法添加到TransitionCoordinator:

func navigationController(_ navigationController: UINavigationController,
                          animationControllerFor operation: UINavigationControllerOperation,
                          from fromVC: UIViewController,
                          to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
  return nil
}

所有这个方法需要做的是查看它正在移动的视图控制器,以及它移动到的哪个视图控制器,并返回该对的适当动画对象。

目前,你只是返回nil,这是默认情况下一直在发生的事情。 当导航控制器要求动画控制器进行某个转场并收到nil时,它会使用您之前看到的push和pop转场。

你将回到这个类,稍微返回一个合适的动画控制器对象。

AppDelegate.swift中,在窗口属性声明的下方添加以下内容:

let transitionCoordinator = TransitionCoordinator()

这初始化了TransitionCoordinator并保留了对它的强引用。

现在找到您隐藏导航栏的行:

nav.isNavigationBarHidden = true

在此行之后,将TransitionCoordinator指定为导航控制器的代理,其中包含以下内容:

nav.delegate = transitionCoordinator

Build并运行以确认它运行。 因为代理正在返回一个nil动画,所以你不会看到任何新的事情发生。


The UIViewControllerAnimatedTransitioning Protocol - UIViewControllerAnimatedTransitioning协议

TransitionCoordinator将返回的animation object只是符合UIViewControllerAnimatedTransitioning的东西。

符合此协议的对象工作简单。他们只需要实现两种方法。第一种方法是返回转换所需的时间(以秒为单位)。第二个是一个方法,它接受一个上下文(context)对象,它具有实际执行动画所需的所有信息。

一个非常常见的模式是创建动画对象,并在pushing and popping之间的转换看起来不同时为其分配UINavigationControllerOperation参数。

在这种情况下,您实际上并不需要这样做;无论你是push还是pop,转场都是一样的,所以如果你通用的方法进行写,无论你方向如何,它都会起作用。

既然你知道自己需要什么,那就是写新类的时候了。再次按⌘+ N,再创建一个Cocoa Touch类,这次将其命名为CircularTransition

您需要对新类执行的第一件事是使其符合UIViewControllerAnimatedTransitioning协议。为此,只需在NSObject继承声明之后添加它,如下所示:

class CircularTransition: NSObject, UIViewControllerAnimatedTransitioning {

按照惯例,您会立即被告知您的类不符合此协议。下面就优先解决这个问题。

首先,添加指定动画持续时间的方法。

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

这是UIKit在导航控制器代理提供之后调用转场对象的第一种方法。 在这里,您设置了这种转场需要大约半秒才能完成。

接下来,添加您将在稍后返回的实际动画方法的空定义。

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
  //make some magic happen
  
}

在这里,您将收到一个转换上下文对象,该对象将包含编写动画代码所需的所有信息。

返回到TransitionCoordinator.swift并用更有用的东西替换当前的nilreturn语句:

return CircularTransition()

在这里,你告诉导航控制器你厌倦了它一直试图使用的那种无聊的push和pop转场,并且你有更好的想法。 在内部,UIKit将采用此UIViewControllerAnimatedTransitioning对象,并使用它来驱动此导航控制器从现在开始发生的所有转场的动画。

这对你来说非常棒,但请记住,还需要做很多工作。 所以回到CircularTransition.swift并为自己的实际工作做好准备吧!


The CircleTransitionable Protocol - CircleTransitionable协议

如果您曾经尝试过写其中一个转换,那么您可能已经发现编写内部视图控制器状态的代码非常容易。但是,您将准确定义视图控制器需要预先提供的内容,并允许任何希望以此方式设置动画的视图控制器提供对这些视图的获取和访问。

在类定义之前,在CircularTransition.swift的顶部添加以下协议定义:

protocol CircleTransitionable {
  var triggerButton: UIButton { get }
  var contentTextView: UITextView { get }
  var mainView: UIView { get }
}

此协议定义了每个视图控制器所需的信息,以便成功动画。

  • 1)triggerButton将是用户点击的按钮。
  • 2)contentTextView将是在屏幕上或屏幕外设置动画的文本视图。
  • 3)mainView将是在屏幕上或屏幕外动画的主视图。

接下来,转到ColoredViewController.swift并通过使用以下内容替换定义使其符合您的新协议。

class ColoredViewController: UIViewController, CircleTransitionable {

幸运的是,这个视图控制器已经定义了triggerButtoncontentTextView,所以它已经接近准备好了。 您需要做的最后一件事是为mainView属性添加一个计算属性。 在定义contentTextView之后立即添加以下内容:

var mainView: UIView {
  return view
}

在这里,您所要做的就是返回视图控制器的默认view属性。

该项目包含一个BlackViewControllerWhiteViewController,它在应用程序中显示两个视图。 两者都是ColoredViewController的子类,所以你正式设置两个类都可以转场。 恭喜!


Animating the Old Text Away - 动画移走旧的文本

接下来要真正的开始做一些动画🎉🎉🎉🎉了。

回到CircularTransition.swift,将guard语句添加到animateTransition(transitionContext:)

guard let fromVC = transitionContext.viewController(forKey: .from) as? CircleTransitionable,
  let toVC = transitionContext.viewController(forKey: .to) as? CircleTransitionable,
  let snapshot = fromVC.mainView.snapshotView(afterScreenUpdates: false) else {
    transitionContext.completeTransition(false)
    return
}

transitionContext允许您获取对正在转换的视图控制器的引用。 您将它们转换为CircleTransitionable,以便稍后可以访问它们的主视图和文本视图。

snapshotView(afterScreenUpdates :)返回fromVC的快照位图。

快照视图(Snapshot view)是一种非常有用的方法,可以快速获取动画视图的一次性副本。 您无法为各个子视图设置动画,但如果您只需要为完整的层次结构制作动画而不必在完成后将其放回原处,则快照是一种理想的解决方案。

在你的guardelse子句中,你在transitionContext上调用completeTransition()。 您传递false以告诉UIKit您没有完成转换,并且它不应该移动到下一个视图控制器。

guard之后,抓取对上下文提供的容器视图的引用。

let containerView = transitionContext.containerView

此视图就像是用于在到达最终目的地的路上添加和删除视图的暂存器。

当您完成动画制作后,您将在containerView中完成以下操作:

  • 1)从容器中删除了fromVC的视图。
  • 2)将toVC的视图添加到目的地,并配置应该显示的子视图。

animateTransition(transitionContext :)的底部添加以下内容:

containerView.addSubview(snapshot)

要在屏幕外设置旧文本的动画而不会弄乱实际文本视图的frame,您将为快照snapshot设置动画。

接下来,删除您要来的实际视图,因为您将不再需要它。

fromVC.mainView.removeFromSuperview()

最后,在animateTransition(transitionContext:)方法下面添加下面代码:

func animateOldTextOffscreen(fromView: UIView) {
  // 1
  UIView.animate(withDuration: 0.25, 
                 delay: 0.0, 
                 options: [.curveEaseIn], 
                 animations: {
    // 2
    fromView.center = CGPoint(x: fromView.center.x - 1300,
                              y: fromView.center.y + 1500)
    // 3
    fromView.transform = CGAffineTransform(scaleX: 5.0, y: 5.0)
  }, completion: nil)
}

这个方法非常简单:

  • 1)您可以定义一个动画,该动画将需要0.25秒才能完成并进入其动画曲线。
  • 2)您可以将视图的中心向下设置为动画,也可以设置在屏幕左侧。
  • 3)视图被扩大了5倍,因此文本似乎随着您稍后将要制作的圆圈一起增长。

这会导致文本同时增长和移出屏幕。 神奇的数字可能看起来有点随意,但它们来自于调试的结果。

将以下内容添加到animateTransition(transitionContext :)的底部:

animateOldTextOffscreen(fromView: snapshot)

你将snapshot传递给新方法并动画移出屏幕,Build并运行看一下效果。

好的,它仍然不是那么令人印象深刻,还需要完善和修改。

注意:CircularTransition.swift中仍有一个警告。 别担心,你很快就会解决它!


Fixing the Background - 修复背景

一个令人讨厌的事情是,由于整个视图都是动画,所以你会看到背后的黑色背景。

这个黑色背景是containerView,你真正想要的是它看起来只是文本动画,而不是整个背景。 要解决此问题,您需要添加一个不会设置动画的新背景视图。

CircularTransition.swift中,转到animateTransition(using:)。 获取对containerView的引用之后,在将snapshotView添加为子视图之前,请添加以下代码:

let backgroundView = UIView()
backgroundView.frame = toVC.mainView.frame
backgroundView.backgroundColor = fromVC.mainView.backgroundColor

在这里,您要创建backgroundView,将其frame设置为全屏,并将其背景颜色设置为与backgroundView的颜色相匹配。

然后,添加新背景作为containerView的子视图。

containerView.addSubview(backgroundView)

Build并查看效果

这个就好多了。


The Circular Mask Animation - 圆形遮罩动画

现在你已经完成了第一个块,接下来你需要做的是实际的圆形转场,新的视图控制器从按钮的位置开始动画。

首先将以下方法添加到CircularTransition

func animate(toView: UIView, fromTriggerButton triggerButton: UIButton) {
  
}

这将完成圆形转场 - 您很快就会实现它!

animateTransition(using:)中,在animateOldTextOffscreen(fromView:snapshot)之后添加以下内容:

containerView.addSubview(toVC.mainView)
animate(toView: toVC.mainView, fromTriggerButton: fromVC.triggerButton)

这会将您的最终视图添加到containerView中,并在您实现动画后对其进行动画处理!

现在你有了圆形转场的骨架。 然而,使这个动画工作的真正关键是理解方便的CAShapeLayer类以及layer masking的概念。

1. CAShapeLayer

CAShapeLayers是一个特殊的CALayer类,它不是总是呈现为正方形,而是可以通过首先定义贝塞尔曲线路径然后将该路径指定给图层的path属性来定义其形状。

在这种情况下,您将定义两个贝塞尔曲线路径并在它们之间设置动画。

将以下逻辑添加到先前添加的方法animate(toView:triggerButton :)

// 1
let rect = CGRect(x: triggerButton.frame.origin.x,
                  y: triggerButton.frame.origin.y,
                  width: triggerButton.frame.width,
                  height: triggerButton.frame.width)
// 2
let circleMaskPathInitial = UIBezierPath(ovalIn: rect)

这会创建一个bezier路径,从triggerButton的位置开始,在内容中定义一个小的圆形窗口。

你创建了一个:

  • 1)rect类似于按钮的frame,但宽度和高度相等。
  • 2)bezier路径椭圆形从rect,最后是圆形。

接下来,创建一个表示动画结束状态的圆圈。 由于您只能看到圆圈内的内容,因此您不希望在动画结束时仍能看到圆圈的任何边缘。 在刚刚添加的代码下面添加以下内容:

// 1
let fullHeight = toView.bounds.height
let extremePoint = CGPoint(x: triggerButton.center.x,
                           y: triggerButton.center.y - fullHeight)
// 2
let radius = sqrt((extremePoint.x*extremePoint.x) +
                  (extremePoint.y*extremePoint.y))
// 3
let circleMaskPathFinal = UIBezierPath(ovalIn: triggerButton.frame.insetBy(dx: -radius,
                                                                           dy: -radius))

这是这样做的:

  • 1)定义一个点,即整个屏幕高于屏幕顶部的高度。
  • 2)使用毕达哥拉斯定理计算新圆的半径:a²+b²=c²。
  • 3)通过获取圆的当前frame并在两个方向上“插入”负值来创建新的贝塞尔曲线路径,从而将其推出以完全超出屏幕在两个方向上的界限。

现在您已经设置了更好的路径,现在是时候将它们用于工作了。 仍然在animate(toView:triggerButton:)中,添加:

let maskLayer = CAShapeLayer()
maskLayer.path = circleMaskPathFinal.cgPath
toView.layer.mask = maskLayer

这将创建一个CAShapeLayer图层并将其路径设置为圆形贝塞尔曲线路径。 然后将maskLayer用作目标视图的遮罩。

但等一下,masks究竟是如何工作的?

2. CALayer Masking - CALayer遮罩

通常,alpha值为1的蒙版显示下面的图层内容,而alpha值为0则隐藏下面的内容。 中间的任何内容都会部分显示图层的内容。 这是一个解释这个的图表:

基本上,你可以想到你可以看到的任何形状都是被剪掉的形状,这样你就可以看到下面的东西。 其他一切最终都会被隐藏起来。 使用这些贝塞尔曲线路径时,圆圈内的像素的alpha值为1.0,而圆圈边界以外的部分则清晰,因此无法在这些点处看到蒙版视图。

现在你已经完成了所有这些设置,剩下要做的唯一事情就是在两个圆形蒙版之间实际制作动画。 棘手的是,到目前为止,你只完成了UIView动画,但那些不适用于CALayers。

3. Animations with Core Animation - 使用Core Animation进行动画

在这种情况下,您已经达到了UIView动画抽象无法再帮助您的程度,您需要降低一个级别。

这肯定迟早会发生,但不要担心,API非常简单。 这也很好理解,因为无论如何UIView动画真的只是引擎盖下的CATransactions

与UIView动画的基于闭包的API相比,Core Animation动画使用基于对象的方法。 这也是一个抽象,分解为引擎盖下的CATransaction,这对于你所做的任何与视图相关的事情实际上都是正确的。

仍然在animate(toView:triggerButton:),创建一个将执行动画的CABasicAnimation对象。

let maskLayerAnimation = CABasicAnimation(keyPath: "path")

在这里,您创建一个动画对象并告诉它将要设置动画的属性是path属性。 这意味着您将为渲染的形状设置动画。

接下来,设置此动画的fromto值。

maskLayerAnimation.fromValue = circleMaskPathInitial.cgPath
maskLayerAnimation.toValue = circleMaskPathFinal.cgPath

在这里,您使用先前创建的两个贝塞尔曲线路径来定义图层应在其间进行动画处理的两个状态。

配置动画最后要做的就是告诉对象运行多长时间。 添加以下行来执行此操作:

maskLayerAnimation.duration = 0.15

在这种情况下,动画将运行0.15秒。

CAAnimations不使用像UIView动画这样的完成块,而是使用带回调的委托来表示完成。 虽然您在技术上不需要这个代理,但您将实现委托以更好地理解它。

首先添加以下行:

maskLayerAnimation.delegate = self

此类现在是动画对象的委托。

转到文件的底部并添加此类扩展以遵循CAAnimationDelegate协议。

extension CircularTransition: CAAnimationDelegate {
  func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {

  }
}

完成此动画后,您可以正式调用整个动画。 在此回调中,您希望在动画开头接收的上下文context对象上调用completeTransition()

不幸的是,这突出了一个令人讨厌的事情,即必须使用此委托回调。 要访问上下文对象,您必须在主动画方法的开头保存对它的引用。

首先,转到CircularTransition的顶部并添加:

weak var context: UIViewControllerContextTransitioning?

然后,转到animateTransition(transitionContext :)中的guard语句之后的行,并保存传入的上下文以供日后使用。

context = transitionContext

现在,回到扩展中的animationDidStop(anim:finished :)并添加以下行:

context?.completeTransition(true)

您现在在动画成功完成时通知系统。

现在您已经设置了动画对象,只需将其添加到maskLayer即可。 在animate(toView:triggerButton:)后面加上下面代码。

maskLayer.add(maskLayerAnimation, forKey: "path")

您需要再次指定要为maskLayerpath设置动画。 将动画添加到图层后,它将自动启动。

Build并运行以查看几乎完全完成的转换!


The Finishing Touches - 结束点击

为了完整起见,您将为转场添加一个小动画。 您还可以从右侧获得目标视图的文本淡入,而不是仅显示目标视图控制器的圆圈。

与上一个动画相比,这一个是轻而易举的。 转到CircularTransition类定义的底部并添加以下方法:

func animateToTextView(toTextView: UIView, fromTriggerButton: UIButton) {
  
}

在新方法中添加如下代码:

let originalCenter = toTextView.center
toTextView.alpha = 0.0
toTextView.center = fromTriggerButton.center
toTextView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)

在这里,您要设置toTextView的起始状态。 您将其alpha设置为0,使用触发按钮居中,并将其缩放到正常大小的1/10

接下来,添加以下UIView动画。

UIView.animate(withDuration: 0.25, delay: 0.1, options: [.curveEaseOut], animations: {
  toTextView.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
  toTextView.center = originalCenter
  toTextView.alpha = 1.0
}, completion: nil)

在这里,您只需撤消您刚才所做的一切,即将文本视图设置回中心,并将其快速淡入淡出。

最后,将以下调用添加到animateTransition(transitionContext :)的底部。

animateToTextView(toTextView: toVC.contentTextView, fromTriggerButton: fromVC.triggerButton)

您将使用toVC文本视图和fromVC按钮提供animateToTextView。 现在,它将完成文本视图动画以及其他转场动画。

最后一次Build并运行,以获得与原始Ping应用程序非常相似的转场效果!


源码

1. TransitionCoordinator.swift
import UIKit

class TransitionCoordinator: NSObject, UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController,
                              animationControllerFor operation: UINavigationControllerOperation,
                              from fromVC: UIViewController,
                              to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CircularTransition()
    }

}
2. CircularTransition.swift
import UIKit

protocol CircleTransitionable {
  var triggerButton: UIButton { get }
  var contentTextView: UITextView { get }
  var mainView: UIView { get }
}

class CircularTransition: NSObject, UIViewControllerAnimatedTransitioning {
  weak var context: UIViewControllerContextTransitioning?

  //make this zero for now and see if it matters when it comes time to make it interactive
  func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return 0.0
  }
  func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    guard let fromVC = transitionContext.viewController(forKey: .from) as? CircleTransitionable,
      let toVC = transitionContext.viewController(forKey: .to) as? CircleTransitionable,
      let snapshot = fromVC.mainView.snapshotView(afterScreenUpdates: false) else {
        transitionContext.completeTransition(false)
        return
    }
    
    context = transitionContext

    let containerView = transitionContext.containerView
    
    //Background View With Correct Color
    let backgroundView = UIView()
    backgroundView.frame = toVC.mainView.frame
    backgroundView.backgroundColor = fromVC.mainView.backgroundColor
    containerView.addSubview(backgroundView)
    
    //Animate old view offscreen
    containerView.addSubview(snapshot)
    fromVC.mainView.removeFromSuperview()
    animateOldTextOffscreen(fromView: snapshot)
    
    //Growing Circular Mask
    containerView.addSubview(toVC.mainView)
    animate(toView: toVC.mainView, fromTriggerButton: fromVC.triggerButton)
    
    //Animate Text in with a Fade
    animateToTextView(toTextView: toVC.contentTextView, fromTriggerButton: fromVC.triggerButton)
  }
  
  func animateOldTextOffscreen(fromView: UIView) {
    UIView.animate(withDuration: 0.25, delay: 0.0, options: [.curveEaseIn], animations: {
      fromView.center = CGPoint(x: fromView.center.x - 1000, y: fromView.center.y + 1500)
      fromView.transform = CGAffineTransform(scaleX: 5.0, y: 5.0)
    }, completion: nil)
  }
  func animate(toView: UIView, fromTriggerButton triggerButton: UIButton) {
    //Starting Path
    let rect = CGRect(x: triggerButton.frame.origin.x,
                      y: triggerButton.frame.origin.y,
                      width: triggerButton.frame.width,
                      height: triggerButton.frame.width)
    let circleMaskPathInitial = UIBezierPath(ovalIn: rect)
    
    //Destination Path
    let fullHeight = toView.bounds.height
    let extremePoint = CGPoint(x: triggerButton.center.x,
                               y: triggerButton.center.y - fullHeight)
    let radius = sqrt((extremePoint.x*extremePoint.x) +
      (extremePoint.y*extremePoint.y))
    let circleMaskPathFinal = UIBezierPath(ovalIn: triggerButton.frame.insetBy(dx: -radius,
                                                                               dy: -radius))
    
    //Actual mask layer
    let maskLayer = CAShapeLayer()
    maskLayer.path = circleMaskPathFinal.cgPath
    toView.layer.mask = maskLayer
    
    //Mask Animation
    let maskLayerAnimation = CABasicAnimation(keyPath: "path")
    maskLayerAnimation.fromValue = circleMaskPathInitial.cgPath
    maskLayerAnimation.toValue = circleMaskPathFinal.cgPath
    maskLayerAnimation.delegate = self
    maskLayer.add(maskLayerAnimation, forKey: "path")
  }
  
  func animateToTextView(toTextView: UIView, fromTriggerButton: UIButton) {
    //Start toView offscreen a little and animate it to normal
    let originalCenter = toTextView.center
    toTextView.alpha = 0.0
    toTextView.center = fromTriggerButton.center
    toTextView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)
    
    UIView.animate(withDuration: 0.25, delay: 0.1, options: [.curveEaseOut], animations: {
      toTextView.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
      toTextView.center = originalCenter
      toTextView.alpha = 1.0
    }, completion: nil)
  }
}

extension CircularTransition: CAAnimationDelegate {
  func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
    context?.completeTransition(true)
  }
}
3. AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  
  var window: UIWindow?
  let transitionCoordinator = TransitionCoordinator()
  
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    let whiteVC = WhiteViewController()
    
    window = UIWindow(frame: UIScreen.main.bounds)
    let nav = UINavigationController(rootViewController: whiteVC)
    nav.isNavigationBarHidden = true

    //Add TransitionCoordinator as navigation controller's delegate
    nav.delegate = transitionCoordinator
    
    window?.rootViewController = nav
    window?.makeKeyAndVisible()
    
    return true
  }
}
4. ColoredViewController.swift
import UIKit

enum Color {
  case white
  case black
}

class ColoredViewController: UIViewController, CircleTransitionable {    
  var mainView: UIView {
    return view
  }

  let triggerButton = UIButton()
  let contentTextView = UITextView()

  let color: Color
  
  init(color: Color) {
    self.color = color
    
    super.init(nibName: nil, bundle: nil)
  }
  override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = color(for: color)
    contentTextView.isUserInteractionEnabled = false
    
    triggerButton.addTarget(self, action: #selector(buttonWasTapped), for: .touchUpInside)
    
    self.view.addSubview(contentTextView)
    self.view.addSubview(triggerButton)
  }
  
  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    view.setNeedsLayout()
  }
  
  func color(for color: Color) -> UIColor {
    switch color {
    case .white:
      return .white
    case .black:
      return .black
    }
  }
  
  @objc func buttonWasTapped() {
    assertionFailure("This method should be implemented in subclasses")
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}
5. WhiteViewController.swift
import UIKit

class WhiteViewController: ColoredViewController {
  init() {
    super.init(color: .white)
  }
  override func buttonWasTapped() {
    let vc = BlackViewController()
    navigationController?.pushViewController(vc, animated: true)
  }
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}
6. WhiteViewControllerLayout.swift
import UIKit

extension WhiteViewController {
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    triggerButton.backgroundColor = .black
    contentTextView.backgroundColor = .clear
    
    let titleAttributes = [NSAttributedStringKey.foregroundColor: UIColor.black,
                           NSAttributedStringKey.font: UIFont.boldSystemFont(ofSize: 18.0)]
    let storyAttributes = [NSAttributedStringKey.foregroundColor: UIColor.lightGray,
                           NSAttributedStringKey.font: UIFont.systemFont(ofSize: 16.0)]
    
    let mutableAttrString = NSMutableAttributedString(string: "Beep boop, you've unlocked a new\n something or other that I call \"Tech Talk\"\n\n", attributes:titleAttributes)
    mutableAttrString.append(NSAttributedString(string: "Hi, I'm Pong. Tap the black dot,\n choose stuff you care about and close\n me when you're done. You're going to\n like me.\n\nTouch me.", attributes:storyAttributes))
    
    contentTextView.attributedText = mutableAttrString
  }
  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    
    let buttonWidthHeight: CGFloat = 25.0
    let padding: CGFloat = 16.0
    let statusBarPadding: CGFloat = 20.0
    
    let constrainedSize = CGSize(width: view.bounds.width - 32.0 - buttonWidthHeight, height: view.bounds.height)
    let titleSize = contentTextView.sizeThatFits(constrainedSize)
    
    contentTextView.bounds = CGRect(x: 0, y: 0, width: titleSize.width, height: titleSize.height)
    
    contentTextView.center = CGPoint(x: 16 + contentTextView.bounds.width/2.0, y: 60 + contentTextView.bounds.height/2.0)
    
    triggerButton.layer.cornerRadius = buttonWidthHeight/2.0
    triggerButton.frame = CGRect(x: view.bounds.width - buttonWidthHeight - padding, y: padding + statusBarPadding, width: buttonWidthHeight, height: buttonWidthHeight)
  }
}
7. BlackViewController.swift
import UIKit

class BlackViewController: ColoredViewController {
  init() {
    super.init(color: .black)
  }
  override func buttonWasTapped() {
    navigationController?.popViewController(animated: true)
  }
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}
8. BlackViewControllerLayout.swift
import UIKit

extension BlackViewController {
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    triggerButton.backgroundColor = .white
    contentTextView.backgroundColor = .clear
    
    let titleAttributes = [NSAttributedStringKey.foregroundColor: UIColor.white,
                           NSAttributedStringKey.font: UIFont.systemFont(ofSize: 18.0)]
    
    let string = """
                        Apps worth downloading\n\n
                        Best of Hacker News\n\n
                        Curiosities\n\n
                        Daily Fortune Cookies\n\n
                        Fitspiration\n\n
                        Is it Friday yet?\n\n
                        Movies worth mocking\n\n
                    """
    let mutableAttrString = NSMutableAttributedString(string: string, attributes:titleAttributes)
    
    contentTextView.attributedText = mutableAttrString
  }
  
  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    
    triggerButton.backgroundColor = .white
    contentTextView.backgroundColor = .black
    
    let buttonWidthHeight: CGFloat = 25.0
    let padding: CGFloat = 16.0
    let statusBarPadding: CGFloat = 20.0
    
    let constrainedSize = CGSize(width: view.bounds.width - 32.0 - buttonWidthHeight, height: view.bounds.height)
    let titleSize = contentTextView.sizeThatFits(constrainedSize)
    
    contentTextView.bounds = CGRect(x: 0, y: 0, width: titleSize.width, height: titleSize.height)
    contentTextView.center = CGPoint(x: 16 + contentTextView.bounds.width/2.0, y: 60 + contentTextView.bounds.height/2.0)
    
    triggerButton.layer.cornerRadius = buttonWidthHeight/2.0
    triggerButton.frame = CGRect(x: view.bounds.width - buttonWidthHeight - padding, y: padding + statusBarPadding, width: buttonWidthHeight, height: buttonWidthHeight)
  }
  
  override var preferredStatusBarStyle: UIStatusBarStyle {
    return .lightContent
  }
}

后记

本篇主要讲述了UIViewController间转场动画的实现,感兴趣的给个赞或者关注~~~

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

推荐阅读更多精彩内容