Search code examples
animationswiftui

How to apply changes on a removing item in SwiftUI?


Setup:

I have a simple view that animates its color when needed:

struct SelfAnimatingView: View {
    let name: String
    let animate: Bool

    var body: some View {
        Text(name)
            .padding()
            .foregroundStyle(.white)
            .background(animate ? .red : .blue)
    }
}

And a simple list of removable items :

struct ContentView: View {
    @State private var removable = false
    @State private var names = ["Ted", "Barney", "Lily", "Robin", "Marshal"]
    @State private var animatedNames =  Set<String>()
    private var unanimatedNames: [String] { names.filter({ !animatedNames.contains($0) }) }

    var body: some View {
        VStack {
            Toggle("Removable", isOn: $removable).padding()
            ForEach(removable ? unanimatedNames : names, id: \.self) { name in
                Button { animatedNames.insert(name) } label: {
                    SelfAnimatingView(name: name, animate: animatedNames.contains(name))
                }
            }
            Spacer()
        }
        .animation(.default.delay(1), value: unanimatedNames)
    }
}

Behaviour

The SelfAnimatingView animates when the Removable switch is off as expected:

Demo 1

But as soon as the delete logic involved, the self-contained animation stops working:

Demo 2

It seems SwiftUI does not apply any changes on the (logically) already removed item.


Question

So how can we make it apply the self-contained changes before it is deleted visually?


Solution

  • While the problem of "SwiftUI doesn't update views that are already removed" can be solved by writing your own custom Transition, the color change will occur at the same time as the other views moving up to fill its place.

    You cannot control the animations of the ForEach moving the other buttons up, separately from the pressed button changing its color. This must be done in two separate animations.

    You can create a custom View that wraps the Button. This will use a separate @State to animate the color change first, and only then remove itself from the view hierarchy by calling animatedNames.insert(name).

    struct CustomButton: View {
        let name: String
        @Binding var names: Set<String>
        @State private var flag = false
        
        var body: some View {
            Button {
                // this guard is not needed in this case, because
                // calling names.insert(name) twice will have the same effect as
                // calling it only once.
                // In general, you need this guard to prevent side effects from
                guard !flag else { return }
                withAnimation {
                    flag = true
                } completion: {
                    names.insert(name)
                }
            } label: {
                SelfAnimatingView(name: name, animate: flag)
            }
        }
    }
    

    Usage:

    // also consider wrapping this whole if statement in the custom view
    if removable {
        CustomButton(name: name, names: $animatedNames)
    } else {
        Button { animatedNames.insert(name) } label: {
            SelfAnimatingView(name: name, animate: animatedNames.contains(name))
        }
    }