I have a chat screen built with ScrollView { LazyVStack } and ScrollViewReader. Two things that worked through iOS 26 stopped working on iOS 27 (Xcode 27, iOS 27.0 simulator and device; unchanged code still works on the iOS 26.x simulator):
Open at the newest message. On appear I call proxy.scrollTo(lastId, anchor: .bottom). On iOS 27 the list stops two or three rows above the bottom when the rows have variable heights (text bubbles mixed with 200pt images).
Load older messages at the top without the list jumping. When the user reaches the top, I prepend 25 older messages and call proxy.scrollTo(previousTopId, anchor: .top) so the row they were reading stays put. On iOS 27 the call does nothing: the list stays at the top of the newly inserted page (offset stays at 0), which immediately re-triggers the load.
Re-issuing scrollTo on every layout change for a short window (which is what made this reliable on iOS 26) has no effect on iOS 27.
Minimal reproduction
import SwiftUI
struct Message: Identifiable, Hashable {
let id: Int
let height: CGFloat // simulates text vs. image bubbles
}
@MainActor
final class ChatModel: ObservableObject {
@Published var messages: [Message] = []
@Published var previousTopId: Int? // set when a page is prepended
private var nextOldId = 1_000
init() {
messages = (0..<25).map { Message(id: nextOldId - $0, height: Self.randomHeight()) }.reversed()
nextOldId -= 25
}
func loadOlder() {
let top = messages.first!.id
let page = (0..<25).map { Message(id: nextOldId - $0, height: Self.randomHeight()) }.reversed()
nextOldId -= 25
messages.insert(contentsOf: page, at: 0)
previousTopId = top // "keep this row at the top"
}
static func randomHeight() -> CGFloat { [44, 60, 90, 200, 260].randomElement()! }
}
struct ChatView: View {
@StateObject private var model = ChatModel()
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
// top sentinel: load older when it becomes visible
Color.clear.frame(height: 1)
.onAppear { model.loadOlder() }
ForEach(model.messages) { m in
RoundedRectangle(cornerRadius: 12)
.fill(m.height >= 200 ? .orange.opacity(0.4) : .blue.opacity(0.3))
.frame(height: m.height)
.overlay(Text("\(m.id)"))
.padding(.horizontal)
.id(m.id)
}
}
}
.onAppear {
// (1) open at the newest message
DispatchQueue.main.async {
proxy.scrollTo(model.messages.last!.id, anchor: .bottom)
}
}
.onChange(of: model.previousTopId) { _, id in
// (2) restore the row that was at the top before the prepend
guard let id else { return }
DispatchQueue.main.async {
var t = Transaction(); t.disablesAnimations = true
withTransaction(t) { proxy.scrollTo(id, anchor: .top) }
}
}
}
}
}
Observed
(1) Open at the newest message, scrollTo(lastId, anchor: .bottom)
iOS 26.x: lands on the last row.
iOS 27.0: stops 2–3 rows above the bottom.
(2) Prepend 25 rows, then scrollTo(previousTopId, anchor: .top)
iOS 26.x: the previous top row is at the top of the viewport.
iOS 27.0: the offset stays at 0 and the new page's first row is at the top.
What I have tried on iOS 27
Re-issuing scrollTo for 0.5s on every content-height or offset change (via GeometryReader preferences). No effect; the target row is not realized, and the visible rows are kept stable instead.
.defaultScrollAnchor(.bottom) and .defaultScrollAnchor(.bottom, for: .sizeChanges): fixes the initial open, but the stored anchor is re-applied once on the first content change (the prepend), which snaps the list to the newest message. .sizeChanges did not preserve the prepend.
.scrollPosition(id: $topId, anchor: .top) with .scrollTargetLayout() on the LazyVStack: the binding tracks the top row correctly, but after the prepend SwiftUI re-targets the binding to the new page's first row. Writing the previous id back, in the same update or on later run-loop turns, does not restore the position.
Replacing LazyVStack with VStack fixes both cases, but the list holds hundreds of image rows and needs the lazy container for memory.
What does work: reaching the backing UIScrollView (SwiftUIIntrospect), recording the visible rows' frames before the insert and adjusting contentOffset after layout. It works but is a lot of code for something that used to be one scrollTo.
Questions
Is it intended on iOS 27 that ScrollViewProxy.scrollTo does not scroll to a LazyVStack row that is not currently realized, or that the lazy stack keeps the currently visible rows stable in preference to the requested target? The WWDC26 lazy-stacks session describes the stack and scroll view coordinating the offset as estimates update; is scrollTo to an unrealized row now unsupported?
Is there a supported SwiftUI way on iOS 27 to keep the visible rows in place when items are prepended to a LazyVStack, or to scroll reliably to an unrealized row? For example a ScrollPosition usage or an anchor role I am missing.
If not, is adjusting the UIScrollView offset the expected approach, or is List now the recommended container for chat-style lists with bidirectional paging?
I have filed this as FB24968838 with the sample project attached.