Insert, update and delete animations with ForEach in SwiftUI
Asked Answered
S

2

16

I managed to have a nice insert and delete animation for items displayed in a ForEach (done via .transition(...) on Row). But sadly this animation is also triggered when I just update the name of Item in the observed array. Of course this is because it actually is a new view (you can see that, since onAppear() of Row is called).

As we all know the recommended way of managing lists with cool animations would be List but I think that many people would like to avoid the standard UI or the limitations that come along with this element.

A working SwiftUI example snippet is attached (Build with Xcode 11.4)

So, the question:

Is there a smart way to suppress the animation (or have another one) for just updated items that would keep the same position? Is there a cool possibility to "reuse" the row and just update it?

Or is the answer "Let's wait for the next WWDC and let's see if Apple will fix it..."? ;-)

Cheers,
Orlando 🍻


Edit

bonky fronks answer is actually a good approach when you can distinguish between edit/add/delete (e.g. by manual user actions). As soon as the items array gets updated in background (for example by synced updates coming from Core Data in your view model) you don't know if this is an update or not. But maybe in this case the answer would be to manually implement the insert/update/delete cases in the view model.


struct ContentView: View {

    @State var items: [Item] = [
        Item(name: "Tim"),
        Item(name: "Steve"),
        Item(name: "Bill")
    ]

    var body: some View {
        NavigationView {
            ScrollView {
                VStack {
                    ForEach(items, id: \.self) { item in
                        Row(name: item.name)
                    }
                }
            }
            .navigationBarItems(leading: AddButton, trailing: RenameButton)
        }
    }

    private var AddButton: some View {
        Button(action: {
            self.items.insert(Item(name: "Jeff"), at: 0)
        }) {
            Text("Add")
        }
    }

    private var RenameButton: some View {
        Button(action: {
            self.items[0].name = "Craigh"
        }) {
            Text("Rename first")
        }
    }
}

struct Row: View {

    @State var name: String

    var body: some View {
        HStack {
            Text(name)
            Spacer()
        }
        .padding()
        .animation(.spring())
        .transition(.move(edge: .leading))
    }
}

struct Item: Identifiable, Hashable {

    let id: UUID
    var name: String

    init(id: UUID = UUID(), name: String) {
        self.id = id
        self.name = name
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Spermatocyte answered 28/4, 2020 at 17:37 Comment(0)
C
12

Luckily this is actually really easy to do. Simply remove .animation(.spring()) on your Row, and wrap any changes in withAnimation(.spring()) { ... }.

So the add button will look like this:

private var AddButton: some View {
    Button(action: {
        withAnimation(.spring()) {
            self.items.insert(Item(name: "Jeff"), at: 0)
        }
    }) {
        Text("Add")
    }
}

and your Row will look like this:

struct Row: View {

    @State var name: String

    var body: some View {
        HStack {
            Text(name)
            Spacer()
        }
        .padding()
        .transition(.move(edge: .leading))
    }
}
Chirpy answered 28/4, 2020 at 18:4 Comment(3)
Thanks for your answer. You are right, this solves the problem because in this example I can distinguish between add and edit. But unfortunately this is not an option when the items array is updated in background (e.g. by incoming core data updates in the view model). Since this was not clear in my question I will update it ...Spermatocyte
Since your answer is correct to my non-updated question I will accept it. On the other side I hope that some alternatives will follow up ;) Thanks again.Spermatocyte
As far as I know there's no 'clean' way to do this. You could try adding an abstraction layer in which you'll add a didSet to the data that gets updated in the background. Then you set the data that's used in your UI with withAnimation.Chirpy
S
0

The animation must be added on the VStack with the modifier animation(.spring, value: items) where items is the value with respect to which you want to animate the view. items must be an Equatable value. This way, you can also animate values that you receive from your view model.

var body: some View {
    NavigationView {
        ScrollView {
            VStack {
                ForEach(items, id: \.self) { item in
                    Row(name: item.name)
                }
            }
            .animation(.spring(), value: items) // <<< here 
        }
        .navigationBarItems(leading: AddButton, trailing: RenameButton)
    }
}
Samuella answered 3/6, 2022 at 15:58 Comment(1)
+1: This works when using a coredata fetch request for the items. Applying the animation modifier on the ForEach does not work at all.Tjirebon

© 2022 - 2024 — McMap. All rights reserved.