简介:面向iOS开发者的自定义转场动画示例工程,提供完整的Xcode项目与Objective-C源码,帮助突破系统默认页面切换效果的限制,实现个性化转场体验。压缩包共24个文件,包含8个.m源文件、5个.h头文件,以及4个plist配置文件、storyboard界面布局、Xcode工程描述和测试相关文件,整体大小仅51KB,结构紧凑,便于在本地直接运行并逐模块分析。已有493人学习下载,适合中高级开发者作为进阶练习。内容围绕核心转场协议展开,涵盖UIViewControllerAnimatedTransitioning动画逻辑与时长控制、UIViewControllerTransitioningDelegate代理设置、导航控制器下的自定义动画,以及基于UIPercentDrivenInteractiveTransition的手势交互式转场;工程内附有动画管理类与控制器实例,可对照源码理解动画生命周期和容器控制器协作方式,快速迁移到实际项目中。
1. 自定义转场动画:为什么系统自带的转场样式满足不了真实需求
先抛个结论:系统转场并不差,差的是它对页面关系的表达。当你的 App 想让列表项看起来“展开”成详情页,或者让页面跟随手指滑动消失时,系统固定的平移和缩放样式会打断用户对信息层级的感知。自定义转场动画的价值就在这里——把页面的来去变成产品逻辑的一部分。不过它也确实有门槛:动画器、转场上下文、交互控制器、代理方法,每一环都要接上,否则就会碰到动画卡住、手势失效这种黑匣子问题。这篇笔记是围绕这条链路来拆的,适合已经能独立写页面、想优化 App 交互细节的开发者。
2. 转场动画的地基:UIViewControllerAnimatedTransitioning 与转场上下文
2.1 转场上下文里,哪些 view 是你必须手动管理的
自定义转场的核心协议是UIViewControllerAnimatedTransitioning。它只有两个必须实现的方法:返回动画时长,以及执行动画。真正复杂的是第一个参数transitionContext,它像一个临时容器,装载了这次转场需要的全部信息:两个视图控制器、两个 view、一个容器视图,以及转场是否已取消的状态。
很多初写转场的人在拿到transitionContext后,会直接读fromVC.view和toVC.view来干活。但官方推荐的姿势是用view(forKey:)方法:transitionContext.view(forKey: .from)和transitionContext.view(forKey: .to)。这两个 view 不简单等于控制器的 view,因为系统在部分转场形态下会做一些包装,比如 modal 弹窗的 presentingView 在 presentation 结束后就不在容器里了。如果只盯着控制器 view,动画很容易出现黑屏或者 view 消失。
另一个容易忽略的点是containerView。系统会把所有转场相关的视图都放在这个临时容器里,你的动画子视图也应该加在它上面。不要试图把fromView从父视图里移除,那是系统的事。你只负责改frame、透明度、transform,系统负责在completeTransition后统一收拾。
2.2 你的第一个自定义转场:淡入淡出的最小实现
先写一个最朴素的动画器,它只做一件事:新页面淡入,旧页面淡出。
final class FadeTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { // 转场动画总时长,单位秒 func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.35 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { // 容器视图:所有参与转场的 view 都要加到这里 let container = transitionContext.containerView // 从上下文按 key 取 view,不要直接取 controller.view guard let fromView = transitionContext.view(forKey: .from), let toView = transitionContext.view(forKey: .to) else { // 取不到视图是异常情况,直接取消转场,避免界面卡死 transitionContext.completeTransition(false) return } // 新视图先完全透明,然后加进容器 toView.alpha = 0 toView.frame = container.bounds container.addSubview(toView) // 动画结束时,fromView 会自动淡出,toView 淡入 UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: { toView.alpha = 1 fromView.alpha = 0 }, completion: { finished in // 必须调用 completeTransition,系统才会结束转场流程 transitionContext.completeTransition(!transitionContext.transitionWasCancelled) }) } }这个实现里有三个关键参数。第一个是transitionDuration(using:),它返回的时长会同时用于系统内嵌手势和动画调度,最好不要在里面写死随机值。第二个是container.bounds,每次转场开始前,toView.frame必须等于容器边界,不设的情况下很多转场会从默认的(0,0,0,0)开始,画面直接缩成一个点。第三个是completeTransition,它必须出现在动画闭包的完成回调里,而且最好只调用一次。系统靠它去清理容器、恢复视图层级、刷新控制器的状态。
2.3 注册到导航控制器:操作方向与代理优先级
有了动画器还不够,系统不会自动发现它。你需要让导航控制器或 modal 的代理把它交出去。先看导航控制器的注册方式。
final class NavigationTransitionDelegate: NSObject, UINavigationControllerDelegate { // 返回具体动画器 func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { return FadeTransitionAnimator() } }然后在控制器的viewDidLoad里设置代理,并保证代理被强引用。常见错误是直接在viewDidLoad里写navigationController?.delegate = self,但self只是一个临时变量,转场结束后代理被释放,下一次转场又变回系统默认了。我一般会用一个属性来持有:
final class SomeViewController: UIViewController { private let transitionDelegate = NavigationTransitionDelegate() override func viewDidLoad() { super.viewDidLoad() navigationController?.delegate = transitionDelegate } }注意代理方法里有一个operation参数,它区分 push 和 pop。在系统默认样式中,push 动画和 pop 动画是镜像关系,所以你的动画器里最好也区分方向。比如淡入淡出可以不用管方向,但如果是滑动转场,push 时toView从右边进来,pop 时fromView应该从右边退出去,逻辑正好相反。一个粗暴但好用的方式是给动画器加一个isPush布尔值,在代理方法里根据 operation 设置进去。
3. 交互式转场:让手势接管动画进度,这才是“自定义”的完全体
3.1 交互式转场的工作原理
静态转场只能通过点击按钮触发,体验再好也只是一段播放完就结束的动画。交互式转场则把动画进度交给用户手势,最常见的场景就是阅读类 App 里边缘右滑返回,页面跟随手指左右移动。实现交互式转场需要额外引入一个交互控制器,苹果已经帮你把进度管理封装好了,就是UIPercentDrivenInteractiveTransition。
它的内部机制并不复杂:系统在转场开始时会调用交互控制器的startInteractiveTransition,此后你每一次调用update(_:)方法,系统就把动画时间线推进到对应的百分比位置。比如update(0.5)表示动画走了一半,finish()表示继续执行完剩余动画,cancel()则退回起点。要注意的是,交互控制器必须和一个已经注册好的UIViewControllerAnimatedTransitioning动画器配合使用。系统先通过代理拿到动画器,再通过另一个代理方法询问是否有交互控制器。如果有,就把动画器塞给交互控制器,让它的进度控制接管动画。
新手经常把交互控制器写成独立逻辑,这会碰壁。交互控制器和动画器是绑定关系:动画器负责“每一帧长什么样”,交互控制器负责“现在播到哪一秒”。
3.2 用 UIPercentDrivenInteractiveTransition 实现边缘滑动返回
下面是一个可运行的边缘滑动返回例子。它由一个交互控制器和一个导航控制器代理组成。交互控制器继承自UIPercentDrivenInteractiveTransition,在手势开始时手动触发 pop 操作。
final class InteractivePopController: UIPercentDrivenInteractiveTransition { // 当前是否处于手势驱动状态,导航代理会通过它决定是否返回交互控制器 var isInteractive = false // 处理边缘滑动手势 @objc func handleEdgePan(_ gesture: UIScreenEdgePanGestureRecognizer) { guard let view = gesture.view else { return } // 根据横向位移计算进度,0 ~ 1 let translationX = gesture.translation(in: view).x let progress = min(max(translationX / view.bounds.width, 0), 1) switch gesture.state { case .began: isInteractive = true // 这里必须用导航控制器的 pop 方法触发转场 // 代理方法会立刻被调用,并返回这个交互控制器 viewController.navigationController?.popViewController(animated: true) case .changed: // 进度更新,系统会把动画推进到对应位置 update(progress) case .ended, .cancelled: isInteractive = false let velocityX = gesture.velocity(in: view).x // 同时判断进度和速度:进度过半或者速度快就完成 if progress > 0.3 || velocityX > 800 { finish() } else { cancel() } default: isInteractive = false cancel() } } }这里的viewController是一个弱引用属性,用来触发 pop。手势识别器需要加到当前控制器的 view 上,并指定边缘为.left。我在项目里通常是在viewDidLoad里创建手势并 add,然后把导航控制器的代理设置成持有它的对象。
接下来在导航代理中补上交互控制器对应的返回方法。
final class InteractiveNavigationDelegate: NSObject, UINavigationControllerDelegate { let popController = InteractivePopController() func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { return FadeTransitionAnimator() } func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { // 关键:只有手势触发时才返回交互控制器,否则整个转场会失去进度控制 return popController.isInteractive ? popController : nil } }注意interactionControllerFor的返回值设计。如果isInteractive为 false,系统会直接把动画从头播到尾,用户没有手势介入。如果为 true,动画器返回的同时系统会立即把动画挂到popController上,供手势驱动。所以手势began时设置isInteractive的顺序必须在调用popViewController之前,不然代理拿到的状态还是旧值。
这套实现的表现在真机上,页面会紧密跟随手指。手势速度的判断也比较关键:有些用户滑到一半突然快速往回扫,此时进度虽然低,但速度较高,应该直接取消回弹。我把速度阈值设在 800,真机测试时这个值适配大多数手速,你可以根据自己的产品调整。
4. 实战落地:导航 push/pop 与全屏模态中的自定义转场方案
4.1 导航控制器场景:接管系统返回手势而不丢失滑动返回
自定义导航转场有一个容易踩的隐雷:当你设置navigationController?.delegate后,系统自带的边缘右滑手势在部分情况下会失灵。原因是自定义动画器接管了转场后,系统不再自动提供交互控制器。所以要想保留滑动返回,必须自己接一个手势,并像第 3 章那样设置交互控制器。我习惯的做法是把手势和交互控制器封装成一个可复用组件,而不是在每个控制器里重复写。
final class EdgePanPopGestureHandler: NSObject, UIGestureRecognizerDelegate { private weak var navigationController: UINavigationController? private let interactiveController = InteractivePopController() init(navigationController: UINavigationController) { self.navigationController = navigationController super.init() } func attach(to view: UIView) { let edgePan = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePan(_:))) edgePan.edges = .left edgePan.delegate = self view.addGestureRecognizer(edgePan) } @objc private func handlePan(_ gesture: UIScreenEdgePanGestureRecognizer) { interactiveController.handleEdgePan(gesture) } // 防止系统手势和自己加的手势冲突导致转场被中断 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return gestureRecognizer is UIScreenEdgePanGestureRecognizer } }这个组件的用法很简单:在导航控制器的根控制器里创建它并 attach 到 view 上。注意UIScreenEdgePanGestureRecognizer的误触概率远比普通 pan 小,因为只有边缘区域会触发。手势代理方法里我返回了与其他手势同时识别,这样页面内部滚动时,边缘区域的轻微滑动不至于被吞掉。如果你发现自定义转场后右滑手势没反应,百分之八九十是导航代理里没有返回交互控制器,或者手势的 edge 设错了方向。
4.2 模态场景:从底部滑出弹窗,再让 dismiss 动画完美对称
模态转场的坑比导航转场更隐蔽。系统模态默认在 presentation 完成后会把 presenting view 从容器中移除,导致 dismiss 动画的toView变成 nil。解决办法是设置modalPresentationStyle = .custom,这样系统不会移走 presentingView,你就能在两段动画里拿到正确的视图。
先写一个支持上下滑动的模态动画器。
final class ModalSlideAnimator: NSObject, UIViewControllerAnimatedTransitioning { var isPresenting = true func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.4 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { let container = transitionContext.containerView if isPresenting { guard let toView = transitionContext.view(forKey: .to) else { transitionContext.completeTransition(false) return } // 从底部往下偏移一个屏幕高度 toView.frame = container.bounds toView.transform = CGAffineTransform(translationX: 0, y: container.bounds.height) container.addSubview(toView) UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: { toView.transform = .identity }, completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) }) } else { guard let fromView = transitionContext.view(forKey: .from) else { transitionContext.completeTransition(false) return } // dismiss 时把当前弹层滑出底部 UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: { fromView.transform = CGAffineTransform(translationX: 0, y: container.bounds.height) }, completion: { _ in fromView.removeFromSuperview() transitionContext.completeTransition(!transitionContext.transitionWasCancelled) }) } } }这个动画器需要通过UIViewControllerTransitioningDelegate注册到目标控制器上。通常做法是创建一个代理类,里面分别返回 presentation 和 dismissal 的动画器。
final class ModalTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate { func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { let animator = ModalSlideAnimator() animator.isPresenting = true return animator } func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { let animator = ModalSlideAnimator() animator.isPresenting = false return animator } }设置弹窗时,必须把代理强引用了再赋值:
let secondVC = SecondViewController() secondVC.modalPresentationStyle = .custom secondVC.transitioningDelegate = transitionDelegate present(secondVC, animated: true)注意.custom模式下,系统不会自动调整 presented view 的 frame,所以动画器里必须要手动设置container.bounds。我在封装这个动画器时,把isPresenting设计成内部属性,而不是通过fromVC和toVC去推断,因为推断逻辑在 modal 场景里很容易误判。
4.3 用 UIPresentationController 做卡片弹层
如果不想把一个全屏 modal 做成滑入滑出,而是想做卡片式弹窗,就需要再引入UIPresentationController。它负责 presentation 期间的面层布局和背景遮罩。自定义转场动画器负责两段过渡动画,而 presentation controller 负责弹层出现后的整体层次。
一个典型做法是在presentationController(forPresented:presenting:source:)里返回自定义 presentation controller,在其中重写frameOfPresentedViewInContainerView,设置弹层的尺寸,再添加一个半透明遮罩层。转场动画器则只对弹层做缩放淡入,遮罩跟随淡入淡出。这个组合能实现类似地图 App 底部卡片上滑的效果。如果项目里有抽屉式弹窗需求,我建议直接走这套方案,而不是在动画器里硬塞 frame 计算,否则在旋转和尺寸变化时很容易出现层级错乱。
5. 避坑与排查:自定义转场最容易翻车的 5 个细节
5.1 动画结束后界面卡死:completeTransition 的调用时机
现象:动画播放完,页面就冻结了,点击事件没有任何响应,有时候整个窗口变成半透明。
原因:completeTransition没有在正确时机被调用,或者没有在完成回调里调用。系统在等待转场结束信号时,会暂停用户交互和处理视图层级。这是一个很常见的黑匣子问题,因为系统不会报错,只会表现成界面死掉。
解决:确认每一段业务动画都在 completion 闭包里调用了completeTransition,并且只调用一次。如果动画使用了UIView.animate且中间可能有 break,建议加一个布尔标志,保证completeTransition只走一次。
5.2 模态 dismiss 动画黑屏:presentation 与 dismissal 不对称
现象:presentation 动画正常,dismiss 的动画执行时,背景一片黑或者直接瞬间消失。
原因:dismiss 时transitionContext.view(forKey: .to)返回 nil。这是因为modalPresentationStyle使用默认样式,系统在 presentation 结束后把 presentingView 移出了容器。dismiss 发生时,容器里只有 fromView,没有 toView。
解决:把modalPresentationStyle设为.custom,并保证 presentation 和 dismissal 动画器里都使用containerView来设置视图层级。如果想保留 presentingView,还可以考虑用UIPresentationController管理它。
5.3 自定义转场后系统右滑返回手势失效
现象:设置导航代理后,从详情页边缘右滑,页面不再跟着手势走,或者直接返回但没有动画。
原因:自定义转场动画器打断了系统默认的转场流程,而系统手势本身是依赖默认动画器完成的。代理中只实现了animationControllerFor,却没有返回interactionControllerFor,导致系统找不到交互控制器,手势只触发了一个静默的转场。
解决:在导航代理里实现interactionControllerFor,当手势触发时返回UIPercentDrivenInteractiveTransition的子类。同时检查手势添加的视图,确保是当前控制器的根 view,边缘方向为.all或.left,具体取决于你的产品规则。
5.4 toView 的 frame 与动画位置错乱
现象:动画开始时页面从顶部偏下或中间偏左的位置冒出来,而不是从设定位置出现。
原因:没有在动画前设置toView.frame = container.bounds。部分容器尺寸不等于屏幕尺寸,比如横屏、分屏或弹层场景,直接沿用 controller.view.frame 会错位。
解决:在把 toView 添加到容器后,立刻更新它的 frame。除非做特定滑入效果,否则不要依赖原始控制器 view 的 frame。需要做位移时,优先使用 transform,而不是改 frame,因为 transform 不会影响自动布局和系统转场时的坐标计算。
5.5 内存持续增长:交互控制器强持有导致泄漏
现象:反复 push/pop 多次后,内存曲线一直上升,返回的控制器没有释放。
原因:交互控制器在手势结束后仍然持有transitionContext,而transitionContext又引用了控制器视图。如果你把InteractivePopController创建成属性并长期持有,但没有在转场结束后清空内部状态,它就会成为一条强引用链。
解决:重写UIPercentDrivenInteractiveTransition的方法,在startInteractiveTransition里保留transitionContext,并在finish或cancel之后释放它。另外,交互控制器中对控制器的引用用弱引用。我在实际开发中会额外加一个deinit打印,方便排查这类剪辑问题。
6. 进阶玩法:弹簧动画、遮罩扩散转场与性能监控
6.1 弹簧动画:让转场体感更弹
把标准UIView.animate换成弹簧参数,转场立刻会有一种“吸附感”。常见做法是在动画器里使用usingSpringWithDamping和initialSpringVelocity。阻尼参数越小弹性越明显,0.7 左右比较适合页面转场;速度参数控制初始冲量,如果直接用手势驱动,速度值可以取手势速度与屏幕宽度的比值。
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.3, options: [.curveEaseOut], animations: { toView.frame = container.bounds }, completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) })弹簧动画放在交互转场里要注意:手势驱动时,系统会按进度时间线推进,而不是逐帧播放弹簧效果,所以弹簧参数只会在finish()或cancel()之后的收尾阶段体现。想要手势拖动过程中也有弹簧弹性,需要重写UIViewPropertyAnimator,但那是另一套复杂方案,普通场景用系统类就够了。
6.2 遮罩扩散转场:从一个点展开的视觉技巧
遮罩扩散转场适合做列表到详情的连续感。原理是把新视图用一个圆形 mask 包起来,初始 mask 半径很小,慢慢扩大到覆盖整个屏幕。代码里可以用CAShapeLayer配合UIBezierPath实现。
let maskLayer = CAShapeLayer() let startRadius: CGFloat = 10 let endRadius = max(view.bounds.width, view.bounds.height) * 1.2 let startPath = UIBezierPath(ovalIn: CGRect(x: origin.x - startRadius, y: origin.y - startRadius, width: startRadius * 2, height: startRadius * 2)) let endPath = UIBezierPath(ovalIn: CGRect(x: origin.x - endRadius, y: origin.y - endRadius, width: endRadius * 2, height: endRadius * 2))动画时给 maskLayer 的path做隐式动画或显式动画,从一个圆放大到满足屏幕的圆。这个技巧有两个和转场容器相关的坑:maskLayer 的 frame 要同步容器 bounds 变化,动画结束后要把 maskLayer 从 view 上移除,否则转场回收视图时会留下一个不透明的圆形裁剪区。
6.3 性能监控:转场期间的 layout 耗时与离屏渲染检查
自定义转场最容易在低端真机上掉帧,原因是动画里触发大量布局计算。我每次写完一个转场,都会在动画期间打开 Instruments 的 Core Animation 调试,查看 Commit 阶段的耗时。如果单帧提交超过 16ms,优先检查 toView 里有没有频繁调用layoutIfNeeded或设置圆角阴影。阴影和圆角会在动画期间触发离屏渲染,是转场掉帧的第一元凶。
一个稳妥的做法是动画期间关闭阴影,在转场结束前单独截一张不带阴影的图层提交。针对列表页展开详情页这种复杂转场,可以把详情页的高成本 UI 构建放到转场开始前预加载,但避免一次性加载太多图片,否则内存峰值会直接让系统杀掉 App。我一般会在转场结束后延迟一点做剩余的图片解码和布局。
从第一次写出能跑的淡入淡出,到后面把手势和动画全部串起来,我养成了一个习惯:每做一个转场,都会在真机上用慢速动画和快速手势各跑一遍,看页面在取消、完成、被打断三种状态下是否正确回退。这个习惯帮我挡掉了不少投诉,小到动画卡顿,大到转场中途白屏,几乎都能在这一步暴露出来。自定义转场动画说到底不是拼写协议的速度,而是拼对状态流转的掌控。希望这篇拆解能帮你把那些看不见的转场逻辑理顺。
本文还有配套的精品资源,点击获取