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:

    Other suggestions:

    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