NavigationStack下@Namespace与MatchedGeometryEffect真机动画卡顿问题
问题描述
我遇到一个仅在真机上出现的异常:使用NavigationStack结合@Namespace和MatchedGeometryEffect时,从第一页跳转到包含该动画的第二页后,头部的下划线切换动画严重卡顿(帧率极低),但模拟器和预览里完全正常;如果不用NavigationStack直接展示第二页,动画就很流畅。
相关代码如下:
FirstView
struct NameSpaceTest2Navigation: View { @State private var nameSpaceTest2Path: [String] = [] var body: some View { NavigationStack(path: $nameSpaceTest2Path) { Button(action: { nameSpaceTest2Path.append("nameSpaceTest2") }, label: { Text("Button") }) .navigationDestination(for: String.self) { path in NameSpaceTest2() } } } }
SecondView
struct NameSpaceTest2: View { @State private var selection: Int = 0 var body: some View { TabView(selection: $selection) { ForEach(0..<5, id: \.self) { _ in Color.white } } .tabViewStyle(.page(indexDisplayMode: .never)) .overlay(alignment: .top) { NameSpaceTest2Header(selection: $selection) } } }
ThirdView
struct NameSpaceTest2Header: View { @Binding var selection: Int @Namespace var sectionUnderline var body: some View { ScrollViewReader { scrollReader in //START: ScrollViewReader ScrollView(.horizontal, showsIndicators: false) { //START: ScrollView HStack(spacing: 0) { //START: HStack ForEach(0..<5, id: \.self) { index in VStack { //START: VStack Text("Name: \(index)") .foregroundStyle(Color.green) .padding(.horizontal, 24) .padding(.vertical, 15) .overlay(alignment: .bottom) { if selection == index { Rectangle() .fill(Color.red) .frame(height: 5) .matchedGeometryEffect(id: "sectionUnderline", in: sectionUnderline, properties: .frame) .transition(.scale(scale: 1)) } } .animation(.smooth, value: selection) } //END: VStack .onTapGesture { withAnimation(.smooth) { scrollReader.scrollTo(index) selection = index } } .tag(index) } } //END: HStack } //END: ScrollView .onChange(of: selection, perform: { value in withAnimation(.smooth) { scrollReader.scrollTo(value, anchor: .center) } }) } //END: ScrollViewReader } }
我已经试过把selection = index包在DispatchQueue.main.async里、换不同的动画类型,都没用;用Instruments测了也没发现CPU或GPU高负载的情况。想知道这个问题的根源是什么,怎么才能让动画恢复流畅?
问题根源分析
这个卡顿是NavigationStack的视图生命周期与MatchedGeometryEffect的动画时机冲突导致的:
- NavigationStack在push新视图时,会对整个目标视图做过渡动画,这个过程中系统会对视图层级做大量布局计算和渲染优化。
- 而你的
MatchedGeometryEffect在视图初始化时(也就是push完成的瞬间)就绑定了selection的初始值,此时NavigationStack的过渡动画还没完全结束,两个动画的布局更新互相干扰,真机的硬件渲染管线无法及时处理这种叠加的布局变更,最终导致帧率暴跌。 - 模拟器因为基于Mac的硬件加速,性能冗余大,所以能扛住这种冲突,不会表现出卡顿。
解决方法
方法1:延迟MatchedGeometryEffect的启用时机
等NavigationStack的push动画完全结束后,再激活MatchedGeometryEffect的动画逻辑,避免两个动画叠加:
修改NameSpaceTest2Header,添加状态变量控制下划线显示时机:
struct NameSpaceTest2Header: View { @Binding var selection: Int @Namespace var sectionUnderline // 新增:控制是否启用动画 @State private var isAnimationEnabled = false var body: some View { ScrollViewReader { scrollReader in ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { ForEach(0..<5, id: \.self) { index in VStack { Text("Name: \(index)") .foregroundStyle(Color.green) .padding(.horizontal, 24) .padding(.vertical, 15) .overlay(alignment: .bottom) { if selection == index && isAnimationEnabled { // 添加启用判断 Rectangle() .fill(Color.red) .frame(height: 5) .matchedGeometryEffect(id: "sectionUnderline", in: sectionUnderline, properties: .frame) } } .animation(.smooth, value: selection) } .onTapGesture { withAnimation(.smooth) { scrollReader.scrollTo(index) selection = index } } .tag(index) } } } .onChange(of: selection, perform: { value in withAnimation(.smooth) { scrollReader.scrollTo(value, anchor: .center) } }) .onAppear { // 延迟0.3秒,等NavigationStack的push动画结束 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { isAnimationEnabled = true } } } } }
方法2:替换MatchedGeometryEffect为普通动画
如果不需要跨视图的几何匹配动画,只是实现下划线滑动效果,完全可以用普通offset动画替代MatchedGeometryEffect,避免和NavigationStack的冲突:
修改NameSpaceTest2Header的实现:
struct NameSpaceTest2Header: View { @Binding var selection: Int // 移除@Namespace // @Namespace var sectionUnderline var body: some View { ScrollViewReader { scrollReader in ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { ForEach(0..<5, id: \.self) { index in Text("Name: \(index)") .foregroundStyle(Color.green) .padding(.horizontal, 24) .padding(.vertical, 15) .tag(index) .onTapGesture { withAnimation(.smooth) { scrollReader.scrollTo(index) selection = index } } } } // 把下划线单独放在HStack下面,用offset实现滑动 .overlay(alignment: .bottomLeading) { Rectangle() .fill(Color.red) .frame(width: 80, height: 5) // 宽度和单个Text加padding后的宽度匹配 .offset(x: CGFloat(selection) * 80) // 根据selection计算偏移量 .animation(.smooth, value: selection) } } .onChange(of: selection, perform: { value in withAnimation(.smooth) { scrollReader.scrollTo(value, anchor: .center) } }) } } }
注:这里的宽度80是根据Text("Name: \(index)")加padding后的实际宽度计算的,你可以根据自己的UI调整这个值,或者用GeometryReader动态获取每个选项的宽度。
方法3:禁用NavigationStack的过渡动画
如果不需要NavigationStack的push过渡动画,可以直接关闭它,避免和内部的MatchedGeometryEffect冲突:
修改FirstView的NavigationStack配置:
struct NameSpaceTest2Navigation: View { @State private var nameSpaceTest2Path: [String] = [] var body: some View { NavigationStack(path: $nameSpaceTest2Path) { Button(action: { // 禁用push动画 withAnimation(.none) { nameSpaceTest2Path.append("nameSpaceTest2") } }, label: { Text("Button") }) .navigationDestination(for: String.self) { path in NameSpaceTest2() } } } }
验证效果
这三种方法都能解决真机上的卡顿问题:
- 方法1保留了原有的几何匹配动画逻辑,适合需要该效果的场景;
- 方法2性能最优,避免了MatchedGeometryEffect带来的额外布局计算;
- 方法3最简单,但牺牲了NavigationStack的过渡动画。
内容的提问来源于stack exchange,提问作者Simimi_Dot

