Fix: LazyVStack Scroll Position Lost When Prepending Items in iOS 27

Fix: LazyVStack Scroll Position Lost When Prepending Items in iOS 27

Written by

in

Quick answer: In iOS 27, LazyVStack’s scrollTo breaks when opening at the bottom or prepending items. Replace ScrollViewReader with .scrollPosition(id:anchor:), add .scrollTargetLayout() to the LazyVStack, double-set the scroll position on appear, and use .onScrollPhaseChange to load older messages only when scrolling is idle.

Why this happens

iOS 27 changed how LazyVStack estimates row heights and processes programmatic scroll requests. Two related regressions appear:

  1. Initial scroll to bottom lands short. LazyVStack only knows the heights of rows it has already laid out. When you call scrollTo(lastId, anchor: .bottom) on first appear, the stack guesses the remaining heights, often stopping two or three variable-height rows above the true bottom.

  2. Prepending items resets offset to zero. Inserting rows at the top invalidates the previous scroll position. In iOS 26 you could immediately call scrollTo(previousTopId, anchor: .top) to restore the visual position. In iOS 27 that call is ignored while the scroll view is still settling from the insertion, so the content stays pinned to the new first row and the top sentinel fires again, causing a loop.

The root cause is that ScrollViewReader‘s scrollTo is synchronous and does not wait for layout passes or scroll animations to finish. The new .scrollPosition API (iOS 17+) is designed to be state-driven and works with the scroll view’s own layout cycle.

How to fix it

1. Migrate from ScrollViewReader to .scrollPosition

Replace the ScrollViewReader wrapper with a plain ScrollView and bind a scroll position state variable. Mark the LazyVStack with .scrollTargetLayout() so the scroll system knows each row’s identity.

struct ChatView: View {
    @StateObject private var model = ChatModel()
    @State private var scrollPosition: Int?
    @State private var performingLoad = false

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                ForEach(model.messages) { m in
                    MessageView(message: m)
                }
            }
            .scrollTargetLayout()
        }
        .scrollPosition(id: $scrollPosition, anchor: .top)
        // …
    }
}

Why .top anchor? The scroll position always refers to the leading edge of the identified view. For the last message, the nearest it can get to the top is the bottom of the viewport, so .top works for both directions.

2. Double-set the position on initial appear

Because the first layout pass may not have measured all rows, set the target ID, wait a tick, clear it, wait again, then set it a second time. This forces a second layout pass with more accurate heights.

.task {
    if let lastMessageId = model.messages.last?.id {
        scrollPosition = lastMessageId
        try? await Task.sleep(for: .milliseconds(10))
        scrollPosition = nil
        try? await Task.sleep(for: .milliseconds(10))
        scrollPosition = lastMessageId
    }
}

The nil assignment is essential; without it the second assignment is treated as a no-op.

3. Load older messages only when scrolling is idle

Use .onScrollPhaseChange to detect when the user has stopped scrolling and the scroll position is at the first message. Set a guard flag to prevent re-entrancy.

.onScrollPhaseChange { _, newPhase in
    if newPhase == .idle && !performingLoad,
       let scrollPosition,
       let firstMessageId = model.messages.first?.id,
       scrollPosition == firstMessageId {

        performingLoad = true
        defer { performingLoad = false }

        await model.loadOlder()

        // Restore the row that was at the top before the prepend
        scrollPosition = firstMessageId
    }
}

loadOlder() is now async so the position restore happens after the insertion is reflected in the published array.

4. Factor row views out for cleaner diffing

Extract each message into its own view (MessageView). This keeps the ForEach body light and helps SwiftUI’s identity tracking when rows are inserted at the top.

struct MessageView: View {
    let message: Message
    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(message.height >= 200 ? .orange.opacity(0.4) : .blue.opacity(0.3))
            .frame(height: message.height)
            .overlay(Text("\(message.id)"))
            .padding(.horizontal)
    }
}

How to prevent it

  • Always add .scrollTargetLayout() to LazyVStack/LazyHStack when using .scrollPosition.
  • Prefer .scrollPosition over ScrollViewReader for iOS 17+ targets; it integrates with the scroll view’s layout cycle.
  • When prepending or appending data, await the model update before restoring the scroll position.
  • Guard programmatic scrolls with a performingLoad–style flag to avoid fighting the scroll view’s own animations.
  • Test with variable-height content (text, images, system layouts) on both simulator and device; height estimation bugs only surface with mixed row sizes.

FAQ

Does this work on iOS 16 or earlier?

No. .scrollPosition, .scrollTargetLayout, and .onScrollPhaseChange require iOS 17 / macOS 14. For iOS 16 you must stay with ScrollViewReader and the older workarounds (re-issuing scrollTo after a delay, using GeometryReader preferences).

Why does the double-set need the nil assignment?

SwiftUI ignores a state assignment if the new value equals the current value. Setting scrollPosition = nil between the two attempts forces the second assignment to be treated as a change, triggering a fresh layout pass.

Can I still animate the initial scroll?

Yes. Wrap the final scrollPosition = lastMessageId in withAnimation if you want a smooth entrance. The double-set technique works with or without animation; just keep the nil reset outside the animation block.


Tested with Xcode 27, iOS 27.0 simulator and device. The reproduction code in the question fails on iOS 27; the fixed version above restores the iOS 26 behavior.

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.