SwiftUI Mutating a List during animation causes incorrect display state

Viewed 183

I'm trying to add animations to a list view when using some controls and an undo + redo button. I've added withAnimation closures to my buttons, but when I double tap a button the list view falls out of sync with the backing state and doesn't refresh. I find I'll often undo multiple steps at a time, so this is a pattern I expect users to experience frequently.

My understanding of why this happens is that during animations state changes don't trigger a new render.

I've created an MVP of the issue:

import SwiftUI

struct MyListView: View {
    
    @State var data: [UUID] = []
    
    var body: some View {
        VStack{
            HStack{
                Button("Add", action: {
                    withAnimation(Animation.linear(duration: 2)){ // Offending animation
                        data.append(UUID())
                    }
                })
                Spacer()
                Button("Clear", action: {
                    withAnimation{
                        data.removeAll()
                    }
                })
                Spacer()
                Text("Count: \(data.count)")
            }
            List{
                ForEach(data, id: \.self) { v in
                    Text("\(v)")
                }
                .onMove(perform: { indices, newOffset in
                    data.move(fromOffsets: indices, toOffset: newOffset)
                })
                .onDelete(perform: { indexSet in
                    data.remove(atOffsets: indexSet)
                })
            }
        }
        .padding()
    }
}

struct MyListView_Previews: PreviewProvider {
    static var previews: some View {
        MyListView()
            .environment(\.editMode, .constant(.active))
    }
}

If you run this code in the simulator you can reproduce the issue by rapidly pressing the Add button.

I've been unable to find anything on Google that really provided insight to the right way to solve this issue. I've explored if Transactions could fix the issue but that doesn't seem like the solution, and I haven't found anything on how to interrupt the animation to start a new one without re-rendering the view.

I'm starting to think the answer is to just not animate undo/redo and appending, or relying on some other form of feedback to the user.

Any help would be appreciated, thanks.

2 Answers

I can't either find the exact underlying issue, changing the @State should update your view. But I indeed also think that withAnimation breaks something.

To solve this you can create your own animation in a separate view like this:

struct MyListItem: View {
    
    let id: UUID
    
    @State var animate = false
    
    var body: some View {
        Text("\(id)").scaleEffect(y: animate ? 1 : 0).animation(.default).onAppear {
            animate = true
        }
    }
    
}

struct MyListView: View {
    
    @State var data: [UUID] = []
    
    var body: some View {
            ...
                Button("Add", action: {
                    data.append(UUID())
                })
            ...
            List{
                ForEach(data, id: \.self) { v in
                    MyListItem(id: v)
                }
            ...
    }
}

This is pretty annoying haha. I don't know why the 2nd animation isn't triggering, but here's a workaround that might help.

I put the data into a class and then added a custom publisher called 'dataPublisher'. The dataPublisher is $binding to the data array, so it will publish (force a render) every time the data array is updated. The magic, however, is in the .debounce, which will make it to wait 0.5 seconds after the data array is updated (you can change the timing if you want) before actually publishing the new update. This 0.5 second delay seems to be working to keep the list in sync with the data.

With this, the action is coming from the publishers and not from the button, so the withAnimation in your add button wouldn't work. So, I've move the animation directly only to List. You can of course move it back, but it will only animate the first button click when double-tapping.


import SwiftUI
import Combine

class DataViewModel: ObservableObject {
    
    @Published var data: [UUID] = []
    @Published var dataPublisher: Bool = false
    var cancellables = Set<AnyCancellable>()

    init() {
        setUpPublisher()
    }
    
    func setUpPublisher() {
        $data
            .debounce(for: 0.5, scheduler: RunLoop.main) // magic
            .removeDuplicates()
            .map { input in
                return !self.dataPublisher
            }
            .eraseToAnyPublisher()
            .subscribe(on: DispatchQueue.global(qos: .userInteractive))
            .receive(on: DispatchQueue.main)
            .assign(to: \.dataPublisher, on: self)
            .store(in: &cancellables)
    }
    
}

struct MyListView: View {
    
    @ObservedObject var viewModel = DataViewModel()
    
    var body: some View {
        VStack{
            HStack{
                Button("Add", action: {
                    viewModel.data.append(UUID())
                })
                Spacer()
                Button("Clear", action: {
                    withAnimation{
                        viewModel.data.removeAll()
                    }
                })
                Spacer()
                Text("Count: \(viewModel.data.count)")
            }
            List {
                ForEach(viewModel.data, id: \.self) { v in
                    Text("\(v)")
                }
                .onMove(perform: { indices, newOffset in
                    viewModel.data.move(fromOffsets: indices, toOffset: newOffset)
                })
                .onDelete(perform: { indexSet in
                    viewModel.data.remove(atOffsets: indexSet)
                })
            }
            .animation(Animation.linear(duration: 2))
            
        }
        .padding()

    }
}

struct MyListView_Previews: PreviewProvider {
    static var previews: some View {
        MyListView()
            .environment(\.editMode, .constant(.active))
    }
}
Related