简介:面向iOS开发者的自定义转场动画学习资源,围绕页面切换时的视觉动效展开,重点讲解CAAnimation与UIView Animation的选用、UIViewControllerAnimatedTransitioning协议、transitioningDelegate设置、Storyboard Segue重写以及交互式转场等核心机制。压缩包内含完整Xcode工程,共24个文件,以Objective-C的m/h源码为主,另有plist配置、storyboard界面描述和xcworkspace工程文件,整体约51KB,目录结构精简,便于直接运行和对照修改。已有492人学习下载。通过示例代码可直观看到自定义导航转场、手势驱动百分比动画等落地写法,适合有一定iOS基础、希望提升界面动效细节的中级开发者参考。 自定义转场动画,这六个字在iOS开发里看起来平平无奇,但真到了产品经理拿着设计稿找你,要做一个从卡片点击放大到全屏的过渡效果,或者一个底部弹窗要像弹簧一样拉出来的时候,绝大多数人都会卡在同一个地方:不是不知道API叫什么,而是不知道系统到底在背后替我们做了什么。我自己第一次做的时候也踩了不少坑,网上资料零零散散,抄一段代码能跑但改成自己的需求就翻车,所以想着把整体机制和一套能直接用的实现思路整理出来,希望能帮到正在跟转场动画死磕的人。
这篇文章不打算只给一段模板代码,我会从系统转场的执行机制开始讲,再分别给出Present转场、Push/Pop转场、手势交互式转场和UIPresentationController的完整实现,最后把真机调试时容易踩的坑列成清单。适合刚开始接触自定义转场的开发者,也适合已经写过但被各种边缘情况折磨过的人。
1. 为什么我劝你别用“把新控制器塞进window”的野路子
1.1 野路子看似能用,问题却藏在底层
不少人第一次做转场动画,图省事,直接在按钮点击事件里写:
let vc = DetailViewController() UIApplication.shared.keyWindow?.addSubview(vc.view)然后在vc.view上跑一个UIView.animate,看起来效果确实出来了:新页面从右边滑进来,或者从底部弹上来,还挺像那么回事。但用两个月之后就陆续出问题:明明present了新控制器,为什么内存里旧控制器没释放?为什么屏幕旋转之后布局乱了?为什么在某个低版本系统上,手势返回失效了?为什么状态栏偶尔对不齐?
原因很简单:你绕过了iOS的控制器生命周期体系。系统的present、push并不是简单把view加到Window上,它背后会维护一套父子控制器关系、旋转适配、内存警告、状态栏管理、手势交互逻辑。你手动加view,等于把一个演员塞上舞台但没给他剧本、没灯光、没场务,短时间能演出,但任何风吹草动都会出问题。
1.2 系统转场真正的工作方式:一个舞台,两份视图,一张场记板
系统的自定义转场机制,核心是一个协议:UIViewControllerAnimatedTransitioning。当系统执行present或者push时,它不会自己去摆弄视图,而是把“演出”交给你写的动画对象,由一个转场上下文UIViewControllerContextTransitioning提供你所需的一切。
这个上下文里有几个关键东西,我习惯用舞台来理解:
containerView:整个转场发生时的临时容器,相当于舞台。所有动画期间要显示的东西,比如新控制器视图、旧控制器视图、背景遮罩,都要放在这里面。view(forKey: .to):要出场的新视图,相当于即将上场的演员。view(forKey: .from):要退场的旧视图,相当于演完准备下台的演员。completeTransition(true/false):转场结束哨。你告诉系统“演完了”,系统才真正结束这次转场,更新控制器层级。
系统做的事其实很克制:它搭好舞台,把两个演员领到你面前,然后喊action,剩下的动作全由你编排。这就是为什么自定义转场动画的灵活度极高——你可以让新视图从任何位置、任何大小、任何透明度进入,只要在动画闭包里写清楚就行。同样,这也是为什么很多人一上来就迷糊——因为以前系统的present动画是“自动的”,现在突然要你操控舞台上的每一样东西,很多人不知道该从哪里下手。
理解了这套机制,后面所有代码都是围绕同一个核心:你的动画对象负责在containerView里,将toView从某种初始状态动画到最终状态,最后调用completeTransition。
2. 手写一个Present转场:从底部弹出的详情页卡片
先来一个最常见的场景:列表页点了一个卡片,需要从屏幕底部弹出一个详情页,带一点弹簧效果。不用系统默认的present动画,而是自己控制它的入场和退场。
2.1 先写“入场动画”:PresentAnimator
UIViewControllerAnimatedTransitioning协议要求实现两个方法:transitionDuration(using:)返回动画时长,animateTransition(using:)里写具体的动画过程。
final class PresentAnimator: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.45 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let toView = transitionContext.view(forKey: .to) else { return } let containerView = transitionContext.containerView containerView.addSubview(toView) let finalFrame = transitionContext.finalFrame(for: transitionContext.viewController(forKey: .to)!) toView.frame = finalFrame // 初始状态:往下偏移一个屏幕高度,透明度0 toView.transform = CGAffineTransform(translationX: 0, y: finalFrame.height) toView.alpha = 0 let duration = transitionDuration(using: transitionContext) UIView.animate( withDuration: duration, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 0.6, options: [.curveEaseOut] ) { toView.transform = .identity toView.alpha = 1 } completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } }这里有几个地方特别容易忽略。第一,toView必须手动加到containerView上,系统不会替你加,至少在一些present场景下不会。第二,要用finalFrame来确定动画的终点坐标,不要想当然写containerView.bounds。第三,动画结束后的completion里必须调用completeTransition,否则系统会认为转场还没结束,界面会卡死。
2.2 再写“退场动画”:DismissAnimator
入场和退场其实是两个独立对象。很多人偷懒想用一个对象通过状态区分,但协议本身没有区分参数,所以分开写更清晰。
final class DismissAnimator: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.35 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromView = transitionContext.view(forKey: .from) else { return } let containerView = transitionContext.containerView let duration = transitionDuration(using: transitionContext) UIView.animate( withDuration: duration, delay: 0, options: [.curveEaseIn] ) { fromView.transform = CGAffineTransform(translationX: 0, y: containerView.bounds.height) fromView.alpha = 0 } completion: { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } }退场时要注意:presented的详情页是fromView,它的最终落点是移出屏幕。这里我顺手做了淡出效果,让动画更柔和一些。如果你只想让它滑下去,去掉透明度变化也可以。
2.3 别忘了用transitioningDelegate把表演者请上台
有了两个动画对象还不够,系统要找到它们才行。UIViewControllerTransitioningDelegate就是那个牵线的人,它负责回答系统“presenting时用哪个动画对象”“dismiss时用哪个动画对象”。
final class DetailTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate { func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { return PresentAnimator() } func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { return DismissAnimator() } }使用的时候:
let detailVC = DetailViewController() let delegate = DetailTransitionDelegate() // 注意:delegate必须被强引用持有 detailVC.transitioningDelegate = delegate detailVC.modalPresentationStyle = .custom present(detailVC, animated: true)这里有个特别经典的坑:transitioningDelegate是一个weak属性。如果你像下面这样写,delegate会在函数结束后立刻释放,然后动画完全不生效或者crash:
detailVC.transitioningDelegate = DetailTransitionDelegate() // 不行!代理马上被释放正确做法是把delegate对象存成属性,保证它的生命周期能撑过整个转场过程。我在项目里通常会让调用方持有它,或者用一个单例协调器统一管理。
另外,modalPresentationStyle必须设置为.custom,不要用系统默认的.fullScreen或iOS 13之后的.pageSheet。.custom模式下系统才允许你完全接管presented视图的展示方式,并且不会在present过程中移除presenting控制器的视图,这对做缩放、平移类动画非常重要。
3. Push/Pop转场:返回手势消失和黑屏,两个高频事故
导航控制器的转场和present不太一样,但思路类似,只是入口换成了UINavigationControllerDelegate。
3.1 自定义导航转场为什么会弄丢左滑返回
先还原一个最典型的翻车现场:你按网上的教程写了push和pop的自定义动画,运行起来效果很炫,但用户第一次尝试左滑返回时发现手势没反应了。原因是:只要你通过UINavigationControllerDelegate返回了自定义动画对象,系统就认为转场完全交给你接管,原本由系统自己承载的交互式pop手势也跟着“退位”了。
这不是bug,是转场动画的必然结果。想要保留左滑返回,你只能自己把手势接回来,并用UIPercentDrivenInteractiveTransition做百分比驱动。这一套我放在第4章讲,这里先解决push/pop动画本身的实现和方向区分。
3.2 用UINavigationControllerDelegate接住push和pop
导航转场的动画对象通常一个类就够了,用一个isPush标记区分两个方向:
final class NavAnimator: NSObject, UIViewControllerAnimatedTransitioning { private let isPush: Bool init(isPush: Bool) { self.isPush = isPush } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.38 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { let containerView = transitionContext.containerView guard let fromView = transitionContext.view(forKey: .from), let toView = transitionContext.view(forKey: .to) else { return } let finalFrame = transitionContext.finalFrame(for: transitionContext.viewController(forKey: .to)!) if isPush { // push:新页面从右侧滑入,旧页面稍微往左缩一点点 containerView.addSubview(toView) toView.frame = finalFrame toView.transform = CGAffineTransform(translationX: containerView.bounds.width, y: 0) UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseOut]) { toView.transform = .identity fromView.transform = CGAffineTransform(scaleX: 0.96, y: 0.96) } completion: { _ in fromView.transform = .identity transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } else { // pop:当前页面从中间向右滑出,下面的页面从缩放状态恢复正常 containerView.insertSubview(toView, belowSubview: fromView) toView.frame = finalFrame toView.transform = CGAffineTransform(scaleX: 0.96, y: 0.96) UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseOut]) { fromView.transform = CGAffineTransform(translationX: containerView.bounds.width, y: 0) toView.transform = .identity } completion: { _ in fromView.transform = .identity transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } } }代理类负责根据导航操作生成对应的动画对象:
final class NavTransitionDelegate: NSObject, UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { switch operation { case .push: return NavAnimator(isPush: true) case .pop: return NavAnimator(isPush: false) default: return nil } } }然后把navigationController.delegate设置成这个代理对象。它在导航控制器生命周期里只会被设置一次,通常放在导航控制器的初始化阶段。
3.3 页面间缩放联动时,fromView的transform一定要复位
3.2的push动画里有个细节:我让fromView进行了微小缩放,形成一种“当前页面退后一步,新页面压上来”的层级感。但这个transform必须在动画结束后复位为.identity,否则下一次转场或布局时会看到旧页面卡在缩放状态。更危险的是,如果动画中途被系统打断(比如来了电话),completion里不处理复位,整个界面就可能错乱。
我的习惯是:所有涉及transform的动画,在completion里统一先复位,再调用completeTransition。顺序不能反,因为completeTransition之后系统会进行视图层级整理,如果transform还留在非identity状态,系统计算出来的frame会变得很诡异。
4. 把动画做得“跟手”:交互式转场与PresentationController
自定义转场如果只是固定动画,体验顶多算“换了个样式”。真正的高级感来自交互式转场:用户的手指可以直接控制动画进度,拖到一半松手,继续或者回退。这就是App Store卡片点击进详情页那种丝滑感的来源。
4.1 手势驱动:UIPercentDrivenInteractiveTransition
系统为我们准备好了UIPercentDrivenInteractiveTransition,它是一个现成的百分比驱动控制器,和自定义动画对象搭配使用。简单来说,当用户拖动手势时,它会根据你给的进度,实时执行自定义动画的一部分;松手后,要么继续到完成,要么回滚到开始。
以3.2的导航pop为例,加一个手势控制器:
final class PopInteractionController: UIPercentDrivenInteractiveTransition { var isInteractive = false private weak var navigationController: UINavigationController? func wire(to navigationController: UINavigationController) { self.navigationController = navigationController let pan = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePan(_:))) pan.edges = .left navigationController.view.addGestureRecognizer(pan) } @objc private func handlePan(_ gesture: UIScreenEdgePanGestureRecognizer) { guard let view = gesture.view else { return } let progress = min(max(gesture.translation(in: view).x / view.bounds.width, 0), 1) switch gesture.state { case .began: isInteractive = true navigationController?.popViewController(animated: true) case .changed: update(progress) case .cancelled, .ended: isInteractive = false let velocity = gesture.velocity(in: view).x if velocity > 300 || progress > 0.5 { finish() } else { cancel() } default: break } } }然后在导航代理里增加一个方法:
func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return popInteractionController.isInteractive ? popInteractionController : nil }这段逻辑的关键在于:手势刚开始时发起pop,但通过isInteractive = true告诉系统这是交互式转场,系统就不会立刻跑完整动画,而是等我们的百分比驱动指令。手势移动时update(progress)实时调整动画位置;松手时判断是finish还是cancel,交互动画对象会自动播放剩余部分或回滚。
这里我要提醒一个容易让人抓狂的问题:update的progress一定要做0到1的钳制。比如用户快速右滑再快速左滑,translation可能是负数,此时如果不处理,系统会收到异常进度,动画状态直接乱掉。
4.2 用UIPresentationController控制presented视图的尺寸和背景
自定义present转场里,还有一个很常用的组件:UIPresentationController。它负责presented视图在屏幕中的最终位置、尺寸,以及背景遮罩。有了它,你才能实现“卡片式弹窗”“菜单式下拉”这些非全屏效果。
举个例子,让之前那个详情页只占屏幕高度的60%,并且带一个可点击的黑色半透明背景:
final class CardPresentationController: UIPresentationController { private var dimmingView: UIView! override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) { super.init(presentedViewController: presentedViewController, presenting: presentingViewController) dimmingView = UIView() dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.45) dimmingView.alpha = 0 } override func presentationTransitionWillBegin() { guard let containerView = containerView else { return } dimmingView.frame = containerView.bounds containerView.addSubview(dimmingView) presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in self.dimmingView.alpha = 1 }, completion: nil) } override func dismissalTransitionWillBegin() { presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in self.dimmingView.alpha = 0 }, completion: nil) } override var frameOfPresentedViewInContainerView: CGRect { guard let containerView = containerView else { return .zero } let height = containerView.bounds.height * 0.6 return CGRect(x: 0, y: containerView.bounds.height - height, width: containerView.bounds.width, height: height) } override func containerViewWillLayoutSubviews() { presentedView?.frame = frameOfPresentedViewInContainerView } }光有这个还不够,还需要在UIViewControllerTransitioningDelegate里增加一个返回PresentationController的方法:
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { return CardPresentationController(presentedViewController: presented, presenting: presenting) }加上这个之后,你再配合第2章的PresentAnimator,就能做出一个既有入场动画、又只占部分屏幕、还有背景遮罩的完整弹窗。PresentationController本质上控制的是“presented视图的最终栖息地”,而动画对象控制的是“从哪来、怎么飞过去”,两者分工明确,各管一段。
5. 真机跑过之后,我整理了一份转场动画的避坑清单
下面这些坑,几乎每一个都是我在实际项目中遇到过的。有求助无门的,有翻了好几版系统才明白的,也有纯属自己粗心折腾了半天才发现原因的。
5.1 十个高频问题速查
| 问题 | 根因 | 解决方法 |
|---|---|---|
| 转场动画完全不执行 | transitioningDelegate被释放 | 用强引用属性持有代理对象 |
| present后黑屏 | modalPresentationStyle用了.fullScreen且没把toView加入containerView | 用.custom模式,并在animateTransition里addSubview(toView) |
| 转场结束后页面卡死 | 没有调用completeTransition | 在动画completion里必须调用 |
| iOS 13之后present的尺寸不对 | 默认样式变成了pageSheet | 显式设置modalPresentationStyle = .custom |
| push自定义后左滑返回失效 | 系统把交互式pop一并交给了自定义代理 | 自己实现手势+UIPercentDrivenInteractiveTransition |
| 动画取消后视图位置错乱 | transform没有复位 | 在completion里先恢复identity再completeTransition |
| 手势一拖动就跳到100% | progress计算没有钳制0到1 | 用min(max(...))包裹 |
| 手势快速反方向拖动就闪退 | 进度出现负数 | 先钳制再update |
| 卡片弹窗背景点击没反应 | dimmingView塞错了层级 | 放在presentationTransitionWillBegin里加到containerView |
| fromView动画过程中一闪而过的黑边 | toView初始frame没有设置 | 动画开始前设置toView.frame = finalFrame |
这十条不是全部,但覆盖了新手最容易踩的80%的坑。尤其是第一条和第三条,我已经数不清帮多少人排查过了,很多看起来是“动画没效果”的问题,最后都是代理释放或者没调completeTransition导致的。
5.2 关于动画时长、曲线和手感的三点实测心得
最后分享几个调参数的体会。
第一,动画时长宁短勿长。系统present/push的默认时长大概在0.35到0.5秒之间。如果你做的是带弹簧效果的自定义转场,0.45秒是上限;如果只是普通的线性滑入,0.3秒就够了。动画做得太长,用户会产生“这个App卡卡”的直觉,即使它其实很流畅。
第二,弹簧动画的damping和velocity不要乱给。usingSpringWithDamping的值在0.7到0.85之间,表现是“有弹性但不夸张”;initialSpringVelocity给0.5到1.0之间,能做出“带一点冲力但不会过头”的感觉。低于0.5会显得拖沓,高于1.0容易回弹过头,尤其在列表快速连点时会让用户眼花。
第三,转场动画期间如果会触发大量UI刷新,比如切夜间模式、布局重算、加载图片,最好把transitionContext.view(forKey:)对应的视图做一次截图缓存,或者直接降低动画过程中的阴影和圆角使用。圆角、阴影、maskLayer这些属性在动画过程中会触发离屏渲染,低端机上表现尤其明显,动画帧率一下就掉下来。实测中,把toView.layer.shadowPath提前指定好,或者用shouldRasterize做全局栅格化,能明显改善动画卡顿。
还有一个很个人的建议:不要一上来就找第三方转场动画库。VCTransitionsLibrary这类库确实好用,但如果你没有自己手写过一遍完整的Present、Push、交互式驱动,出了问题你是完全看不懂它的内部逻辑的。写一次裸的转场动画,把UIViewControllerContextTransitioning里每个东西都打一遍日志,你后面再看任何高级转场方案都会觉得很清晰。转场动画的所有复杂性都集中在一个点上:你能否清晰地规划好两个控制器视图在一小段时间内的“同台演出”。把这个想明白,剩下的只是动画代码。
本文还有配套的精品资源,点击获取