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

SwiftUI中.matchedGeometryEffect切换时高度先重置为0的问题

问题:SwiftUI Tab高度过渡动画断裂及修复分析

我在SwiftUI中尝试基于内容实现Tab的高度过渡动画,使用.matchedGeometryEffect()处理Tab背景,但发现动画并非直接从原视图过渡到目标视图,而是先将高度重置为0,导致动画断裂。硬编码高度值时动画正常,但使用GeometryReader获取子视图content()的高度时,就会出现高度先重置为0的问题。

初始实现代码

struct ContentView: View {

    // MARK: - Properties
    @State private var selectedTab: Tab = .payment
    @Namespace var namespace

    // MARK: - Body
    var body: some View {
        ZStack(alignment: .bottom) {
            // Background color
            Color.gray6
                .ignoresSafeArea()
            
            // Tab
            switch selectedTab {
            case .dashboard:
                DashboardTabView(namespace: namespace)
                
            case .transactions:
                TransactionsTabView(namespace: namespace)
                
            case .payment:
                PaymentTabView(namespace: namespace)
                
            case .services:
                ServicesTabView(namespace: namespace)
                
            case .settings:
                SettingsTabView(namespace: namespace)
            }

        }
        .safeAreaInset(edge: .bottom, spacing: 0) {
            // Tab bar
            TabBar(selectedTab: $selectedTab)
        }
    }
}

struct DashboardTabView: View {

    // MARK: - Properties
    let namespace: Namespace.ID
    
    // MARK: - Body
    var body: some View {
        TabViewContainer(namespace: namespace) {
            Text("Dashboard Tab")
                .foregroundStyle(.black)
        }
    }
}

struct PaymentTabView: View {

    // MARK: - Properties
    let namespace: Namespace.ID

    // MARK: - Body
    var body: some View {
        TabViewContainer(namespace: namespace) {
            HStack(spacing: 16) {
                CTAButton(title: "Receive", icon: "arrow.down.square") { }

                CTAButton(title: "Pay", icon: "dollarsign.arrow.circlepath", color: Color(hex: "#F1D302")) { }
            }
            .frame(height: 52)
            .padding(.horizontal)
        }
    }
}

struct TabViewContainer<Content: View>: View {

    // MARK: - Init
    init(
        cornerRadii: RectangleCornerRadii = .init(topLeading: 21, bottomLeading: 0, bottomTrailing: 0, topTrailing: 21),
        namespace: Namespace.ID,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.cornerRadii = cornerRadii
        self.namespace = namespace
        self.content = content
    }

    // MARK: - Properties
    let namespace: Namespace.ID
    @State private var childHeight: CGFloat = 0
    @ViewBuilder var content: () -> Content
    private let cornerRadii: RectangleCornerRadii

    // MARK: - Body
    var body: some View {
        ZStack(alignment: .bottom) {
            // Background color
            Color.gray0
                .matchedGeometryEffect(id: "tab_background", in: namespace)
                .frame(height: childHeight)
                .clipShape(UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous))
            
            // Content
            content()
                .padding(.vertical)
                .overlay {
                    GeometryReader { proxy in
                        Color.clear
                            .task(id: proxy.size.height) {
                                childHeight = max(proxy.size.height, 0)
                            }
                    }
                }
        }
    }
}

为解决该问题,我尝试通过环境键传递selectedTab,为选中的Tab设置isSource参数,但动画完全停止了。更新后的实现代码如下:

更新后的尝试代码

struct SelectedTabEnvironmentKey: EnvironmentKey {
    static let defaultValue: Binding<Tab> = .constant(.payment)
}

extension EnvironmentValues {
    var selectedTab: Binding<Tab> {
        get { self[SelectedTabEnvironmentKey.self] }
        set { self[SelectedTabEnvironmentKey.self] = newValue }
    }
}

struct ContentView: View {

    // MARK: - Properties
    @Namespace var namespace
    @State private var selectedTab: Tab = .payment

    // MARK: - Body
    var body: some View {
        ZStack(alignment: .bottom) {
            // Tab
            switch selectedTab {
            case .dashboard:
                DashboardPage(namespace: namespace)
                
            case .transactions:
                TransactionsPage(namespace: namespace)
                
            case .payment:
                PaymentPage(namespace: namespace)
                
            case .services:
                ServicesPage(namespace: namespace)
                
            case .settings:
                SettingsPage(namespace: namespace)
            }

        }
        .environment(\.selectedTab, $selectedTab)
        .safeAreaInset(edge: .bottom, spacing: 0) {
            // Tab bar
            TabBar(selectedTab: $selectedTab)
        }
    }
}

struct DashboardPage: View {
    // MARK: - Properties
    let namespace: Namespace.ID

    // MARK: - Body
    var body: some View {
        Page(namespace: namespace) {
            // Content
            Text("Dashboard page")
                .foregroundStyle(.white)
                .frame(maxHeight: .infinity)
            
        } tabView: {
            DashboardTabView(namespace: namespace)
        }
    }
}

struct Page<Content: View, TabView: TabViewProtocol>: View {

    // MARK: - Init
    init(
        namespace: Namespace.ID,
        @ViewBuilder content: @escaping () -> Content,
        @ViewBuilder tabView: @escaping () -> TabView
    ) {
        self.namespace = namespace
        self.content = content
        self.tabView = tabView
    }

    // MARK: - Properties
    let namespace: Namespace.ID
    @ViewBuilder var content: () -> Content
    @ViewBuilder var tabView: () -> TabView
    @State private var tabViewHeight: CGFloat = 0

    // MARK: - Body
    var body: some View {
        ZStack(alignment: .bottom) {
            // Background color
            Color.gray6
                .ignoresSafeArea()
            
            // Content
            VStack {
                content()
                
                Color.clear
                    .frame(height: tabViewHeight)
            }
            
            // Tab
            tabView()
                .overlay {
                    GeometryReader { proxy in
                        Color.clear
                            .task(id: proxy.size.height) {
                                guard proxy.size.height > 0 else { return }
                                tabViewHeight = max(proxy.size.height, 0)
                            }
                    }
                }
        }
    }
}

struct DashboardTabView: TabViewProtocol {

    // MARK: - Properties
    let namespace: Namespace.ID
    let transactions: [Transaction] = Transaction.section1
    @Environment(\.selectedTab) @Binding var selectedTab: Tab
    
    // MARK: - Body
    var body: some View {
        // Tab binding
        let isActive = Binding(
            get: { self.selectedTab == .dashboard },
            set: { _ in }
        )
        
        // View body
        return TabViewContainer(isActive: isActive, namespace: namespace) {
            VStack {
                // Header
                HStack {
                    Text("Transactions")
                        .font(.system(size: 15, weight: .medium))
                        .foregroundStyle(Color.gray6)
                    
                    Spacer()
                    
                    Text("See all")
                        .underline()
                        .foregroundStyle(Color.gray3)
                        .font(.system(size: 14, weight: .medium))
                }
                .padding(.vertical)
                
                // Transactions
                TransactionListSectionView(day: "Fri", date: "12", transactions: transactions)
            }
            .padding()
        }
    }
}

struct TabViewContainer<Content: View>: View {

    // MARK: - Init
    init(
        cornerRadii: RectangleCornerRadii = .init(topLeading: 21, bottomLeading: 0, bottomTrailing: 0, topTrailing: 21),
        isActive: Binding<Bool>,
        namespace: Namespace.ID,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._isActive = isActive
        self.cornerRadii = cornerRadii
        self.namespace = namespace
        self.content = content
    }

    // MARK: - Properties
    let namespace: Namespace.ID
    @Binding var isActive: Bool
    @State private var childHeight: CGFloat = 0
    @ViewBuilder var content: () -> Content
    private let cornerRadii: RectangleCornerRadii

    // MARK: - Body
    var body: some View {
        ZStack(alignment: .bottom) {
            // Background color
            Color.gray0
                .matchedGeometryEffect(id: "tab_background", in: namespace, isSource: isActive)
                .frame(height: childHeight)
                .clipShape(UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous))
            
            // Content
            content()
                .overlay {
                    GeometryReader { proxy in
                        Color.clear
                            .task(id: proxy.size.height) {
                                guard proxy.size.height > 0 else { return }
                                childHeight = max(proxy.size.height, 0)
                            }
                    }
                }
        }
    }
}

问题原因分析

  1. 非选中Tab视图被销毁,高度状态丢失
    初始实现中使用switch selectedTab,切换时非选中的Tab视图会被完全销毁,新Tab的childHeight初始值为0。matchedGeometryEffect需要同时存在起始和结束视图才能创建平滑过渡,此时找不到原Tab的高度状态,只能从0开始动画,导致断裂。

  2. 异步高度更新打断动画时序
    用task(id: proxy.size.height)异步更新childHeight,动画触发时新Tab的高度还未计算完成,此时childHeight仍为0,动画只能从0开始,无法衔接原Tab的高度。

  3. isSource参数未起到作用
    后续尝试中,非选中Tab的视图同样被销毁,isSource绑定的isActive对未渲染的视图无效,无法建立有效的过渡关联;同时异步高度更新的问题仍未解决,导致动画完全失效。

修复方案

1. 保留所有Tab视图的渲染(隐藏而非销毁)

将switch替换为同时渲染所有Tab视图,通过opacity控制显示状态,确保非选中Tab的高度已被提前计算:

ZStack(alignment: .bottom) {
    Color.gray6.ignoresSafeArea()
    
    DashboardTabView(namespace: namespace)
        .opacity(selectedTab == .dashboard ? 1 : 0)
    TransactionsTabView(namespace: namespace)
        .opacity(selectedTab == .transactions ? 1 : 0)
    PaymentTabView(namespace: namespace)
        .opacity(selectedTab == .payment ? 1 : 0)
    ServicesTabView(namespace: namespace)
        .opacity(selectedTab == .services ? 1 : 0)
    SettingsTabView(namespace: namespace)
        .opacity(selectedTab == .settings ? 1 : 0)
}

2. 同步获取子视图高度

使用PreferenceKey替代异步task,确保动画触发前高度已被同步计算:

// 定义高度偏好键
struct ContentHeightKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

// 修改TabViewContainer中的内容部分
content()
    .padding(.vertical)
    .background(
        GeometryReader { proxy in
            Color.clear
                .preference(key: ContentHeightKey.self, value: proxy.size.height)
        }
    )
    .onPreferenceChange(ContentHeightKey.self) { newHeight in
        childHeight = newHeight
    }

3. 正确配置matchedGeometryEffect的isSource

确保选中的Tab设为isSource: true,非选中Tab设为false,同时所有Tab处于渲染状态:

// 在TabViewContainer中修改
Color.gray0
    .matchedGeometryEffect(
        id: "tab_background",
        in: namespace,
        isSource: isActive // isActive绑定当前Tab是否选中
    )
    .frame(height: childHeight)
    .clipShape(UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous))

内容的提问来源于stack exchange,提问作者jaysack

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.02 17:59:50