Why animations break in SwiftUI's List view

October 20, 2024

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")
        }
    }
}

Broken animation

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.