The animation that jumped, and the `if` that caused it
A card in one of my screens expands when tapped. The expansion animated beautifully. The collapse snapped instantly, with no animation at all, every time.
Same withAnimation, same spring, same view. One direction worked and the other did not, which is
the kind of asymmetry that means the problem is not the animation.
The code
struct CardView: View {
@State private var isExpanded = false
var body: some View {
if isExpanded {
ExpandedCard(content: content)
.onTapGesture { withAnimation(.spring) { isExpanded = false } }
} else {
CollapsedCard(content: content)
.onTapGesture { withAnimation(.spring) { isExpanded = true } }
}
}
}
This looks completely reasonable. It is also the bug, and it took me an embarrassing amount of time to see it because I was reading it as “the card changes appearance” when SwiftUI reads it as something else entirely.
What SwiftUI sees
@ViewBuilder compiles that if/else into _ConditionalContent<ExpandedCard, CollapsedCard>.
Those two branches are different positions in the view tree, which means they have different
identities, which means they are different views.
So nothing is animating from collapsed to expanded. What actually happens is:
CollapsedCardis removed from the treeExpandedCardis inserted into the tree
That is a transition, not a property animation. There is no interpolation between the two frames because there is nothing shared to interpolate — SwiftUI has no reason to believe the height of one view relates to the height of a completely different view.
The reason one direction appeared to animate was incidental. ExpandedCard contained a VStack
whose contents animated internally, so the insertion happened to look smooth. The collapse had no
such accident.
The fix
One view, whose properties change:
struct CardView: View {
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading) {
Text(content.title)
.lineLimit(isExpanded ? nil : 1)
if isExpanded {
Text(content.body)
.transition(.opacity)
}
}
.frame(maxHeight: isExpanded ? .infinity : 60)
.onTapGesture {
withAnimation(.spring) { isExpanded.toggle() }
}
}
}
Now there is one VStack with one identity across both states. Its maxHeight changes from 60 to
.infinity, and maxHeight is a CGFloat, which is Animatable, so SwiftUI interpolates it.
Both directions animate, because both directions are the same view changing.
The inner if is still a conditional and still creates and destroys a view — but that one has a
.transition(.opacity), so its arrival and departure are described rather than instant.
Tip
The distinction worth internalising: a view that keeps its identity animates; a view that gets a new identity transitions. They are different mechanisms and they need different tools. If you have written a spring and are seeing a hard cut, you are almost certainly in the second case while thinking you are in the first.
How to spot it
Two symptoms point at an identity problem rather than an animation problem:
@State resets. If a text field inside the view forgets what was typed, or a scroll position
jumps to the top, the view is being destroyed and rebuilt. Animation and state loss have the same
root cause here.
The animation is instant rather than wrong. A badly-tuned spring looks bouncy or sluggish. An identity change looks like nothing happened at all — one frame it is A, the next it is B. That particular “no animation whatsoever” quality is the tell.
The diagnostic that settles it:
var body: some View {
let _ = Self._printChanges()
…
}
If the console prints @identity rather than a property name, the view is being replaced. That is
ten seconds of work and it removes all the guessing.
The general shape
I now treat any if/else in a body where both branches render the same conceptual thing as a
smell. Some examples I found in the same codebase after fixing this one:
// two branches, same field, different configuration
if isEditing {
TextField("Name", text: $name)
} else {
TextField("Name", text: $name).disabled(true)
}
That one dismisses the keyboard every time isEditing flips, because the text field is destroyed.
The fix is TextField("Name", text: $name).disabled(!isEditing) — one view, one identity.
// two branches, same list, different data
if showArchived {
List(archivedItems) { ItemRow(item: $0) }
} else {
List(activeItems) { ItemRow(item: $0) }
}
That one loses the scroll position and cannot animate the row changes. The fix is one List over a
computed array.
The rule I ended up with: use a conditional when the two branches are genuinely different content. Use a changing property when they are the same content in different states. Almost every animation bug I have had in SwiftUI has been that sentence, applied wrongly.