使用navigationTransition(.zoom(sourceID:in:))在 SwiftUI 中实现 Hero 式放大过渡
SwiftUI iOS 17 带来了新的导航过渡系统。本文将带你学习如何使用 navigationTransition(.zoom(sourceID:in:)) 实现类似 Hero 动画的平滑放大效果。
✨ 简介
在 UIKit 时代,想要实现一个"列表 → 详情页"的放大过渡动画,往往需要复杂的自定义转场或者第三方库 Hero。而从 iOS 17 开始,SwiftUI 提供了新的导航过渡 API,使得这一切都能以极少的代码实现。
navigationTransition(.zoom(sourceID:in:)) 是 Apple 新增的 API,它允许我们在两个导航页面间创建共享元素(Shared Element)动画。源视图与目标视图使用相同的 sourceID,系统就能自动识别并生成缩放过渡。
🧩 实现思路
-
定义一个 @Namespace 来管理共享动画空间。
-
在源视图(比如卡片)与目标视图(详情页)上使用相同的 id。
-
通过 .navigationTransition(.zoom(sourceID:in:)) 声明使用 zoom 过渡。
系统会自动在两个页面间平滑地缩放、移动这两个匹配的视图,形成 Hero 式的过渡体验。
💻 完整示例代码
less
import SwiftUI
struct ZoomHeroExample: View {
@Namespace private var ns
@State private var selected: Item? = nil
let items: [Item] = [
Item(id: "1", color: .pink, title: "粉红"),
Item(id: "2", color: .blue, title: "湛蓝"),
Item(id: "3", color: .orange, title: "橙色")
]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 120))], spacing: 12) {
ForEach(items) { item in
RoundedRectangle(cornerRadius: 12)
.fill(item.color)
.frame(height: 140)
.overlay(Text(item.title).foregroundColor(.white).bold())
.matchedTransitionSource(id: "card-" + item.id, in: ns)
.onTapGesture { selected = item }
}
}
.padding()
}
.navigationDestination(item: $selected) { item in
// 添加 zoom 过渡
DetailView(item: item, namespace: ns)
.navigationTransition(.zoom(sourceID: "card-" + item.id, in: ns))
}
.navigationTitle("颜色卡片")
}
}
}
struct DetailView: View {
let item: Item
let namespace: Namespace.ID
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 24)
.fill(item.color)
.frame(height: 420)
.padding()
Text(item.title)
.font(.largeTitle)
.bold()
Spacer()
}
.navigationBarTitleDisplayMode(.inline)
}
}
struct Item: Identifiable, Hashable {
let id: String
let color: Color
let title: String
}
#Preview {
ZoomHeroExample()
}
🎬 动画效果说明
当用户点击某个卡片:
-
源视图与目标视图通过相同的 sourceID 匹配。
-
系统自动计算两者在坐标空间中的差异。
-
SwiftUI 执行一个 .zoom 类型的放大过渡动画,使卡片平滑地扩展到详情页。
这与 Hero 库的核心概念完全一致,但现在由 SwiftUI 原生支持。
⚙️ 常见问题
❓ 为什么动画没生效?
-
确认源视图和目标视图的 id 一致。
-
确保使用的是 NavigationStack 而不是旧的 NavigationView。
-
确认系统版本 ≥ iOS 17。
⚠️ 视图闪烁或跳动?
- 避免动态尺寸变化过大的布局。
- 给匹配元素一个固定的 .frame() 可增强过渡的稳定性。
🔍 与matchedGeometryEffect的对比
| 特性 | matchedGeometryEffect | navigationTransition(.zoom) |
|---|---|---|
| 匹配方式 | Namespace + ID | sourceID + Namespace |
| 场景 | 任意动画、自定义匹配 | 导航过渡专用 |
| 代码复杂度 | 较高 | 极简 |
| 稳定性 | 手动控制 | 系统托管 |
简言之:在导航场景下优先使用 navigationTransition,其它复杂动画仍可用 matchedGeometryEffect。
💡 延伸思考
如果你想自定义更多转场样式,比如滑动、淡入淡出,可以尝试:
less
.navigationTransition(.slide(sourceID: "card-" + $0.id, in: ns))
或:
less
.navigationTransition(.zoom(sourceID: "card-" + $0.id, in: ns).combined(with: .opacity))
SwiftUI 让多个过渡组合成为可能,且依旧保持声明式风格。
🧭 总结
通过 navigationTransition(.zoom(sourceID:in:)),我们可以在 SwiftUI 中轻松实现 Hero 式放大动画。它不仅简化了过渡代码,还 seamlessly 与 NavigationStack 集成。
一句话总结:
从此以后,Hero 动画在 SwiftUI 中,不再需要 Hero。
参考链接: