Search code examples
animationswiftuipickermatchedgeometryeffect

Animation for Custom Segmented Picker in SwiftUI


I am trying to recreate a segmented picker with customized buttons. My goal is that when switching the tab, the background smoothly transitions from the former active tab to the new active tab. it mostly works, but when I am trying to switch back (eg. from tab3 to tab2 or tab1), the animation is gone/ or does not work. Am I missing something?

struct CustomSegmentedPickerView: View {

private var titles = ["products", "causes", "info"]
private var colors = [Color.primaryAccent, Color.primaryAccent, Color.primaryAccent]
@State private var currentIndex: Int = 0
@Namespace var namespace
@Namespace var namespace2



var body: some View {
    VStack (alignment: .center){
        ZStack {
            HStack {
                ForEach (0 ..< titles.count) {index in
                    Button {
                        withAnimation(.default){
                            self.currentIndex = index
                        }
                    } label: {
                    ZStack {
                        if index == currentIndex {
                            Rectangle()
                                .frame(height: 40)
                                .cornerRadius(770)
                                .foregroundColor(
                                    self.colors[self.currentIndex].opacity(0.3))
                                .matchedGeometryEffect(id: "background", in: namespace)
                            
                        } else {
                                Rectangle()
                                    .frame(height: 40)
                                    .cornerRadius(770)
                                    .matchedGeometryEffect(id: "background2", in: self.namespace2)
                                    .foregroundColor(.clear)
                        }
                            Text(self.titles[index])
                                .foregroundColor(.black)
                        }
                    }
                }
            }
            .padding()
        }
    }
 }
}

I thought maybe I need a new namespace ID, but it does not change anything. Any help is appreciated. Thanks in advance!

BR


Solution

  • EDIT The question was about using .matchedGeometryEffect but the (accepted) answer I provided before did not use that technique. I have now replaced the solution in the answer, to show how .matchedGeometryEffect can be used, as this is really the better approach.


    I tried your example and it actually seemed to be working ok (using an iPhone 14 simulator running iOS 16.4 with Xcode 14.3), except that the labels themselves would flash between selections. However, the following warning/error is being reported in the console:

    Multiple inserted views in matched geometry group Pair(first: "background2", second: SwiftUI.Namespace.ID(id: 84)) have `isSource: true`, results are undefined.
    

    To fix, I would suggest the following changes:

    • Use a single shape to highlight the selection and have it move between the views in the background.
    • Only one namespace is needed.
    • The buttons are used as the source for the .matchedGeometryEffect. They each have a unique id (equal to the index of the button) and are flagged with isSource: true.
    • The shape is flagged with isSource: false, which means it is matched to the source, instead of trying to be the source.
    • The shape uses currentIndex as the id for .matchedGeometryEffect, so when the selection changes it moves to the corresponding new source.

    Other changes:

    • To get a rounded rectangle with fully rounded ends, just use a Capsule.
    • Instead of iterating over the array indices, use Array(titles.enumerated()).
    • If the buttons are just showing the text of the title as the label then they can be created in a simpler way.
    • An .animation modifier can be used instead of withAnimation. It makes the code slightly more compact but otherwise works the same.
    • The surrounding ZStack and VStack were redundant -> removed.
    • The modifier .foregroundColor is deprecated, use .foregroundStyle instead.
    struct CustomSegmentedPickerView: View {
        private var titles = ["products", "causes", "info"]
    //    private var colors = [Color.primaryAccent, Color.primaryAccent, Color.primaryAccent]
        private let colors = [Color.green, Color.blue, Color.red]
        @State private var currentIndex: Int = 0
        @Namespace var namespace
    
        var body: some View {
            HStack {
                ForEach(Array(titles.enumerated()), id: \.offset) { index, title in
                    Button(title) {
                        currentIndex = index
                    }
                    .foregroundStyle(.black)
                    .frame(maxWidth: .infinity, minHeight: 40)
                    .matchedGeometryEffect(id: index, in: namespace, isSource: true)
                }
            }
            .background {
                Capsule()
                    .fill(colors[currentIndex].opacity(0.3))
                    .matchedGeometryEffect(id: currentIndex, in: namespace, isSource: false)
            }
            .animation(.default, value: currentIndex)
            .padding()
        }
    }
    

    Animation