For a while, I've noticed certain animations break in SwiftUI's List
view.
Notice that even though we use a withAnimation
block, the animation is not smooth.
struct ContentView: View {
@State var showContent: Bool = false
var body: some View {
List {
VStack {
Text("Hello world")
if showContent {
Text("Extra content")
}
}
.onTapGesture {
withAnimation {
showContent.toggle()
}
}
Text("More content")
}
}
}
List as a wrapper over UITableView
Although I can only speculate, I believe SwiftUI's List
view is a wrapper over UIKit's UITableView
.
I experimented with implementing a custom List
view that wraps a UITableView
(link to source code)
and I managed to achieve smooth animations using the exact code above.
List only animates insertion and removal
It only occurred to me today, that List
only animates insertion and removal.
List
does not animate changes to existing cells resizing.
For example, if showContent
is toggled, the VStack
will resize,
but List
will not animate this change properly, as the cell is just being resized,
not removed or inserted.
VStack {
Text("First")
if showContent {
Text("Second")
}
}
Thus, if you want smooth animations, you should avoid wrapping views in a VStack
(or any container view) inside a List
.
For example, in this case, the animation will be smooth when showContent
is toggled,
because the Text("Second")
will be inserted / removed from the view hierarchy,
as opposed to an existing view/cell being resized.
List {
Text("First")
if showContent {
Text("Second")
}
Text("Third")
}
This blog post here talks more about how all SwiftUI views are "lists", which should help illuminate this behavior.
How to tell if an insertion, deletion, or update will occur?
From what I can tell, it all depends on the view's identity.
- If the view's identity changes, then a deletion or insertion will occur.
- If the view's identity remains constant, then a resize will occur if necessary.
Identity in SwiftUI
SwiftUI has two forms of identity:
- Structural identity
- Stable identity
As far as I can tell, structural identity essentially uses the index of the view in the parent view hierarchy.
For example:
// Structural identity
List {
Text("First") // index 0
Text("Second") // index 1
}
Stable identity occurs when the .id()
modifier is applied, i.e, the View
conforms to Identifiable
.
// Stable identity
List {
Text("First").id("first")
Text("Second").id("second")
}
A hack for smooth animations
Thus, applying this knowledge, we can force SwiftUI
to animate changes within a List
by changing a view's identity upon view update.
In this case, when condition
is toggled, the Text
view's identity is changed,
thus forcing SwiftUI to animate the change because SwiftUI will consider the change
to be a deletion & insertion, as opposed to a resize.
List {
Text(condition ? shortText : longText)
.id(condition ? "short" : "long")
.onTapGesture {
withAnimation {
condition.toggle()
}
}
Text("More content")
}
Why does List behave this way?
We can only speculate, but I assume it’s either:
- Expensive to animate changes to existing cells
- Or, animating view resizes could sometimes look glitchy
Checkout this demo
if you're interested in seeing the code for the custom List
view that supports animating
cell resizes.