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)
}
}
The SelfAnimatingView
animates when the Removable
switch is off as expected:
But as soon as the delete
logic involved, the self-contained animation stops working:
It seems SwiftUI does not apply any changes on the (logically) already removed item.
So how can we make it apply the self-contained changes before it is deleted visually?
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))
}
}