
简介面向iOS开发者的自定义转场动画工程资源适合已掌握UIKit基础、希望提升页面切换体验的中级开发者。压缩包内为完整Xcode示例项目共24个文件以Objective-C源文件8个.m、5个.h为主辅以project.pbxproj、Info.plist等工程配置以及storyboard和测试文件整体仅51KB结构紧凑可直接运行与拆解已有493人学习下载。工程通过ZFJ_Animation、ZFJ_ViewController等类完整演示了UIViewControllerAnimatedTransitioning与UIViewControllerTransitioningDelegate协议的落地涵盖导航栏转场、容器控制器协调、动画时长控制等关键环节同时涉及交互式转场中UIPercentDrivenInteractiveTransition的用法并利用transitionCoordinator实现动画与系统UI的联动。开发者可对照代码理解CAAnimation与UIView Animation的适用差异参考UIStoryboardSegue的perform重写方式将自定义动画接入Storyboard再结合自身场景修改复用快速获得稳定、流畅且个性化的过渡效果。工程源文件彼此独立、命名清晰便于逐行调试和二次开发可在此基础上扩展弹出菜单、卡片翻转、缩放推入等效果。1. 转场动画为什么用 UIView.animate 模拟不出系统 pushiOS 转场动画有一个很反直觉的现象你用UIView.animate去模拟系统 push——把 toView 从右侧滑进来——动画还没结束fromView 就凭空消失了。这不是你写错了动画而是 UIKit 在转场开始前就把 fromView 从视图层级里摘掉了。换句话说系统转场根本不是「两个 view 之间做位移动画」而是一场视图控制器生命周期之间的接管仪式。搞懂这场接管仪式背后的三个对象——transitionContext、animationController、delegate——你才能做出系统做不到的转场手势打断、快照冒顶、3D 翻转、甚至混合真实视图与位图素材。本文就从这条链路拆开讲读完你会明白转场动画到底在调度什么、为什么快照无处不在、以及手势驱动是怎么「欺骗」动画进度的。2. 转场的骨架三个协议对象与一个上下文到底谁在指挥谁自定义转场的第一步不是写动画而是搞清楚 UIKit 在转场发生的那一瞬间究竟向你要什么。它只要三样东西一个动画器UIViewControllerAnimatedTransitioning、一个交互控制器可选、以及一套转场上下文UIViewControllerContextTransitioning。这三样东西彼此独立但只有把它们串起来转场才能被 UIKit 识别并调度。2.1 两个 delegate 入口present 与 push 不是一回事iOS 把转场入口分成了两套 delegate 协议这是新手最容易混的地方。UIViewControllerTransitioningDelegate负责 present / dismiss而UINavigationControllerDelegate负责 push / pop。它们返回的东西几乎一样——都是一个UIViewControllerAnimatedTransitioning对象——但调用的时机和语义完全不同。// present / dismiss 的入口由 presented VC 的 transitioningDelegate 提供 extension ViewController: UIViewControllerTransitioningDelegate { func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) - UIViewControllerAnimatedTransitioning? { return SlideTransitionAnimator() } func animationController(forDismissed dismissed: UIViewController) - UIViewControllerAnimatedTransitioning? { return SlideTransitionAnimator() } } // push / pop 的入口由 navigationController 的 delegate 提供 extension ViewController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) - UIViewControllerAnimatedTransitioning? { return operation .push ? PushAnimator() : PopAnimator() } }注意区分两处present 的 delegate 必须挂在被展示的那个 VC上即presentedVC.transitioningDelegate self系统才会在展示时来问你要动画器而 push 的 delegate 挂在导航控制器上。很多人设了 delegate 却没设modalPresentationStyle .custom系统会直接忽略自定义转场这是后话。2.2 transitionContext转场世界的完整地图当 UIKit 决定走自定义转场时它会构造一个私有的UIViewControllerContextTransitioning对象然后把它传给动画器的animateTransition(using:)。这个上下文包含了转场需要的全部信息两个视图控制器、两个视图、容器视图、初始与最终 frame、以及转场是否被取消的标志。实际写代码时你只依赖上下文的只读接口不依赖任何全局状态。最关键的是containerView——所有动画素材都要加到这个视图里。它本质上是当前 window 之上的一个临时层转场结束后会被 UIKit 自动移除。常见的实现思路是在animateTransition里把 toView 加入 containerView然后对 toView 和 fromView或其快照做动画动画结束后调用completeTransition(_:)。这里有一个 UIKit 不会替你做的事iOS 8 之后系统不再自动把 toView 加进 containerView。很多转场动画「白屏」或「只看到黑背景」就是忘了这一步。2.3 从 delegate 到 completeTransition 的完整调用链整个转场的执行序列是这样的触发转场API → UIKit 询问 delegate 获取动画器 → UIKit 构造 transitionContext 并调用animateTransition→ 你在动画闭包里操作视图 → 动画结束后调用completeTransition。completeTransition是最后一道命令它告诉 UIKit「舞台可以拆了」然后 UIKit 才会执行真正的视图控制器生命周期切换调用viewDidAppear、释放旧的 VC、清理层级。一个容易被忽略的点是transitionWasCancelled只有在手势驱动的交互式转场里才可能为true。普通的一次性动画转场这个值永远是false。所以代码里写completeTransition(!transitionContext.transitionWasCancelled)并不是冗余而是为了让同一个动画器在普通转场和交互转场里都能正确工作。func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromVC transitionContext.viewController(forKey: .from), let toVC transitionContext.viewController(forKey: .to) else { return } let container transitionContext.containerView // iOS 8 之后必须手动添加 toView let toView transitionContext.view(forKey: .to) ?? toVC.view container.addSubview(toView) // 从上下文取 finalFrame而不是自己算屏幕宽度 let finalFrame transitionContext.finalFrame(for: toVC) // 初始位置右移一个屏幕宽度再归位 toView.frame finalFrame.offsetBy(dx: UIScreen.main.bounds.width, dy: 0) UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseOut], animations: { toView.frame finalFrame }) { _ in // 动画结束必须调用否则 UIKit 会一直认为转场没完成 transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } }这三段代码是固定的骨架取 VC、加 toView、取 frame、做动画、complete。凡是复杂的转场——圆角卡片、3D 翻转、模糊遮罩——都是在animateTransition这个函数里叠加效果但骨架永远不变。3. 快照为什么说系统转场的动画素材几乎都是「截图」如果你拆过系统 push 转场会发现一个有意思的细节动画过程中fromView和toView都不在动画层级里取而代之的是两张「截图」。UIKit 用snapshotView(afterScreenUpdates:)把视图的当前绘制内容转成一张位图然后对位图做动画。这个设计不是性能优化而是架构上的必然。3.1 snapshotView 截的到底是什么snapshotView(afterScreenUpdates:)返回的不是一个普通 view而是一个轻量级的快照视图。文档里说它包含视图当前内容的「渲染副本」你可以对它做任何动画操作但它不响应触摸事件、不含子视图层级、也无法修改内部内容。从底层机制看快照复用了渲染服务里已经合成好的图层内容把这个内容「钉」在一个新视图上。它不会重新遍历子视图去重绘所以性能开销极低。与之相对的drawViewHierarchy(in:afterScreenUpdates:)则是强制走一遍 CPU 合成把整个视图层级重新画进一个位图缓冲——两者成本相差一个数量级。// 转场动画里的素材截取优先用 snapshotView let snapshot containerView.superview?.snapshotView(afterScreenUpdates: true) // 快照的 frame 必须手动拷贝原视图的 frame snapshot?.frame view.frame container.addSubview(snapshot!)写转场时我一般这么用把 fromView 的真身隐藏用快照顶上做动画。这样就算你在动画里把快照缩放、旋转、甚至加遮罩fromView 的响应链和内部状态都不受影响。动画结束、completeTransition调用后快照被 UIKit 一并清理掉整个转场就像没发生过一样。3.2 为什么必须用快照而不是直接拿着真视图做动画直接拿真视图做动画会出问题转场开始时 UIKit 已经决定要把 fromView 从层级里移除、把 toView 加进来。如果你坚持拿真视图做动画动画结束后你会面临「视图被谁持有、要不要还原、响应链会不会断」的修罗场。快照把「视图层级」和「动画素材」解耦了——你动画的是一个与视图无关的位图想怎么玩都不影响 VC 的生命周期。这个设计是 UIKit 从 macOS 的层备份机制里继承来的心智模型。转场动画本质上是一个「过渡序列」先展示旧内容的截图再展示新内容的截图中间用动画把旧截图「替换」成新截图。至于真视图永远在转场前后安安静静地待在自己的 VC 里。3.3 afterScreenUpdates 参数一个决定成败的渲染时机陷阱snapshotView(afterScreenUpdates:)的布尔参数是很多人踩坑的地方。false表示「立刻截取当前屏幕上的内容」true表示「等待所有 pending 的界面更新完成后再截取」。// 错误示范toView 刚被加进层级layout 还没跑完 let badSnapshot toView.snapshotView(afterScreenUpdates: false) // 可能全黑 // 正确姿势先强制布局再要求截取最新内容 toView.layoutIfNeeded() let goodSnapshot toView.snapshotView(afterScreenUpdates: true)为什么false会截到黑屏因为视图刚被 addSubview 时它的图层内容还没有被渲染服务合成屏幕上根本不存在这个视图的画面。快照去取图层内容时拿到的是一张空白位图。这个参数你可以理解为「要不要等渲染管线跑完这一帧」在转场动画里几乎总是需要true。4. 手势驱动从一次性动画到可打断的交互转场一次性转场只是开胃菜。真正的进阶是从「动画播完就结束」到「手势拽一半能停住、反向拖能回滚」——这就是UIPercentDrivenInteractiveTransition的职责。它做的事听起来简单把一个已经写好的动画器通过进度百分比来控制播放位置。但底层机制比「把动画进度映射成百分比」要微妙得多。4.1 UIPercentDrivenInteractiveTransition 是怎么「接管」动画的这个类的官方实现细节是私有 API但从行为上可以反推它的机制。当你把它作为interactionController返回给 UIKit并在手势的changed里调用update(_:)时它在做的事是把动画所在图层的播放头拨到指定位置。我一般会这么理解animateTransition执行时动画被提交到了转场上下文的某个私有图层上。UIPercentDrivenInteractiveTransition随后把这个图层的speed设为 0让动画暂停在起始帧每次update(_:)就设置timeOffset duration * progress相当于手动拨动播放头。finish()则是把speed恢复为 1让动画从当前帧快进到末尾cancel()则把timeOffset拨回 0同时把动画回滚到起点。// 手势驱动的 dismiss 转场核心骨架 final class PanDismissInteraction: UIPercentDrivenInteractiveTransition { weak var presentedVC: UIViewController? private var isInteractive false objc func handlePan(_ gesture: UIPanGestureRecognizer) { let translation gesture.translation(in: gesture.view) // 用上下滑动距离换算进度每 300pt 视为完成 let progress min(max(translation.y / 300.0, 0.0), 1.0) switch gesture.state { case .began: isInteractive true // 手势开始时才发起 dismiss让转场进入「可交互模式」 presentedVC?.dismiss(animated: true, completion: nil) case .changed: guard isInteractive else { return } update(progress) // 拨动动画播放头 case .ended, .cancelled: guard isInteractive else { return } let velocity gesture.velocity(in: gesture.view) if progress 0.5 || velocity.y 800 { finish() // 冲过去 } else { cancel() // 退回来 } isInteractive false default: break } } }这段代码里最容易忽略的是began分支。手势驱动转场的「开始」不是在 update 里而是在手势开始时就调用dismiss——此时 UIKit 会走完整套转场链路但发现存在交互控制器于是把动画交给UIPercentDrivenInteractiveTransition控制。你后续的update才有东西可拨。4.2 update / finish / cancel 背后的状态机调用update时动画是「暂停」状态画面停在中间帧。这期间视图控制器已经经历了viewWillDisappear但viewDidDisappear还没调用。也就是说VC 的生命周期和动画进度是解耦的——动画可以停在任何地方但生命周期只会在finish或cancel之后补齐。cancel比finish更危险因为它要把动画从半路倒放回起点。倒放没有现成的关键帧所以 UIKit 只能依赖你动画闭包里写死的初始值。如果你在动画里用了CGAffineTransform叠加、或者改变了视图的层级倒放时就可能出现「复位不彻底」的残影。4.3 为什么官方推荐用 UIViewPropertyAnimator 配合交互转场UIPercentDrivenInteractiveTransition的机制有一个天然缺陷它只能「拨动」基于 Core Animation 创建的隐式动画对UIView.animate创建的动画进度映射偶尔会失灵——尤其当你在动画里添加了弹簧曲线的时候。这就是 iOS 10 引入UIViewPropertyAnimator的原因它本身就是为可中断动画设计的。// 用 propertyAnimator 实现可中断转场 let animator UIViewPropertyAnimator(duration: duration, curve: .easeOut) { toView.frame finalFrame } animator.addCompletion { position in // position 是 UIViewAnimatingPosition只会是 .end / .start / .current let completed (position .end) transitionContext.completeTransition(completed !transitionContext.transitionWasCancelled) } // 交互控制器把 animator 暴露出来手势 update 直接映射 animator.pauseAnimation() animator.fractionComplete progressUIViewPropertyAnimator的核心价值在于它把「动画状态」显式化了fractionComplete可以直接设置pauseAnimation可以随时暂停更关键的是它支持interruptionBehavior——即使动画已经播完也能被手势重新接管回滚。这是UIPercentDrivenInteractiveTransition做不到的。实际项目中复杂转场几乎都是「手写 animator 手写交互状态机」UIPercentDrivenInteractiveTransition只用于简单场景。5. 转场避坑指南5 个最常见的翻车现场转场动画的坑不在语法而在 UIKit 的「隐藏约定」。下面这些翻车现场我几乎都亲手踩过一遍每一个都是血泪经验。5.1 fromView 在动画开始时凭空消失现象自定义 present 转场里toView 滑入的同时fromView 直接不见了露出黑背景。原因present 语义下转场开始时 UIKit 把 presenting VC 的视图从 window 层级摘走。如果你的动画闭包里引用了 fromView 并对它做了 alpha / transform 操作本质上是在操作一个「不在渲染树上的视图」画面自然就没了。解决在 present 转场里不要动 fromView而是先snapshotView截一张图放进 containerView动画的快照。dismiss 转场则反过来不要动 toView。时刻记住只有 containerView 里的视图才是渲染树上的视图。5.2 快照的 frame 错乱图片漂到了屏幕外现象快照做缩放动画时缩放中心不对或者快照直接出现在屏幕中间偏下的位置。原因快照的 frame 是在转场容器的坐标系里设置的而很多人直接取了 fromVC.view.frame。如果视图控制器的视图没有撑满容器或者 present 的modalPresentationStyle不是 fullScreen这两个坐标系就不一致。解决统一从transitionContext.initialFrame(for:)和finalFrame(for:)取 frame不要自己算。这两个方法返回的坐标已经换算到了 containerView 的坐标系直接用就绝不会错位。5.3 手势取消后动画停在半空中现象下拉 dismiss 到一半松手取消结果 toView 停在半路页面既没恢复也没关闭像是卡住了。原因cancel()触发时UIKit 回滚转场但如果你用的是UIView.animate它的动画已经被 commit回滚时图层无法精确回到起点。另外completeTransition(false)后UIKit 恢复 fromView 的层级但如果 fromView 在动画中被加过 transform它不会自动清除。解决交互式转场一律使用UIViewPropertyAnimator在 cancel 时调用animator.isReversed true让动画自己倒放。同时completeTransition之前把 fromView 的 transform 显式重置。5.4 delegate 设置了但自定义转场根本没执行现象transitioningDelegate赋值了animationController方法也写了但转场还是系统默认效果。原因两个隐蔽点。第一present 时 delegate 必须挂在 presented VC 上而不是 presenting VC。第二modalPresentationStyle必须是.custom或.overFullScreen用系统的.automatic会忽略自定义转场。解决在prepare(for:sender:)里写destinationVC.transitioningDelegate self并显式设置modalPresentationStyle .custom。写完后可以先打个断点确认animationController有没有被调用。5.5 快照内容是黑的或者截到的是旧画面现象用snapshotView(afterScreenUpdates: false)截出的 toView 快照是全黑的或者截到的还是上一个页面的画面。原因视图刚被加进层级图层内容还没被渲染服务合成false截不到新内容。反过来如果你在viewWillAppear之前截图哪怕传true也可能截到旧的页面布局。解决先调toView.layoutIfNeeded()强制布局再传true截图。如果要用快照作为动画素材最好把快照的生成放到animateTransition里这个时机 UIKit 已经完成了基本布局。6. 设计取舍自绘、快照、混合——转场动画的三种心智模型把机制讲完之后最后一个值得想透的问题发布一个转场效果时你到底在「发布」什么我见过三种做法各有取舍。方案素材优点代价纯快照snapshotView截图代码量最小、天然隔离响应链无法做视图内部的动态效果自绘Core Animation / 自定义 layer效果上限最高、可以精确控制每一帧代码复杂、交互和中断要自己扛混合快照打底 自绘叠加兼顾稳定与表现力需要处理两套素材的坐标同步我现在的默认选择是混合底层用一张快照保证「视图层级绝不出错」上层用UIViewPropertyAnimator驱动 transform 和透明度。只有当需要做类似卡片翻转、水面波纹这种无法用 transform 表达的形变时才会退回去手写CALayer动画。这背后是整个转场体系的设计哲学UIKit 把「视图层级管理」和「动画素材」拆成了两个世界开发者只需要在动画时间内维护好 containerView 里的几块素材剩下的交给转场上下文。从那以后我每次写转场都强制走一遍三个问题fromView 和 toView 此刻在哪个坐标系、动画结束时它们的最终归属是谁、中间态会不会被 completeTransition 误伤。这三个问题回答清楚转场动画就再也没有玄学可言。希望帮到你。本文还有配套的精品资源点击获取