How can I animate changes to a view when the value being mutated is a reference type?
In the example below, changing the reminder time updates the order but without animation.
I thought arrays were value types, but maybe event.reminders is a reference, hence not triggering an update? If so I guess the .animation(_:value:) isn't the right approach?
What are the alternatives?
import SwiftUI
struct MissingAnimationWhenChangingArrayOrder: View {
let event: Event
var body: some View {
ForEach(event.reminders.sorted { $0.dateTime < $1.dateTime }) { reminder in
ReminderView(reminder: reminder)
}
.animation(.spring, value: event.reminders)
}
}
#Preview {
var event = Event(
reminders: [
Reminder(title: "Meeting 1", dateTime: .now),
Reminder(title: "Meeting 2", dateTime: .now)
]
)
return MissingAnimationWhenChangingArrayOrder(event: event)
}
struct ReminderView: View {
@Bindable var reminder: Reminder
var body: some View {
DatePicker("", selection: $reminder.dateTime)
.frame(width: 200)
}
}
@Observable
class Event {
var reminders: [Reminder]
init(reminders: [Reminder]) {
self.reminders = reminders
}
}
@Observable
class Reminder: Identifiable, Equatable {
let id = UUID()
var title: String
var dateTime: Date
init(title: String, dateTime: Date) {
self.title = title
self.dateTime = dateTime
}
static func == (lhs: Reminder, rhs: Reminder) -> Bool {
lhs.id == rhs.id
}
}
This is indeed because of reference type semantics.
The way the .animation
modifier works, is that it checks whether its value:
parameter has changed (as determined by ==
) after a view update. SwiftUI stores an "old version" of value:
before the a view update, then runs the view update by calling body
, then compares the new value:
it received during the body
call with the "old value" it had. If they are unequal, start the animation.
This works fine for value types, but for reference types, the "old version" of value:
is just another copy of the reference to the same object! When you change a property, the old version that SwiftUI stores "changes" to the same value as well because it is the same instance. So SwiftUI always finds that they are equal, unless you have implemented ==
in a very wrong way :)
Another contributing factor is that, you display a sorted array in ForEach
, but you never actually sort the event.reminders
array itself. If you had sorted it, then the equality comparison would be unequal. Arrays are value types, after all, even though the old and new array contain references to the same two Reminder
objects, the order is different.
Note that even if Reminder
is a value type, your implementation of ==
will prevent the animation, because it only compares the IDs. Changing the dates of the reminders will not cause the reminders to be unequal.
To fix this, I would map
the array of reminders to an array of their dates:
.animation(.spring, value: event.reminders.map(\.dateTime))