animationswiftui

Putting views within a List prevents matchedGeometryEffect from animating matched geometry between states


I have a rather simple animation I am trying to achieve where the selected item in a list gets a background applied. The goal is that the grey box behind the selected item sort of morphs from previous position to the next one smoothly.

I achieved this using matchedGeometryEffect so that SwiftUI could match the background views between the two states even though technically they have a different ID path. However this effort is thwarted by List

Here is the example project. All you need to do to break the animation is surround the buttons in a List.

Why does List break this animation? Is there any way to get around that?

struct AnimationButtonStyle: ButtonStyle {
    
    var isCurrent: Bool
    var animationNamespace: Namespace.ID
    
    var backgroundView: some View {
        Color.gray
        .cornerRadius(8)
        .matchedGeometryEffect(id: "Shape", in: animationNamespace)
    }
    
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .background(
                isCurrent ? backgroundView : nil
            )
            .opacity(configuration.isPressed ? 0.5 : 1.0)
    }
}

struct ContentView: View {
    enum cases: String, CaseIterable {
        case foo = "Foo"
        case bar = "Barrrrrr"
        case bat = "Battttttttttttttt"
    }
    
    @Namespace private var animationNamespace
    @State var animatedCurrentCase: cases = .foo
    @State var currentCase: cases = .foo
    @State var isAnimating: Bool = false
    
    var body: some View {
        VStack {
            // Without the list this animation will work
            List {
                Section {
                    VStack(alignment: .leading, spacing: 0) {
                        ForEach(Array(cases.allCases.enumerated()), id: \.offset) { index, theCase in
                            var isCurrent: Bool { theCase == animatedCurrentCase }
                            Button {
                                isAnimating = true
                                Task { @MainActor in
                                    animatedCurrentCase = theCase
                                    try? await Task.sleep(nanoseconds: 200_000_000)
                                    currentCase = theCase
                                    isAnimating = false
                                }
                            } label: {
                                Label {
                                    Text(theCase.rawValue)
                                } icon: {
                                    VStack(alignment: .leading) {
                                        Text("\(index)")
                                    }
                                    .frame(width: isCurrent ? 16 + 4 : 16)
                                }
                            }
                            .disabled(isAnimating)
                            .buttonStyle(AnimationButtonStyle(isCurrent: isCurrent, animationNamespace: animationNamespace))
                            .animation(.smooth, value: animatedCurrentCase)
                        }
                    }
                }
            }
            Spacer().frame(height: 10)
            Text("Selected: \(currentCase.rawValue)")
                .font(.title)
        }
        .padding()
    }
}

Sample App


Solution

  • I would suggest removing all the extra code for animating the selected case, because it's unnecessary. Specifically:

    If you then use withAnimation to perform the update inside the button closure, it works.

    Other suggestions:

    Here is the updated example:

    struct AnimationButtonStyle: ButtonStyle {
        let isCurrent: Bool
        let animationNamespace: Namespace.ID
    
        var backgroundView: some View {
            RoundedRectangle(cornerRadius: 8)
                .fill(.gray)
                .matchedGeometryEffect(id: "Shape", in: animationNamespace)
        }
    
        func makeBody(configuration: Configuration) -> some View {
            configuration.label
                .background {
                    if isCurrent {
                        backgroundView
                    }
                }
                .opacity(configuration.isPressed ? 0.5 : 1.0)
        }
    }
    
    struct ContentView: View {
        enum cases: String, CaseIterable {
            case foo = "Foo"
            case bar = "Barrrrrr"
            case bat = "Battttttttttttttt"
        }
    
        @Namespace private var animationNamespace
        @State var currentCase: cases = .foo
    
        var body: some View {
            VStack {
                List {
                    Section {
                        VStack(alignment: .leading, spacing: 0) {
                            ForEach(Array(cases.allCases.enumerated()), id: \.offset) { index, theCase in
                                let isCurrent = theCase == currentCase
                                Button {
                                    withAnimation(.smooth) {
                                        currentCase = theCase
                                    }
                                } label: {
                                    Label {
                                        Text(theCase.rawValue)
                                    } icon: {
                                        VStack(alignment: .leading) {
                                            Text("\(index)")
                                        }
                                        .frame(width: isCurrent ? 16 + 4 : 16)
                                    }
                                }
                                .buttonStyle(
                                    AnimationButtonStyle(
                                        isCurrent: isCurrent,
                                        animationNamespace: animationNamespace
                                    )
                                )
                            }
                        }
                    }
                }
                Spacer().frame(height: 10)
                Text("Selected: \(currentCase.rawValue)")
                    .font(.title)
            }
            .padding()
        }
    }
    

    Animation