Sequencing a List Insert and a NavigationStack Push Animations in SwiftUI

Published by malhal on

I wanted a simple behaviour in a SwiftUI app: tap Add, a new row slides into the list, and then the editor for that row pushes onto the navigation stack. Two animations, one after the other. It took a detour through a surprising withAnimation behaviour and a small race condition to get it right, so here is how it went.

The setup

The model is a list of people. The Add button inserts a blank person and pushes a detail screen where you type the name. If you go back with the name still blank, the person is deleted again.

struct Person: Identifiable {
    let id = UUID()
    var name: String

    static func isValid(name: String) -> Bool {
        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }
}

Navigation uses a path of IDs, so the Add button can push programmatically:

@State private var people = [Person(name: "Jim")]
@State private var path: [Person.ID] = []

The blank-person cleanup lives in onDisappear on the pushed view, which fires after the pop has finished. That avoids a flash of a not-found screen during the back animation.

The obvious attempt

Since iOS 17, withAnimation has a completion handler. It looks like exactly the tool for this:

private func addPerson() {
    let person = Person(name: "")
    withAnimation {
        people.append(person)
    } completion: {
        path = [person.id]
    }
}

Insert the row with an animation, and when it finishes, push the detail.

Why it doesn’t work with List

In my testing the completion closure ran immediately, before the row had finished animating in. The push started while the row was still sliding into place.

I haven’t confirmed the cause, but here is my working theory. The completion handler tracks the animatable values that SwiftUI itself changed in the transaction. A row insertion in a List isn’t one of those: SwiftUI hands the diff to the underlying UIKit collection or table view, which runs the insertion animation on its own. From SwiftUI’s point of view nothing is animating, so the animation is already logically complete and the closure fires straight away. Passing completionCriteria: .removed makes no difference.

There is also no public API that returns the system’s default animation duration for list row changes. That timing is private to UIKit and isn’t a single constant anyway. So there is no clean way to ask the system when the row has landed.

I’ve filed a Feedback Assistant report asking for the completion to fire correctly for List row changes, or for a public way to observe when they finish FB17267533.

A fix that survives an OS fix

The simplest workaround is a fixed delay of roughly 300 to 350 milliseconds before pushing. It works, but the number is a guess, and if a future OS makes the completion fire at the right time, the delay becomes dead time.

A slightly better approach is to measure how long passed between starting the animation and the completion firing, and only wait for whatever is left of a minimum duration:

private let minimumInsertDuration: Duration = .milliseconds(250)

private func addPerson() {
    guard path.isEmpty, !isAdding else { return }
    isAdding = true

    let person = Person(name: "")
    let clock = ContinuousClock()
    let start = clock.now

    withAnimation {
        people.append(person)
    } completion: {
        let remaining = minimumInsertDuration - (clock.now - start)

        Task {
            if remaining > .zero {
                try? await Task.sleep(for: remaining)
            }
            path = [person.id]
            isAdding = false
        }
    }
}

Today the completion fires almost instantly, so remaining is close to 250 ms and this behaves like a fixed delay. If the completion ever starts firing at the end of the row animation, the elapsed time exceeds the minimum, the remaining time is negative, and the push happens immediately. The same code is correct in both worlds.

The race condition

The delay opens a window. For a quarter of a second the new row is in the list, but the detail screen hasn’t been pushed yet. In that window two things can go wrong:

  • Double tap on Add. path is still empty, so a path.isEmpty check alone doesn’t stop a second tap, and you insert two blank people.
  • Tapping an existing row. The user pushes that person’s detail, and then the delayed push lands on top of it, giving you two children on the stack. Worse, the blank person’s detail view never appears, so its onDisappear cleanup never runs and the blank row stays in the list. This can be tested by setting the minimumInsertDuration to a few seconds to give time for you to tap an existing row.

The first one is handled by the isAdding flag. For the second, my first fix was to re-check path.isEmpty after the delay and clean up the blank person if something else had been pushed. That works, but it’s a lot of code for a situation you can prevent entirely.

Preventing it instead of repairing it

While the add is in flight, block interaction with the list:

List(people) { person in
    NavigationLink(person.name, value: person.id)
}
.allowsHitTesting(!isAdding)

Now nothing can push during the wait, so the repair branch disappears. I kept the path.isEmpty guard as a cheap safety net against programmatic changes such as a deep link arriving mid-wait.

A few details on that choice:

  • I used allowsHitTesting rather than .disabled, because .disabled greys out the rows and the whole list would dim for a fraction of a second.
  • selectionDisabled looks tempting, but as far as I can tell it only affects selection in a List that has a selection binding. A plain NavigationLink row pushes without going through selection, so it probably wouldn’t block anything here. Worth a quick test in your own project.
  • The trade-off of hit testing is that scrolling is blocked too, for roughly a third of a second.

The final shape

struct ContentView: View {
    @State private var people = [Person(name: "Jim")]
    @State private var path: [Person.ID] = []
    @State private var isAdding = false

    var body: some View {
        NavigationStack(path: $path) {
            List(people) { person in
                NavigationLink(person.name, value: person.id)
            }
            .allowsHitTesting(!isAdding)
            .navigationTitle("People")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add Person", systemImage: "plus", action: addPerson)
                }
            }
            .navigationDestination(for: Person.ID.self) { personID in
                if let person = $people[id: personID] {
                    PersonDetail(person: person)
                        .onDisappear { removeIfBlank(personID) }
                } else {
                    ContentUnavailableView("Person Not Found", systemImage: "person.slash")
                }
            }
        }
    }

    private func removeIfBlank(_ id: Person.ID) {
        guard !path.contains(id) else { return }
        withAnimation {
            people.removeAll { $0.id == id && !Person.isValid(name: $0.name) }
        }
    }
}

Plus the addPerson() function from above.

What I’d do differently with custom components

If you build the list and the navigation out of your own SwiftUI views, with a ScrollView, a LazyVStack and a conditionally shown detail view, SwiftUI drives both animations itself. Then withAnimation completion works properly, and you can even put both changes in a single state update and give the detail’s transition a delay equal to the row’s duration. The cost is losing List’s swipe actions and styling, and the system back gesture and navigation bar. For most apps, the small delay on the real List and NavigationStack is the cheaper trade.

Takeaways

  • withAnimation‘s completion handler tracks SwiftUI-driven animation. In my testing it fires immediately for List row insertions, which UIKit animates.
  • There is no public API for the system’s list animation duration, so some timing value is unavoidable until that changes.
  • Measuring elapsed time and only waiting for the remainder keeps the workaround correct if the platform behaviour improves.
  • When a delay opens a window for races, block the interaction during the window instead of writing code to repair the damage afterwards.

None of this is verified against Apple’s internals, and timing behaviour can change between iOS releases, so test on your minimum deployment target.

Categories: SwiftUI