SwiftUI multiple vertical lists row animations not working

Viewed 225

Suddenly I have figured out that there is no automatic animation for non of them:

Demo

no animations for inserting, removing, reordering, etc. ☹️ Ids are unique and persistent between transitions

Question 1

How can I make them work as expected?

Question 2

Is there a simple way to have an animation for seeing the transition between lists? Like moving a row between sections of a single list.


Explaining:

Let's say I have three lists that group different states of elements of a single array:

extension Call {
    enum State: Equatable {
        case inProgress
        case accepted
        case rejected
    }
}

The observable:

class CallManager: ObservableObject {
    @Published var calls: [Call]
    init(visits: [Call] = []) { self.calls = visits }
}

And Call is a simple Identifiable:

struct Call: Identifiable & Equatable & Hashable {
    let id = UUID()
    var state: State
}

By making these bindings, I have bind all lists to the core calls array:

extension CallManager {
    func bindingCalls(for state: Call.State) -> Binding<[Call]> {
        Binding<[Call]>(
            get: { self.calls.filter { $0.state == state } },
            set: { // TODO: Find a better way for this
                self.calls.removeAll(where: { $0.state == state })
                self.calls.append(contentsOf: $0)
            }
        )
    }

    var inProgress: Binding<[Call]> { bindingCalls(for: .inProgress) }
    var accepted: Binding<[Call]> { bindingCalls(for: .accepted) }
    var rejected: Binding<[Call]> { bindingCalls(for: .rejected) }
}

And here is the View code:

struct ContentView: View {

    @StateObject var visitManager =  CallManager(visits: [
        Call(state: .inProgress),
        Call(state: .accepted),
        Call(state: .accepted),
        Call(state: .inProgress),
        Call(state: .inProgress),
        Call(state: .rejected)
    ])

    var body: some View {
        HStack {
            List(visitManager.inProgress) { $call in
                CallView(call: $call)
            }

            List(visitManager.accepted) { $call in
                CallView(call: $call)
            }

            List(visitManager.rejected) { $call in
                CallView(call: $call)
            }
        }
    }
}

struct CallView: View & Identifiable {
    @Binding var call: Call
    var id: UUID { call.id }

    var body: some View {
        Text(call.id.uuidString.prefix(15))
            .foregroundColor(call.state.color)
            .onTapGesture(count: 2) { call.state = .rejected }
            .onTapGesture { call.state = .accepted }
    }
}

extension Call.State {
    var color: Color {
        switch self {
        case .inProgress: return .blue
        case .rejected: return .red
        case .accepted: return .green
        }
    }
}
1 Answers

You can enable the animations on the List view:

List(visitManager.inProgress) { $call in
    CallView(call: $call)
}
.animation(.default)

Or wrap the changes in a withAnimation block:

.onTapGesture { withAnimation { call.state = .accepted } }

As for the animation between the columns, you can get something like that with .matchedGeometryEffect. afaik it will always look a bit crumbly between List, to make it look good you need to use a VStack (but then loose all the comfort of the List view). For example:

import SwiftUI

extension Call {
    enum State: Equatable, CaseIterable {
        case inProgress
        case accepted
        case rejected
    }
}

class CallManager: ObservableObject {
    @Published var calls: [Call]
    init(visits: [Call] = []) { calls = visits }
}

struct Call: Identifiable & Equatable & Hashable {
    let id = UUID()
    var state: State
}

struct ContentView: View {
    @Namespace var items

    @StateObject var visitManager = CallManager(visits: [
        Call(state: .inProgress),
        Call(state: .accepted),
        Call(state: .accepted),
        Call(state: .inProgress),
        Call(state: .inProgress),
        Call(state: .rejected),
    ])

    var body: some View {
        HStack(alignment: .top) {
            ForEach(Call.State.allCases, id: \.self) { state in
                VStack {
                    List(visitManager.calls.filter { $0.state == state }) { call in
                        CallView(call: call)
                            .id(call.id)
                            .matchedGeometryEffect(id: call.id, in: items)
                            .onTapGesture {
                                if let idx = visitManager.calls.firstIndex(where: { $0.id == call.id }) {
                                    withAnimation {
                                        visitManager.calls[idx].state = .rejected
                                    }
                                }
                            }
                    }
                }
            }
        }
    }
}

struct CallView: View & Identifiable {
    var call: Call
    var id: UUID { call.id }

    var body: some View {
        Text(call.id.uuidString.prefix(15))
            .foregroundColor(call.state.color)
    }
}

extension Call.State {
    var color: Color {
        switch self {
        case .inProgress: return .blue
        case .rejected: return .red
        case .accepted: return .green
        }
    }
}
Related