Fix: TextField Scroll Delay Above Keyboard in SwiftUI List

Fix: TextField Scroll Delay Above Keyboard in SwiftUI List

Written by

in

Quick answer: The delay occurs because List waits for the keyboard animation to finish before scrolling. Fix it by removing .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: .center to anchor: 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 + LazyVStack for new forms where keyboard avoidance matters.
  • Always assign stable .id() to every focusable row; without it ScrollViewReader cannot scroll.
  • Avoid mixing .scrollDismissesKeyboard with List unless 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") }
        }
}
Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.