简介面向iOS开发者的自定义转场动画示例工程围绕UIViewControllerAnimatedTransitioning、UIViewControllerTransitioningDelegate等核心协议展示如何摆脱系统默认动画实现带有个性化视觉的页面切换同时覆盖CAAnimation与UIView动画选型、交互式手势驱动、过渡协调器联动等知识点提供从基础到进阶的完整参考适合有一定iOS基础、希望提升App质感的开发者学习。包内共24个文件以Objective-C源码为主体包括8个.m实现和5个.h接口涵盖转场管理类、导航控制器、目标控制器等模块另有storyboard界面、plist配置、Xcode工程文件与项目依赖数据整体仅51KB结构清晰便于直接运行和拆解也方便定位与复用其中的动画逻辑。已有492人学习使用。通过可运行示例和注释可以理解动画时长控制、协议方法调用、容器控制器配合等关键逻辑快速上手从普通跳转到自定义交互式转场的完整流程示例工程还展示了自定义转场在实际项目中的接入方式可作为功能模块直接参考帮助开发者把个性化切换效果真正落地到自己的应用里。1. 先把iOS转场的“官方套路”聊透自定义转场动画这个东西说难是真难说简单其实也就一层窗户纸。我早期做iOS开发的时候产品经理丢过来一个需求列表页点一张卡片cell里的图片要“飞”到详情页背景的位置然后展开成完整页面。当时我第一反应是拿UIView.animate硬拼结果做出来不但掉帧返回时动画还会“穿帮”。后来我把UIViewControllerAnimatedTransitioning这套官方协议啃明白之后才发现苹果早就把路铺好了只是文档写得实在太“优雅”新手根本不知道往哪儿踩。先讲清楚一个核心概念iOS里所谓的“自定义转场”本质上是你接管了两个ViewController切换瞬间的那一帧画面到底长什么样。系统默认的push、present、pop其实也是跑了一套固定的动画只不过动画参数写死在UIKit内部。我们要做的就是把这套“默认实现”替换成自己的实现。这里涉及三个关键角色我拿“搬家”来类比转场上下文UIViewControllerContextTransitioning相当于搬家当天站在楼下的调度员。他知道你在哪个小区容器视图、哪家的家具要搬走fromView、哪家的家具要搬进来toView。动画器UIViewControllerAnimatedTransitioning相当于搬家公司。你告诉它“3秒内完成”它就负责把fromView移出去、把toView移进来。委托Delegate类似于你在搬家平台上下的订单告诉系统“这次搬家我指定用哪家公司”。导航栏转场找UINavigationControllerDelegate模态转场找UIViewControllerTransitioningDelegate。理解了这个三角关系你再看苹果的转场相关API就不会觉得头大了。系统要做的事情其实很简单先把toView加到容器视图上然后调用动画器的animateTransition:方法在动画闭包里改变两个视图的frame、alpha、transform等属性。动画结束后调用completeTransition:告诉系统“搬完了”系统就会把闹情绪的fromView清理掉。这也是我建议每个iOS开发者都手写一次自定义转场的原因——它能帮你看清UIKit在ViewController切换时到底干了哪些脏活累活。你以为的“切页面”只是加了一层新的VC实际上背后涉及视图层级、生命周期、交互手势的三方协调。搞清楚这套逻辑之后你以后再碰到任何转场需求都不会慌无非是在动画器里写不同的动画效果而已。2. 从零写一个“卡片缩放转场”理论说完了直接上实操。我带大家写一个最常见的场景——列表卡片点击后放大到全屏详情页。这个效果在各类App里频繁出现掌握了它等于拿到自定义转场的第一把钥匙。2.1 搭骨架设置代理并返回动画器首先详情页的ModalPresentationStyle要设置成.custom。这一点特别重要因为.fullScreen和.pageSheet这类系统样式会强制走系统转场根本不会问你自定义的事。设置为.custom之后系统的containerView才完全交给你掌控。let detailVC DetailViewController() detailVC.modalPresentationStyle .custom detailVC.transitioningDelegate self present(detailVC, animated: true, completion: nil)然后在当前页面的extension里实现UIViewControllerTransitioningDelegateextension ViewController: UIViewControllerTransitioningDelegate { func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) - UIViewControllerAnimatedTransitioning? { return CardTransitionAnimator(isPresenting: true, originFrame: selectedCellFrame) } func animationController(forDismissed dismissed: UIViewController) - UIViewControllerAnimatedTransitioning? { return CardTransitionAnimator(isPresenting: false, originFrame: selectedCellFrame) } }selectedCellFrame是点击那张cell在window坐标系里的位置和尺寸。为什么一定要用window坐标系因为转场动画发生在containerView里而containerView的坐标系跟window是一致的。如果直接用cell的frame在层级不同的情况下会算错位置。2.2 让动画器干真正的活核心的动画器类要遵循UIViewControllerAnimatedTransitioning协议实现两个方法transitionDuration返回动画时长animateTransition写具体的动画逻辑。我先给一个最简但完整的present实现然后再逐行解释class CardTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { let isPresenting: Bool let originFrame: CGRect init(isPresenting: Bool, originFrame: CGRect) { self.isPresenting isPresenting self.originFrame originFrame super.init() } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) - TimeInterval { return 0.6 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { let containerView transitionContext.containerView guard let fromView transitionContext.view(forKey: .from), let toView transitionContext.view(forKey: .to) else { transitionContext.completeTransition(false) return } if isPresenting { containerView.addSubview(toView) let finalFrame transitionContext.finalFrame(for: transitionContext.viewController(forKey: .to)!) // 初始状态从卡片大小、圆角、缩小的状态开始 toView.frame originFrame toView.layer.cornerRadius 12 toView.layer.masksToBounds true toView.layoutIfNeeded() UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options: [.curveEaseInOut]) { toView.frame finalFrame toView.layer.cornerRadius 0 toView.layoutIfNeeded() } completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } else { // dismiss方向 containerView.insertSubview(toView, belowSubview: fromView) UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.3, options: [.curveEaseInOut]) { fromView.frame self.originFrame fromView.layer.cornerRadius 12 fromView.layoutIfNeeded() } completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } } }注意transitionContext.view(forKey: .from)取到的视图在present场景下就是我们当前列表页的viewview(forKey: .to)则是要展示的详情页view。但在dismiss场景下刚好反过来fromView是详情页toView是列表页。2.3 细节决定成败几个必须处理的坑第一layoutIfNeeded不是玄学。很多新手写转场直接改frame然后进动画闭包结果发现动画“瞬移”而不是渐变。原因是自动布局的约束还没刷新frame被约束“顶”回去了。在设置初始frame之后、动画闭包之前手动调一次layoutIfNeeded()强制系统立刻更新布局动画才能真正跑在正确的起点上。第二圆角动画要跟frame一起做。卡片是12pt圆角页面是0圆角这两者之间的过渡如果不放在动画闭包里会出现“先变方角再放大”的割裂感。上面代码把cornerRadius放进同一组动画里视觉上才连贯。第三iOS 18之后用UIViewPropertyAnimator更顺手。新系统对UIView.animate的某些旧行为做了调整如果项目最低版本支持iOS 15我更推荐用UIViewPropertyAnimatorlet animator UIViewPropertyAnimator(duration: 0.6, dampingRatio: 0.8) animator.addAnimations { ... } animator.addCompletion { position in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } animator.startAnimation()它的好处是天然支持“打断”和“反转”后续做手势驱动时不需要额外封装直接pauseAnimation()、fractionComplete()就能衔接。3. 模态转场的“最后一个拼图”UIPresentationController很多人写自定义转场写到这里就收工了——动画确实能跑效果也还行。但如果你试过dismiss手势或者想在详情页底下加一个半透明遮罩就会发现少了点什么。少了的那块就是UIPresentationController。UIPresentationController是模态转场里专门管理“presented VC怎么摆”的类。系统默认present时会创建它但只有当你设置了transitioningDelegate里对应的回调时它才会真正介入func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) - UIPresentationController? { return CardPresentationController(presentedViewController: presented, presenting: presenting) }这个类的好处是动画器只管“视图怎么动”PresentationController管“动完之后停在哪儿、背景长什么样、外部点击怎么办”。两者配合才算一个完整可用的模态转场。我的做法是自定义一个CardPresentationController实现三个效果给presentedView设置尺寸和位置比如中心弹窗模式想做到屏幕宽度的80%。往容器视图里插一个半透明黑色背景点击背景自动dismiss。控制presentedView的圆角。关键代码如下class CardPresentationController: UIPresentationController { private lazy var dimmingView: UIView { let view UIView() view.backgroundColor UIColor.black.withAlphaComponent(0.4) let tap UITapGestureRecognizer(target: self, action: #selector(dismiss)) view.addGestureRecognizer(tap) return view }() objc private func dismiss() { presentedViewController.dismiss(animated: true) } override var frameOfPresentedViewInContainerView: CGRect { guard let containerView containerView else { return .zero } let size CGSize(width: containerView.bounds.width * 0.85, height: containerView.bounds.height * 0.7) return CGRect(x: (containerView.bounds.width - size.width) / 2, y: (containerView.bounds.height - size.height) / 2, width: size.width, height: size.height) } override func presentationTransitionWillBegin() { super.presentationTransitionWillBegin() if let containerView containerView { dimmingView.frame containerView.bounds dimmingView.alpha 0 containerView.insertSubview(dimmingView, at: 0) } presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in self.dimmingView.alpha 1 }, completion: nil) } override func dismissalTransitionWillBegin() { super.dismissalTransitionWillBegin() presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in self.dimmingView.alpha 0 }, completion: nil) } }提示presentedView的frame是由frameOfPresentedViewInContainerView决定的。如果你不在动画器里手动设置toView.frame系统就会以这个属性为准。两个地方都有控制权但建议“PresentationController管最终位置、动画器管过程”各司其职千万别在动画器里把frame写死否则PresentationController的frame会被覆盖。我们组一开始没接PresentationController结果发现点击背景收起的功能没法统一处理每个页面都要自己写遮罩、自己控制alpha代码烂得没法看。接上这个类之后所有模态弹窗的“黑色背景居中布局点击收起”逻辑全部收敛到一处后面新增弹窗页面几乎零成本。4. 手势驱动转场让交互“跟手”转场动画写好了只是“静态展示”。真正让用户觉得产品高级的是手势驱动——比如从屏幕边缘右滑慢慢pop回去手指移到一半松掉页面跟着动画停住再比如底部弹窗下拉收起手指拖动多少弹窗就缩多少。这种交互全靠UIPercentDrivenInteractiveTransition撑起来。4.1 核心原理动画进度和手指进度对齐UIPercentDrivenInteractiveTransition做的事情说穿了就是“一个能按百分比控制动画进度的代理”。你初始化一个实例在手指移动时调用update(_:)更新进度手指离开时判断是finish()还是cancel()系统会自动把动画跳到对应的目标状态。我在项目里的做法是给导航控制器的interactivePopGestureRecognizer升级了一下替换成自己的手势final class InteractivePopTransition: UIPercentDrivenInteractiveTransition { var isInteractive false private weak var navigationController: UINavigationController? init(navigationController: UINavigationController) { self.navigationController navigationController super.init() } func wireToViewController() { let pan UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) navigationController?.view.addGestureRecognizer(pan) } objc private func handlePan(_ pan: UIPanGestureRecognizer) { guard let view navigationController?.view else { return } let translation pan.translation(in: view) let progress min(max(translation.x / view.bounds.width, 0), 1) switch pan.state { case .began: isInteractive true navigationController?.popViewController(animated: true) case .changed: update(progress) case .ended, .cancelled: isInteractive false if pan.velocity(in: view).x 800 || progress 0.5 { finish() } else { cancel() } default: break } } }注意一点popViewController(animated: true)必须放在began里触发然后在delegate里返回self作为interactionController系统才会把这个pop动作的动画交给我们的UIPercentDrivenInteractiveTransition接管。func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) - UIViewControllerInteractiveTransitioning? { return interactiveTransition.isInteractive ? interactiveTransition : nil }4.2 交互和普通动画的衔接新手最容易踩的坑是自定义了导航转场的animationController之后系统默认的interactivePopGestureRecognizer失灵了。原因很简单——你接管了动画器但没接管交互器。系统不知道该把滑动进度交给谁。解决方法是像上面这样自己用手势接管pop把popViewController(animated: true)变成这个手势触发的动作。另一个需要注意的细节是**完成阈值。**我一般用“速度优先”加“距离兜底”的双重判断手指移动距离超过屏幕宽度的一半或者松手速度大于800pt/s就判定为finish否则cancel。这个值不能拍脑袋定要考虑手指操作的物理惯性——太灵敏用户容易误触返回太迟钝用户觉得“拉不动”。4.3 用UIViewPropertyAnimator做更精细的手势衔接使用UIPercentDrivenInteractiveTransition的默认实现时finish()和cancel()之后的动画曲线都是系统写死的。但如果你希望“松手后卡片继续弹一下再归位”就要自己接管收尾动画了。办法是在自定义的动画器里改用UIViewPropertyAnimator然后暴露出来class InteractiveCardAnimator: NSObject, UIViewControllerAnimatedTransitioning { var propertyAnimator: UIViewPropertyAnimator? func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) - UIViewImplicitlyAnimating { if let existing propertyAnimator { return existing } let animator UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), dampingRatio: 0.85) // ... 配置动画闭包 propertyAnimator animator return animator } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { // 空实现交给 interruptibleAnimator } }注意一旦实现了interruptibleAnimator(using:)animateTransition(using:)里即使写了动画也不会执行。系统默认会走interruptible的那条路。这也是很多开发者改了interruptibleAnimator之后发现“怎么动画不动了”的原因——得重新配置动画闭包而不是继续在animateTransition里写逻辑。我实际测过用UIViewPropertyAnimator接管后手势跟手度明显比默认实现好一个档次。默认的UIPercentDrivenInteractiveTransition在快速滑动、中途停顿、反向拖动这些复杂手势下动画进度会有一点点“打滑”感换成propertyAnimator自己管理进度就能做到完全驱动。5. 做转场最容易踩的几个坑附排查思路我把这几年做自定义转场遇到的典型问题整理成了速查表每一条都是真金白银换来的教训希望能帮你少走一段弯路。问题现象根本原因解决方案动画执行完页面白屏没有真正跳转忘了调用completeTransition(_:)或者传了false在动画completion里务必调用completeTransition(!transitionContext.transitionWasCancelled)动画“瞬移”没有过渡效果修改frame前没有layoutIfNeeded()约束把frame顶回去了在设置初始状态后手动调用view.layoutIfNeeded()返回时详情页“闪一下”再消失dismiss时toView没有插到fromView下面dismiss方向用containerView.insertSubview(toView, belowSubview: fromView)自定义导航转场后系统右滑返回失灵只实现了animationController没实现interactionController按上文方式实现UIPercentDrivenInteractiveTransition并接入手势详情页弹出后状态栏/圆角样式不对忽略了presentedViewController的preferredContentSize和presentation风格在UIPresentationController里统一管理presentedVC的外观转场过程中页面出现“黑边”fromView或toView的背景色是透明/黑色层级没铺满给页面根视图设置明确的背景色或者用UIScreen.main.bounds手动铺满快速点击present按钮弹出两个页面转场过程中没有做“是否在转场中”的状态判断加一个isTransitioning的布尔标志present期间拦截重复点击再补充一个比较容易忽视的点转场动画期间不要依赖viewWillAppear做重布局。自定义转场因为是全手动控制视图层级viewWillAppear和viewDidAppear的调用时机可能跟系统默认转场不完全一致。我们组之前有个页面在viewWillAppear里根据屏幕宽度调整collectionView布局结果自定义转场过程中布局闪了一下。后来把布局刷新的逻辑挪到viewDidLayoutSubviews或者等转场完成后再做问题就消失了。6. 关于转场性能的几句实在话最后聊点优化的东西。自定义转场动画特别是像卡片缩放这种涉及cornerRadius、shadow的动画极其容易掉帧。原因是cornerRadius和shadowPath在动画过程中会导致离屏渲染而离屏渲染是iOS动画掉帧的头号元凶。我的经验是能不用cornerRadius就别用实在要用就给layer做光栅化。toView.layer.shouldRasterize true toView.layer.rasterizationScale UIScreen.main.scale但shouldRasterize打开后要记得在动画结束关掉否则帧缓存一直存在内存会白白涨一截。我一般在completion里把shouldRasterize设回false。另外不要把所有动画都堆在主线程跑。像图片解码、大量视图的layout刷新尽量提前在转场开始前完成。我的做法是转场前先把图片裁剪成即将显示的尺寸把大图decode放到子线程确保动画期间主线程只做坐标变换和alpha变化这些轻操作。提示如果卡片是普通的UIImageView内容直接对imageView.layer做transform动画比改frame更高效。transform走的是GPU渲染管线frame则会让系统做layout和bounds计算两者的性能差距在低端机型上会被明显放大。还有一点UIScrollView的嵌套页面转场要特别小心。如果详情页里有UICollectionView或者WKWebView这些视图内部有自己的绘制机制转场时容易出现“白屏碎片”。我的处理方式是提前把目标页面的view添加到容器视图然后调用一次view.layoutIfNeeded()让它先把内容布局好再开始动画。虽然会多等一两帧的布局时间但观感完全不一样。本文还有配套的精品资源点击获取