Sequencing a List Insert and a NavigationStack Push Animations in SwiftUI
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.
pathis still empty, so apath.isEmptycheck 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
onDisappearcleanup 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
allowsHitTestingrather than.disabled, because.disabledgreys out the rows and the whole list would dim for a fraction of a second. selectionDisabledlooks tempting, but as far as I can tell it only affects selection in a List that has a selection binding. A plainNavigationLinkrow 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 forListrow 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.