.scrollDismissesKeyboard(.interactively) or by wrapping the List in a ScrollViewReader and scrolling to the focused field’s ID in an onChange handler. For the most responsive feel, replace List with a ScrollView + LazyVStack and drive scrolling yourself.Why this happens
When a TextField inside a SwiftUI List becomes first responder, the system keyboard slides up immediately. However, List (which is backed by UITableView) only calculates whether the focused row is obscured after the keyboard animation completes. This built-in auto-scroll runs on the next run-loop cycle, creating a visible 0.3–0.5 second lag where the keyboard is already visible but the text field hasn’t moved into view yet.
The modifier .scrollDismissesKeyboard(.interactively) can make the problem worse because it installs its own pan-gesture recognizer that competes with the List’s internal scroll logic. Removing it often restores the default (still delayed) behavior, but the delay remains because the root cause is the List’s deferred layout pass.
How to fix it
1. Quick win: drop .scrollDismissesKeyboard(.interactively)
If you added that modifier to let users drag the keyboard away, try removing it first. In many projects this single line is the only thing preventing the List from scrolling at the right moment.
List {
ForEach($taskItems) { $task in
TextField("Task Title", text: $task.title, axis: .vertical)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: task.id)
}
}
.listStyle(.insetGrouped)
// .scrollDismissesKeyboard(.interactively) ← delete this line
Test on device; if the delay is acceptable, you’re done.
2. Drive the scroll yourself with ScrollViewReader
Wrap the List in a ScrollViewReader and scroll to the focused item’s ID the moment focusedField changes. Give each row an explicit .id() so the proxy can find it.
@FocusState private var focusedField: UUID?
var body: some View {
ScrollViewReader { proxy in
List {
ForEach($taskItems) { $task in
TextField("Task Title", text: $task.title, axis: .vertical)
.textFieldStyle(.roundedBorder)
.id(task.id) // ← critical
.focused($focusedField, equals: task.id)
}
}
.listStyle(.insetGrouped)
.onChange(of: focusedField) { _, newID in
guard let id = newID else { return }
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(id, anchor: .center)
}
}
}
}
Why this works: onChange fires synchronously when the focus binding updates, before the keyboard animation finishes. The withAnimation block schedules the scroll alongside the keyboard’s own animation, so both move together.
Tip: If you see a flash of over-scrolling, change
anchor: .centertoanchor: UnitPoint(x: 0.5, y: 0.9)to stop a few points short of dead center.
3. Best responsiveness: replace List with ScrollView + LazyVStack
List’s UITableView heritage adds a layout pass you cannot skip. A plain ScrollView lets you control scroll position frame-perfectly.
@FocusState private var focusedField: UUID?
@Namespace private var scrollSpace
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0, pinnedViews: []) {
ForEach($taskItems) { $task in
TextField("Task Title", text: $task.title, axis: .vertical)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
.padding(.vertical, 8)
.id(task.id)
.focused($focusedField, equals: task.id)
.background(Color(.systemGroupedBackground))
}
}
}
.scrollDismissesKeyboard(.interactively) // now safe to keep
.onChange(of: focusedField) { _, newID in
guard let id = newID else { return }
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(id, anchor: .center)
}
}
}
.coordinateSpace(name: scrollSpace)
}
Benefits:
– Zero framework delay; scroll fires in the same transaction as focus change.
– .scrollDismissesKeyboard(.interactively) works without conflict.
– You keep section headers, footers, and swipe actions by adding them inside the LazyVStack.
How to prevent it
- Default to
ScrollView+LazyVStackfor new forms where keyboard avoidance matters. - Always assign stable
.id()to every focusable row; without itScrollViewReadercannot scroll. - Avoid mixing
.scrollDismissesKeyboardwithListunless you’ve verified the interaction on device. - Test on physical hardware — simulator keyboard animations are often faster and hide the lag.
FAQ
Does ScrollViewReader work with List on iOS 17 and later?
Yes. ScrollViewReader can proxy a List because List conforms to the required scrollable protocol. The only requirement is that each row has a unique, hashable .id().
Why does anchor: .bottom not keep the field fully visible?
List measures the anchor against the row’s frame, not the text field inside it. With multi-line axis: .vertical fields the row grows while editing, so .bottom can land below the visible area. anchor: .center or a custom UnitPoint is more predictable.
Can I keep swipe-to-delete with the ScrollView approach?
Yes. Wrap each row in a .swipeActions modifier inside the LazyVStack. The gesture recognizers remain functional because the content is still inside a ScrollView.
ForEach($taskItems) { $task in
TextField(...)
.id(task.id)
.swipeActions(edge: .trailing) {
Button(role: .destructive) { delete(task) } label: { Label("Delete", systemImage: "trash") }
}
}
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
