You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

NavigationStack下@Namespace与MatchedGeometryEffect真机动画卡顿问题

真机上NavigationStack搭配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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 02:29:57