Enhance your UI animations and transitions
System Frameworks 进阶 20m

升级你的 UI 动画与转场效果

Enhance your UI animations and transitions

2024年6月10日

在 Apple 官方观看视频

一句话判断

iOS 18 的 Zoom Transition 让”从列表点击一个卡片、放大变成详情页”变成了系统级行为——而且 SwiftUI、UIKit、AppKit 三端共享同一套动画基础设施,跨框架混搭也不再是噩梦。

这场 Session 讲了什么

这场 Session 的主线是 iOS 18 新引入的 Zoom Transition,但真正有深度的是它背后的动画基础设施升级。

Zoom Transition 表面上是”点击一个 cell,它放大成全屏详情页”。但它的交互细节做得很到位:你可以随时抓取拖拽,从开始就能交互,甚至在转场进行到一半时也能反向操作。不只是 Navigation Push,fullScreenCover 和 sheet 也支持。

更有意思的是底层的动画打通。SwiftUI 的 Animation 类型现在可以直接驱动 UIView 和 NSView 的属性动画,不再需要写 UIView.animate 然后手动同步状态。这意味着在一个混合了 SwiftUI 和 UIKit 视图的界面里,你可以用同一套动画 API 统一管理所有视图的动效。

Session 还详细讲解了 Zoom Transition 在 UIKit 中的生命周期回调时序,特别是”Push 被中断后转为 Pop”这种复杂场景下 viewWillAppear 等回调的触发时机——这对现有代码的兼容性至关重要。

值得深挖的点

Zoom Transition:不只是视觉效果,是交互范式

Zoom Transition 跟普通的 Push 动画最大的区别是”连续可交互”。普通的 Push 动画一旦开始就跑完,用户要么等要么取消。Zoom Transition 在整个过程中都是可交互的——你可以在动画进行到 30% 的时候往回拖,它会流畅地回到原位。

这种交互模式对用户体验的提升是质的。用户不再害怕”点了之后进去一个陌生的页面出不来”,因为他们知道随时可以拖回来。这降低了探索的心理门槛。

SwiftUI 侧的接入方式是给 NavigationLink 和目标视图加上 navigationTransitionStyle(.zoom) modifier,配合 navigationTransitionSource 连接源视图和目标视图。UIKit 侧用 UIViewController.zoomTransition 配置,在 closure 里返回源视图。

Session 特别强调了 closure 里要捕获稳定标识符(比如数据模型对象),而不是直接捕获视图。因为 CollectionView 里 cell 会被复用,如果你捕获了 cell 本身,pop 回来时那个 cell 可能已经在显示别的内容了。

UIKit 的生命周期回调时序:Push 永远不会被取消

这是 Session 中最硬核的部分。在 Zoom Transition 下,用户可以在 Push 进行中触发 Pop。系统如何处理这种”中断”?

答案是:Push 永远不会被取消。如果用户在 Push 进行中触发 Pop,系统会立即完成 Push(viewController 直接到达 Appeared 状态),然后在同一个 runloop 里启动 Pop。从 viewController 的角度看,它完整走过了 Disappeared → Appearing → Appeared → Disappearing 的回调链。

这个设计保证了现有的生命周期代码不会因为”中断”而跳过某个回调。但也意味着你的代码要准备好在任何时刻接收新的转场——不要在 viewWillAppear 里做”只在第一次显示时执行”的假设,因为 Push 中断后 Pop 取消会让它再次经过 Appearing 状态。

代码片段

SwiftUI Zoom Transition

// 源视图:列表中的卡片
NavigationLink(value: bracelet) {
    BraceletPreview(bracelet: bracelet)
}
.navigationTransitionSource(id: bracelet.id, in: namespace)

// 目标视图:详情页
.braceletEditor(bracelet: bracelet)
    .navigationTransitionStyle(.zoom(
        sourceID: bracelet.id,
        in: namespace
    ))

场景:从卡片列表点击进入详情页,卡片放大成详情页。坑:namespace 必须是同一个 @Namespace 变量,而且 sourceID 要在 Push 和 Pop 时都能正确解析到视图。

UIKit Zoom Transition

// 在 push 时配置 zoom
let editorVC = BraceletEditorViewController(bracelet: bracelet)
editorVC.zoomTransition = .zoom { context in
    // 用稳定标识符获取源视图,不要直接捕获 cell
    let currentBracelet = context.editor.currentBracelet
    return collectionView.cellForItem(
        at: indexPath(for: currentBracelet)
    )
}
navigationController?.pushViewController(editorVC, animated: true)

场景:UIKit 中 CollectionView 点击 cell 放大进入详情。坑:closure 会在 zoom in 和 zoom out 各执行一次,必须每次都返回当前正确的源视图。

SwiftUI Animation 驱动 UIView

// 用 SwiftUI 动画驱动 UIView 属性变化
UIView.animate(with: SwiftUI.Animation.spring(duration: 0.3)) {
    myUIView.alpha = isSelected ? 1.0 : 0.5
    myUIView.transform = isSelected ? .identity : .init(scaleX: 0.9, y: 0.9)
}

场景:在混合了 SwiftUI 和 UIKit 的视图层中统一动画效果。坑:这个 API 是新的 UIView.animate(with:) 方法,不要跟老的 UIView.animate(withDuration:) 混用。

最佳实践

已有项目迁移:UIKit 项目接入 Zoom Transition 改动不大——在 push 时设置 zoomTransition 属性,提供返回源视图的 closure。注意检查你的 viewWillAppear / viewDidAppear 代码是否有”只在第一次执行”的假设,因为在可中断转场下这些回调可能被多次触发。

SwiftUI 项目更简单:给 NavigationLink 和目标视图加上对应的 modifier 即可。建议先在一个简单的列表→详情场景中试水,验证交互体验后再推广。

新项目起步:从第一天就用 Zoom Transition 替代默认的 Push 动画——它是 iOS 18 的设计语言,系统 App 都在用。如果你的项目是 SwiftUI + UIKit 混合架构,利用新的跨框架动画 API 统一管理动效,不要在两个框架里各搞一套。

还有什么值得关注

  • Zoom Transition 支持 fullScreenCoversheet,不只是 Navigation Push。
  • UIKit 的新 UIView.animate(with: SwiftUI.Animation) 方法意味着你可以用 SwiftUI 的 spring 配置来驱动 UIView 动画,参数更直观。
  • Session 建议尽量减少转场过程中的临时状态——状态越少,处理中断时的清理工作越简单。
WWDC 2024