iOS自定义转场动画:从卡片放大到手势驱动完整实战指南
2026/9/7 2:30:48 网站建设 项目流程

简介:面向iOS开发者的自定义转场动画学习资源,围绕页面切换时如何用核心动画、视图动画和转场动画协议实现不同视觉特效,适合研究视图控制器生命周期、导航控制器转场管理和交互式转场的初中级开发者。压缩包共24个文件,以源码与配置为主,包含8个实现文件、5个头文件、4个配置文件,另有Xcode工程、Storyboard界面、单元测试用例等,整体仅51KB,结构紧凑,便于快速定位和对照练习。示例从动画基类到导航控制器、目标控制器,提供了从基础动画封装到完整转场跳转的清晰路径,完整覆盖了转场代理、动画协调器、百分比驱动交互和Storyboard转场逻辑,可直接在Xcode中打开运行调试,边读代码边验证效果。已有492人学习下载;想通过实际工程理解转场动画各组件如何协作,这份轻量源码是直观的入门参考。

1. 转场动画这事,先别急着写代码

说到iOS自定义转场动画,我最早接触还是做电商App那会儿。产品丢过来一张设计稿,要求列表页点击商品卡片,图片从缩略图位置“放大”进详情页,页面切换不能有生硬跳变,还得支持右滑手势返回。当时第一反应是“这不就是系统转场嘛,改改参数就行”,结果越挖越深,发现自定义转场动画涉及的机制比想象中多,踩了一圈坑之后才总结出完整套路。

这篇内容主要面向两类读者:一类是做iOS开发没多久、想把界面切换做得更有质感的初级工程师,另一类是已经会写基础转场、但遇到交互式手势就头大的中高级开发者。全文不会堆概念,直接拆解我从UIViewControllerTransitioningDelegate到UIPercentDrivenInteractiveTransition的完整实现思路,把我踩过的坑、实测稳定的写法、参数怎么算都写出来。看完这套内容,你可以直接在项目里动手复现一个带手势驱动的自定义转场动画,而不是停留在“看过API文档”的层面。

2. 为什么系统转场满足不了你:方案选型的底层逻辑

2.1 系统转场的局限

iOS系统自带的和 UINavigationController push/pop、present/dismiss 转场,默认效果就是“从右往左推进”和“从下往上拉起”。老实说,大部分工具型、内容型App用系统效果完全够了,苹果在iOS 13之后又推出了UISheetPresentationController,底部卡片滑出样式更是省了不少事。但一旦品牌调性要求高,或者交互上需要把用户视线聚焦到某个元素时,系统转场就明显不够用了。

比如下面这几个场景,系统效果就扛不住:

  • 列表页的封面图,希望放大成详情页大图,视觉上延续同一个元素,告诉用户“你点的是这个东西”。
  • 图片浏览器左右滑动翻页,从缩略图进入全屏,再在全屏中滑动切换,这个交给系统做根本做不了。
  • 关闭页面时需要配合特定手势(比如下滑、右滑、长按)而不是固定点按钮。

这些场景的共同点是:页面切换不仅是“换屏幕”,而是要传达一种空间关系和操作延续感。系统转场是通用的,通用就意味着没有个性,也没法和特定交互绑定。自定义转场动画的价值就在这里:把页面A和页面B之间的过渡动作,变成你产品体验的一部分。

2.2 两种自定义转场模式怎么选

iOS里自定义转场分两大体系。第一类是modalPresentationStyle.custom的modal转场,走UIViewControllerTransitioningDelegate协议;第二类是导航控制器栈内转场,走UINavigationControllerDelegate协议。两种模式的动画对象完全一致,都是实现UIViewControllerAnimatedTransitioning协议,但触发时机和生命周期管理有差别。

我的选型建议很简单:

  • 如果业务是“临时性的页面”,比如弹窗、引导页、权限说明、详情浮层,用modal custom。
  • 如果业务是“页面栈跳转”,有明确的push/pop关系,比如列表到详情、详情到子详情,用navigation delegate扩展。
  • 如果怕麻烦,不想管UINavigationController的一堆交互栈逻辑,纯modal方案也能模拟大多数场景,但有导航栈的页面建议还是走navigation方案,否则返回手势会和系统冲突。

从我个人经验来说,95%的自定义转场需求其实都能基于modal体系搞定,因为present/dismiss配合手势驱动非常灵活,而且不会污染导航栈。只有那种需要保持导航栏平滑过渡的场景,才建议动UINavigationControllerDelegate。这一块很多人一上来就做错了,先想清楚再动手可以省很多返工时间。

2.3 一套核心动画协议,两者通用

不论走哪条路,真正干活的都是同一个对象:一个遵守UIViewControllerAnimatedTransitioning协议的NSObject子类。苹果在这里的设计很巧妙,它把“转场时长、动画操作、完成回调”都塞进了这个协议里,系统只负责管理上下文,动画具体怎么做完全交给开发者。

我建议在项目里单独建一个AnimationTransition目录,里面放三类文件:

  • 转场动画器:实现UIViewControllerAnimatedTransitioning,负责present/dismiss时分别做两套动画。
  • 手势驱动器:继承UIPercentDrivenInteractiveTransition,负责把拖拽手势进度映射成动画百分比。
  • 中心协调器:把动画器、手势驱动器、delegate绑定到具体的控制器上。

这种分层思路我用了好几年,好处是解耦彻底:动画器不知道手势存在,手势驱动器不知道具体动画怎么实现,协调器把它们串起来。项目里新转场效果只需要替换动画器,其他代码完全不用动。

3. 从零手写一个卡片放大转场:核心机制拆解

3.1 转场上下文里的三个角色

写自定义转场动画之前,必须弄明白转场过程中系统到底在干什么。以present为例,系统做三件事:

  1. 创建一个容器视图containerView,把来和去的两个控制器的视图都塞进去。
  2. 放入fromVC的视图(当前正在展示的页面视图)。
  3. 调用动画器的animateTransition(using:)方法,你在方法里自己决定何时把toVC的视图加进来、怎么做动画、动画结束调completeTransition

这里最容易被忽略的是:动画期间两个视图不是平级页面切换关系,而是同时存在于同一个容器里的“兄弟视图”。所以你想实现“把from视图挪开、让位给to视图”或者“to视图叠在from视图上淡入”,完全是你自己说了算,系统不干预。

UIViewControllerContextTransitioning协议是这个机制里的“上下文单据”,通过view(forKey: .from)可以拿到来源视图,通过view(forKey: .to)拿到目标视图,还有finalFrame(for:)可以获取系统计算好的目标frame。我见过很多初学者不调用finalFrame,硬写死frame,结果横竖屏切换、刘海屏适配时动画就乱了。正确做法是拿finalFrame作为动画的终点位置,这样不管设备尺寸怎么变都是对的。

3.2 动画器里的present和dismiss怎么写

下面是我实际项目里一个卡片放大转场的核心写法,动画器内部根据isPresenting区分两套动画:

final class CardTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { private let isPresenting: Bool private let originFrame: CGRect private let duration: TimeInterval = 0.45 init(presenting: Bool, originFrame: CGRect) { self.isPresenting = presenting self.originFrame = originFrame super.init() } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return duration } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let toVC = transitionContext.viewController(forKey: .to), let fromVC = transitionContext.viewController(forKey: .from) else { transitionContext.completeTransition(false) return } let container = transitionContext.containerView let finalFrame = transitionContext.finalFrame(for: isPresenting ? toVC : fromVC) if isPresenting { // 目标视图先缩放到和来源卡片一样大,叠在卡片位置上 container.addSubview(toVC.view) toVC.view.frame = originFrame toVC.view.layer.cornerRadius = 16 toVC.view.clipsToBounds = true UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.5, options: [.curveEaseInOut]) { toVC.view.frame = finalFrame toVC.view.layer.cornerRadius = 0 } completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } else { // 返回时目标视图是fromVC本身,它需要缩回卡片位置 UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.2, options: [.curveEaseInOut]) { fromVC.view.frame = self.originFrame fromVC.view.layer.cornerRadius = 16 } completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } } }

细节说明几点:

  • 为什么用UIView.animate而不是UIViewPropertyAnimator?纯非交互转场用系统UIView动画完全够用,代码简单易读;需要可中断可交互的复杂动画时再考虑UIViewPropertyAnimator
  • completeTransition的布尔值必须传!transitionWasCancelled,这个细节新手特别容易忽略,直接传true会导致手势取消时状态错乱。
  • 卡片放大转场的关键是起点frame要精确。我在调用处把被点击卡片的frame通过convert(_:to:)转换成window坐标系,再传给动画器。千万别用cell的frame直接传,cell的坐标系不在window里。

3.3 转场协调器的组装方式

动画器只是零件,负责把它接到转场流程里的是协调器。以modal为例,present时把transitioningDelegate指向协调器,协调器在animationController(forPresented:presenting:source:)里返回动画器,在interactionControllerForPresentation(using:)里返回手势驱动器。

final class CardTransitionCoordinator: NSObject, UIViewControllerTransitioningDelegate { private let originFrame: CGRect private let interactiveTransition = UIPercentDrivenInteractiveTransition() init(originFrame: CGRect) { self.originFrame = originFrame super.init() } func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { return CardTransitionAnimator(presenting: true, originFrame: originFrame) } func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return CardTransitionAnimator(presenting: false, originFrame: originFrame) } func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return nil } func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return interactiveTransition.isGestureActive ? interactiveTransition : nil } }

这里有个关键知识:手势驱动和动画器的关系。UIPercentDrivenInteractiveTransition本身不执行动画,它只把自己的percentComplete变化映射到正在进行的动画上。所以必须先有动画器在执行动画,然后手势处理器才能通过update(percent)控制这个动画的位置。它内部实现原理是用了CADisplayLink定时器驱动的层时间偏移,理解这一点就能明白为什么手势活跃期返回interactiveTransition、手势结束返回nil了。

4. 手势驱动的转场怎么做才不翻车

4.1 基础实现:UIPercentDrivenInteractiveTransition

很多人照着文档写手势驱动,结果发现两个典型问题:一是手势中途松手动画跳回起点,二是取消转场以后页面状态错乱。这两个问题我刚开始也遇到过,后来总结出稳定的三件套写法。

第一步,在协调器里设置UIPercentDrivenInteractiveTransition实例的wantsInteractiveStart,这个属性在iOS 10之后可以控制动画器是否以交互模式启动。设成true表示当手势存在时,动画以交互模式运行,系统不会立刻执行完整动画,而是等你更新百分比。

第二步,在目标控制器里添加手势:

@objc private func handleDismissPan(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: gesture.view) let percent = min(max(translation.y / 400.0, 0.0), 1.0) switch gesture.state { case .began: interactiveTransition.isGestureActive = true dismiss(animated: true, completion: nil) case .changed: interactiveTransition.update(percent) case .ended, .cancelled, .failed: interactiveTransition.isGestureActive = false if percent > 0.3 || gesture.velocity(in: gesture.view).y > 800 { interactiveTransition.finish() } else { interactiveTransition.cancel() } default: break } }

第三步,在动画器里确保动画是可中断的。如果动画器里用的是UIView.animate,系统会通过layer动画自动支持interactive transition,不需要额外写代码。但如果用了UIViewPropertyAnimator,就要注意在animateTransition里返回animator对象,并让它支持暂停和反转。

这里有一个思路很多人会搞混:手势结束时finish()cancel()并不是你手动去开启动画完成或回到原位的,而是你告诉系统“这个转场定了/取消”,系统再回调动画器的animationEndedcompleteTransition。所以动画器里的completion闭包最终一定要执行completeTransition,否则手势结束后页面会卡在半透明状态,点哪儿都没反应。

4.2 手势取消后的页面状态恢复

手势驱动最恶心的bug就是:你拖到一半松手,转场取消,结果页面A呈现出被push一半的外观,或者页面B残留了一半在容器里。核心原因就一个:转场取消后,toVC.view没有被正确清理。

我自己踩过坑之后的稳定操作是:在completeTransition(false)之后,主动把toVC.view.removeFromSuperview(),同时恢复fromVC.view的原始状态。但要注意,这个toVC指的是本次转场的目标视图,因为转场取消时它不完整,留在容器里也没意义。

} completion: { [weak transitionContext] _ in let completed = transitionContext?.transitionWasCancelled ?? false if !completed { // 转场完成,目标视图保留 } else { // 转场取消,主动清理目标视图 transitionContext?.view(forKey: .to)?.removeFromSuperview() } transitionContext?.completeTransition(!completed) }

另外提一个我经常用的恢复技巧:手势取消后,如果源控制器是presented出来的(即它是上一级的toVC),系统可能会自动把它加回视图层级,但如果你的源控制器在转场开始前被手动隐藏过(比如view.alpha = 0),取消后必须手动恢复。这类“半路恢复”的状态问题是最难排查的,建议统一写一个resetContainerState方法,在里面重置源视图的alpha、transform、frame。

4.3 手势和边缘返回手势的冲突处理

如果你在navigation controller里做手势驱动转场,同时系统又自带interactivePopGestureRecognizer,两个手势可能打架。我遇到过的现象是:在页面内右滑,系统返回手势触发,但我自定义的返回手势也触发,动画重复执行。

解决办法是在目标控制器里让自定义手势和系统手势互斥:

navigationController?.interactivePopGestureRecognizer?.isEnabled = false

或者在自定义返回手势的delegate里实现shouldRequireFailureOf,让它等系统手势失败后再识别。这个选择取决于你需不需要保留系统的边缘返回能力。像我们项目里自定义手势要覆盖整个屏幕右滑返回,系统边缘手势就只有屏幕左边缘能触发,两者互斥影响不大,所以我直接禁用了系统的,交给自定义手势统一处理。

5. 常见问题与排查技巧实录

5.1 视图层级错乱:为什么两个VC的视图同时出现又消失

转场动画期间,fromVC的视图和toVC的视图都在containerView里,动画结束后系统会移除fromVC视图。如果发现转场完成后视图层级不对,最常见原因是你在动画器里把某个视图加到containerView后,没有在正确的位置加约束或设置frame。

比如我看到过一个情况:present转场正常,但dismiss后fromVC的视图消失了。排查后发现是动画器在dismiss时误把fromVC.view(这里from是详情页)设置了isHidden = true,没有在completeTransition后重置。建议在animationEnded(_:)里统一做一次状态复位,把所有动画期间改过的视图属性恢复原样。

5.2 转场后状态栏样式不对

自定义转场动画如果改变了toVC的状态栏样式,在转场过程中状态栏不会自动切换,因为系统认为两个VC都可能展示。解决办法是在协调器里实现preferredStatusBarStyle的动画过渡,或者直接用UIViewControllerBasedStatusBarAppearance控制显示。

我建议在自定义转场动画期间,不要过度依赖系统状态栏自动切换,而是临时给toVC设置modalPresentationCapturesStatusBarAppearance = true,这样转场过程中状态栏样式由toVC自己说了算,不会闪一下。

5.3 转场卡顿:为什么动画掉帧

自定义转场如果动画目标是巨大的复杂视图,比如全屏图片、列表页截图,很容易掉帧。原因一般是动画期间进行了离屏渲染,或者触发了很多CPU计算。我实测下来最有效的手段有三个:

  • 动画期间尽量只动画transformbounds,避免频繁修改frame(frame变化会触发layoutSubviews)。
  • 复杂视图先做snapshotView(afterScreenUpdates:),把当前视图的内容快照成静态图,动画操作在快照上进行,动完再替换真实视图。
  • 大量圆角+阴影同时动画极其消耗性能,能少用就少用。

5.4 常见问题速查表

问题现象常见原因解决方案
转场完成后目标页面一半透明completeTransition传参错误或未调用检查completion闭包里是否调用了completeTransition(!transitionWasCancelled)
手势拖拽时动画跳变动画不是可交互模式确认动画器在animateTransition里执行的是可中断动画
手势取消后页面残留目标视图未从容器移除completeTransition(false)后手动移除toVC.view
状态栏闪跳modalPresentationStyle未捕获状态栏设置modalPresentationCapturesStatusBarAppearance = true
转场过程卡顿大视图直接参与动画使用snapshotView快照后动画
dismiss后源页面黑屏源视图被误隐藏或移除animationEnded统一复位视图状态

5.5 调试自定义转场的小技巧

写自定义转场动画最痛苦的是看不到中间状态。后来我总结了一个很实用的调试方法:在动画器里加一个DEBUG标记,动画持续时间改为3秒,同时给containerView加一个随机背景色,这样能清楚看到整个动画过程。代码大概这样:

#if DEBUG UIView.animate(withDuration: 3.0, delay: 0, ... ) containerView.backgroundColor = .systemPink #endif

另一个技巧是在协调器里打印转场回调顺序,确认animationControllerinteractionControlleranimationEnded的调用顺序是否符合预期。系统文档对回调顺序语焉不详,实际调试发现顺序是:animationController先于animateTransitioninteractionControlleranimateTransition之后,手势结束后animationEnded最后调用。

6. 这套方案在真实项目里的扩展方向

自定义转场动画写完一套之后,复用性是重中之重。我现在的做法是写一个TransitionConfiguration结构体,统一配置动画时长、弹簧阻尼、起始帧、手势类型、是否可取消这些参数,然后所有动画器都从这一个配置对象取参数。新需求来了只需要新增一个动画器子类,配置好参数,几行代码就能接入。

还有一个建议是:如果项目里有设计系统,转场动画的参数一定要和设计规范保持同步。动画时长、曲线如果每次都由开发自己拍脑袋,整个App的体感会是“每个页面都在蹦迪”。和设计对齐统一节奏后,产品质感会明显提升。

这部分的后续扩展,我打算做圆角过渡、嵌套UIScrollView的联动转场、以及转场期间的模糊效果。核心机制都是我这篇文章里讲的这套上下文管理,只是动画代码不同而已。掌握了这一个骨架,后续任何自定义转场都只是换皮,不用担心架构撑不住。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询