Search code examples
swiftswiftuimatchedgeometryeffectswiftui-matchedgeometryeffect

How to fix Z-stack views reordering while using MatchedGeometryEffect?


I wanted to create a simple custom segmented control component with animation similar to native component. I have seen lots of people using matchedGeometryEffect for this, however when I implemented the component I noticed off behavior during animation: when my background rectangular slides to the right previously selected tab moves behind this view which creates unpleasant ripped motion instead of smooth animation. I suspect this problem is associated with the Z-Stack reordering of views, but I don't know how to fix it please help me.

Here is the code I'm using

import SwiftUI

struct TabSegmentedControlView: View {
    @State private var currentTab = 0
    
    var body: some View {
        VStack {
            TabBarView(currentTab: $currentTab)
        }
    }
}

struct TabBarView: View {
    @Binding var currentTab: Int
    @Namespace var namespace
    
    var tabBarOptions: [String] = ["shield", "house", "hands.clap"]
    
    var body: some View {
        HStack(spacing: 0) {
            ForEach(
                Array(zip(self.tabBarOptions.indices,
                          self.tabBarOptions)),
                id: \.0,
                content: { id, name in
                    TabBarTabView(
                        currentTab: $currentTab,
                        namespace: namespace.self,
                        icon: name,
                        title: name,
                        tab: id
                    )
                }
            )
        }
        .padding(.all, 2)
        .background(.gray.opacity(0.2))
        .cornerRadius(16)
        .padding(.horizontal, 16)
    }
}

struct TabBarTabView: View {
    @Binding var currentTab: Int
    let namespace: Namespace.ID
    
    let icon: String
    let title: String
    let tab: Int
    
    var body: some View {
        Button(action: {
            self.currentTab = tab
        }) {
            ZStack {
                if tab == currentTab {
                    Color.white
                        .frame(height: 60)
                        .frame(maxWidth: .infinity)
                        .cornerRadius(14)
                        .shadow(color: .black.opacity(0.04), radius: 0.5, x: 0, y: 3)
                        .shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 3)
                        .transition(.offset())
                        .matchedGeometryEffect(
                            id: "slidingRect",
                            in: namespace,
                            properties: .frame
                        )
                }
                VStack(spacing: 4) {
                    Image(systemName: icon)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 24, height: 24)
                    Text(title)
                }
                .padding()
                .frame(maxWidth: .infinity)
                .font(Font.body.weight(.medium))
                .foregroundColor(tab == currentTab ? .black : .gray)
                .transition(.opacity)
                .cornerRadius(14)
                .frame(height: 60)
            }
            .animation(.easeInOut, value: self.currentTab)
        }
    }
}

#Preview {
    TabSegmentedControlView()
}

preview: tap here


Solution

  • The way you curently have it, each button has its own white background. You are using .matchedGeometryEffect to animate the change from one button background to another, but this is not working seamlessly.

    I would suggest, a better way to implement the moving background is to have just one shape which moves between the buttons. The main changes for this are as follows:

    • Remove the backgrounds from the individual buttons and add the shape to the background of the HStack instead.

    • The buttons should be used as the source for the .matchedGeometryEffect, so each button should have a unique id.

    • The background should have isSource: false and use the id of the selected tab. This way, the background is matched to the selected button.

    • Move the .animation modifier from TabBarTabView to the HStack.

    Other suggestions:

    • There is no need to apply any sizing to the background shape, because the size comes from the .matchedGeometryEffect.

    • There is also no need for any .transition modifiers, because no views are appearing or disappearing.

    • The modifier .foregroundColor is deprecated, use .foregroundStyle instead.

    • The modifier .cornerRadius is also deprecated. You could use .clipShape with a RoundedRectangle instead, or just put a RoundedRectangle in the background and fill it with the required color.

    • The modifier .scaledToFit() works the same as .aspectRatio(contentMode: .fit), but is perhaps simpler.

    • There is no need to qualify variables with self.

    • The array for the ForEach could be built by applying .enumerated() to the source array, instead of zipping the contents with the indices.

    • Use trailing closures where possible.

    Here is the fully updated example:

    struct TabBarView: View {
        @Binding var currentTab: Int
        @Namespace var namespace
    
        var tabBarOptions: [String] = ["shield", "house", "hands.clap"]
    
        var body: some View {
            HStack(spacing: 0) {
                ForEach(Array(tabBarOptions.enumerated()), id: \.offset) { id, name in
                    TabBarTabView(
                        currentTab: $currentTab,
                        namespace: namespace,
                        icon: name,
                        title: name,
                        tab: id
                    )
                }
            }
            .background {
                RoundedRectangle(cornerRadius: 14)
                    .fill(.white)
                    .shadow(color: .black.opacity(0.04), radius: 0.5, x: 0, y: 3)
                    .shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 3)
                    .matchedGeometryEffect(
                        id: currentTab,
                        in: namespace,
                        isSource: false
                    )
            }
            .padding(2)
            .background {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.gray.opacity(0.2))
            }
            .padding(.horizontal, 16)
            .animation(.easeInOut, value: currentTab)
        }
    }
    
    struct TabBarTabView: View {
        @Binding var currentTab: Int
        let namespace: Namespace.ID
        let icon: String
        let title: String
        let tab: Int
    
        var body: some View {
            Button {
                currentTab = tab
            } label: {
                VStack(spacing: 4) {
                    Image(systemName: icon)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 24, height: 24)
                    Text(title)
                }
                .padding()
                .frame(maxWidth: .infinity)
                .font(.body.weight(.medium))
                .foregroundStyle(tab == currentTab ? .black : .gray)
                .frame(height: 60)
            }
            .matchedGeometryEffect(
                id: tab,
                in: namespace,
                isSource: true
            )
        }
    }
    

    Animation