Below you will find an example view of my problem. It has one button that, when pressed, will toggle between two scroll views using withAnimation, setting the scroll position onto the 2nd and 3rd items for either scroll view in onAppear.
The intent is to have the background of items within each list transition smoothly from their position in one list, to their position in the other. However, this does not appear to be easily possible when setting the list position using an ID/ScrollPosition:
Initializing a ScrollPosition with the correct ID and rendering the ScrollView with that appears to have no effect - the ScrollView will be drawn at the top of the scroll contents
The only way I've found to render the ScrollView at an ID position is to scroll to that position in onAppear. However, it appears that when doing so, the matchedGeometryEffect interpolates the position of the elements as if the contentOffset.y of the ScrollView is briefly 0, resulting in a strange effect
The desired animation can be seen if the two lists are toggled rapidly, allowing for the matchedGeometryEffect to smooth out the brief y 0 and interpolate between the correct positions
It seems I either need to
a) ensure the list is laid out at the correct y location beforehand (very difficult with dynamic list items, but seems to solve this problem if setting the y position explicitly)
b) ensure that the list is laid out at the correct ID beforehand (have not been able to figure out how)
c) ensure the matched geometry effect animation ignores the brief "0" y offset of the ScrollView before setting the ID position in onAppear (have not been able to figure out how)
Note that I have to use VStack here for the matched geometry effect to work consistently.
Any ideas on solving this?
Code:
import SwiftUI
struct Item: Identifiable {
let id = UUID().uuidString
var height: CGFloat
var label: String
}
enum TestScrollListStyle {
case primary
case alternate
}
struct TestScrollList: View {
let items: [Item]
let style: TestScrollListStyle
let namespace: Namespace.ID
@Binding var scrollPosition: ScrollPosition
var initialIndex: Int = 2
var body: some View {
ScrollView {
VStack(spacing: style == .primary ? 8 : 16) {
ForEach(items, id: \.id) { item in
switch style {
case .primary:
Text(item.label)
.frame(maxWidth: .infinity)
.frame(height: item.height)
.padding(.horizontal)
.background(
Rectangle()
.fill(.blue.opacity(0.15))
.matchedGeometryEffect(id: item.id, in: namespace)
)
case .alternate:
HStack {
Circle()
.fill(.green.opacity(0.25))
.frame(width: 24, height: 24)
Text(item.label)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: item.height)
.padding(.horizontal)
.background(
Rectangle()
.fill(.green.opacity(0.08))
.matchedGeometryEffect(id: item.id, in: namespace)
)
}
}
}
.scrollTargetLayout()
.padding(.vertical)
}
.scrollPosition($scrollPosition, anchor: .top)
.onAppear {
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) {
if items.indices.contains(initialIndex) {
scrollPosition.scrollTo(id: items[initialIndex].id)
}
}
}
}
}
struct ContentView: View {
@Namespace private var matchedNamespace
@State private var items: [Item] =
(0..<10).map { i in Item(height: .random(in: 80...220), label: "Row \(i)") }
@State private var showAlternateView: Bool = false
// Scroll positions for each scroll view
@State private var primaryScrollPosition = ScrollPosition(idType: String.self)
@State private var alternateScrollPosition = ScrollPosition(idType: String.self)
var body: some View {
NavigationStack {
ZStack {
if !showAlternateView {
TestScrollList(
items: items,
style: .primary,
namespace: matchedNamespace,
scrollPosition: $primaryScrollPosition,
initialIndex: 2
)
}
if showAlternateView {
TestScrollList(
items: items,
style: .alternate,
namespace: matchedNamespace,
scrollPosition: $alternateScrollPosition,
initialIndex: 3
)
}
}
.navigationTitle("Two ScrollViews + Matched Geometry")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(showAlternateView ? "Primary" : "Alternate") {
withAnimation() {
showAlternateView.toggle()
}
}
}
}
}
}
}
#Preview { ContentView() }
Selecting any option will automatically load the page
Post
Replies
Boosts
Views
Activity
The below code will render a list of items and a button to shuffle them and change their height withAnimation.
If you scroll down and shuffle it you will eventually see glitches in the animation, mostly consisting of ghost items animating on top of other items and disappearing.
Does anyone know why this is happening, and how to stop it happening? The issue goes away entirely if I use a VStack, but using a VStack brings its own problems.
import SwiftUI
struct Item: Identifiable {
let id = UUID()
var height: CGFloat
var label: String
}
struct ContentView: View {
@State private var items: [Item] =
(0..<30).map { i in Item(height: .random(in: 80...220), label: "Row \(i)") }
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items) { item in
Text(item.label)
.frame(maxWidth: .infinity)
.frame(height: item.height)
.background(.gray.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
}
.padding(.vertical)
}
.navigationTitle("LazyVStack Demo")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Shuffle") {
withAnimation() {
items.shuffle()
for i in items.indices {
items[i].height = .random(in: 80...220)
}
}
}
}
}
}
}
}
#Preview { ContentView() }
The following code works properly, ensuring the list is scrolled at the correct ID when first rendered:
import SwiftUI
struct DataItem: Identifiable {
let id: Int
let height: CGFloat
init(id: Int) {
self.id = id
self.height = CGFloat.random(in: 100...300)
}
}
struct ContentView: View {
@State private var items = (0..<1000).map { DataItem(id: $0) }
@State private var scrollPosition: ScrollPosition
init() {
let mid = 500
_scrollPosition = State(initialValue: .init(id: mid, anchor: .center))
}
var body: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items) { item in
Text("Item \(item.id)")
.frame(height: item.height)
.frame(maxWidth: .infinity)
.background(.gray)
}
}
.scrollTargetLayout()
}
.scrollPosition($scrollPosition, anchor: .center)
}
}
However, if I change this to use VStack this ceases to work - the list begins rendered at the top of the list. The only way I can render it starting at the correct position is using side effects like onAppear or task, where I would update the scroll position.
I have observed the following behavior on my iPhone 15 Pro/iOS 26. Is this a bug?
Topic:
UI Frameworks
SubTopic:
SwiftUI
I have been banging my head against this problem for a bit now.
I am trying to build a bidirectional, infinitely scrolling list that implements these core requirements:
Loads data up/down on the fly as the user scrolls
Preserves scroll velocity as the list is updated
Restores the scroll to the exact visual location after data has changed
Ensures no flicker when restoring scroll position - the user cannot know the list has updated and should continue scrolling as normal
Because LazyVStack does not play well with animations, I am opting to go with VStack and am implementing my own sliding window for data. This means that data can be removed as well as added, and a simple application of a height delta is not enough when restoring position.
So far I have tried many things:
Relying on ScrollPosition - simply does not work by itself as described (swift UI trying to keep the position stable with ID's)
Relying on ScrollPosition.scrollTo - only kind of works with ID, no way to restore position with pixel perfect accuracy
Intercepting the UIKit scrollView instance, using it to record and access the top row's position, mutating data and then queuing a scroll restoration using CATransaction.setCompletionBlock - this is the closest I've come, and it satisfies the top 3 requirements but sometimes I get a flicker on slightly heavier lists
What I would really like, is a way of using ScrollView and granularly hooking into the lifecycle of the view after layout, and just before draw. At this point I would update the relevant scroll positions, and allow draw to continue. Is this possible? My knowledge is very limited at this point, but I believe I may be able to achieve something of the sort by swizzling layerWillDraw? Does this make sense, and is it prudent?
In general, I'm very interesting in hearing what people have to say about the above, as well as this problem in general.
I’m trying to keep a specific row visually stable while the data backing a ScrollView changes.
Goal
1. Before updating model.items, capture the top row’s offset relative to the scroll view.
2. Mutate the observable state so SwiftUI recomputes layout — but don’t draw yet.
3. Read the new layout, compute the delta, and adjust the scroll position so the previously visible row stays put.
4. Only then draw the new frame.
Reduced example
@Observable
final class SomeModel {
var items: [SomeItem] = [/* ... */]
}
struct MyBox: View {
@Environment(SomeModel.self) private var model
var body: some View {
ScrollView {
VStack {
ForEach(model.items, id: \.id) { item in
Color.red.frame(height: randomStableHeight(for: item.id))
}
}
}
}
}
// Elsewhere:
let oldRow = recordOldRow() // capture the row to stabilize
model.items = generateNewItems() // mutate model (invalidates layout)
let newPos = capturePreviousRowNewPosition(oldRow) // read new layout?
restoreScrollPosition() // adjust so oldRow stays visually fixed
// draw now
Is that pipeline achievable in SwiftUI? If not, what’s the supported way to keep a row visually stable while the list updates?
I really enjoyed using SwiftData for persistence until I found out that the CloudKit integration ensures changes are only eventually consistent, and that changes can propagate to other devices after as long as minutes, making it useless for any feature that involves handoff between devices. Devastating news but I guess it’s on me for nrtfm. I may try my hand at a custom model context DataStore integrating Powersync, but that’s a whole trip and before I embark on it I was wondering if anyone had suggestions for resolving this problem in a simple and elegant manager that allows me to keep as much of the machinery within Apple’s ecosystem as possible, while ensure reliable “live” updates to SwiftData stores on all eligible devices.