Height resetting to 0 first when using a .matchedGeometryEffect() transition in SwiftUI

69 views Asked by At

I'm struggling to understand why .matchedGeometryEffect() is behaving this way:

I'm trying to animate a tab's height to different heights, based on its content. To do so I'm using .matchedGeometryEffect() on the tab background as follow:

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)
                            }
                    }
                }
        }
    }
}

I can see the animation on the screen but it looks like instead of animating from the origin view to the destination view directly, it reset the height to 0 first, causing the animation to break.

Would anybody understand why it's behaving this way by any chance? Am I missing something in the implementation?

Here's what it looks like right now. I slowed down the animation on Xcode simulator so you can see it more clearly:

Link to Xcode simulator demo

I'm trying to display some tabs based on the selectedTab value in the ContentView. These tabs (DashboardTabView, PaymentTabView, etc) all leverage the same TabViewContainer that contains a Color.gray0 view that uses .matchedGeometryEffect(id: "tab_background", in: namespace).

My expectation is that these tab will transition height seamlessly from one tab to another, and in fact when I hard code the values for the height the animation is working propertly.

The problem arises when I use GeometryReader to retrieve the height of the content() child view. Then the animation breaks.


UPDATE: 01/10

I tried to pass a selectedTab environment key down the subviews to set isSource for the selected tab but it stopped animating all together. Here's my new implementation:

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)
                            }
                    }
                }
        }
    }
}

In the example above, I'm just showing the DashboardTabView but all of the other tab views also have the same implementation and use the TabViewContainer component

0

There are 0 answers