SwiftUI中.matchedGeometryEffect切换时高度先重置为0的问题
我在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) } } } } } }
问题原因分析
非选中Tab视图被销毁,高度状态丢失
初始实现中使用switch selectedTab,切换时非选中的Tab视图会被完全销毁,新Tab的childHeight初始值为0。matchedGeometryEffect需要同时存在起始和结束视图才能创建平滑过渡,此时找不到原Tab的高度状态,只能从0开始动画,导致断裂。异步高度更新打断动画时序
用task(id: proxy.size.height)异步更新childHeight,动画触发时新Tab的高度还未计算完成,此时childHeight仍为0,动画只能从0开始,无法衔接原Tab的高度。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

