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.