Keep ScrollView position when adding items on the top

I am using a LayzVStack embedded into a ScrollView. The list items are fetched from a core data store by using a @FetchResult or I tested it also with the new @Query command coming with SwiftData.

The list has one hundred items 1, 2, 3, ..., 100. The user scrolled the ScrollView so that items 50, 51, ... 60 are visible on screen.

Now new data will be fetched from the server and updates the CoreData or SwiftData model. When I add new items to the end of the list (e.g 101, 102, 103, ...) then the ScrollView is keeping its position. Opposite to this when I add new items to the top (0, -1, -2, -3, ...) then the ScrollView scrolls down.

Is there a way with the new SwiftData and SwiftUI ScrollView modifiers to update my list model without scrolling like with UIKit where you can query and set the scroll offset pixel wise?

Answered by Frameworks Engineer in 755399022

The new scrollPosition modifier can help you. It associated a binding to an identifier with the scroll view. When changes to the scroll view occur like items being added or the scroll view changing its containing size, it will attempt to keep the currently scrolled item in the same relative position as before the change. The docs on the website have some incorrect information but here's an example to hopefully get you started.

struct ContentView: View {
@State var data: [String] = (0 ..< 25).map { String($0) }
@State var dataID: String?
var body: some View {
ScrollView {
VStack {
Text("Header")
LazyVStack {
ForEach(data, id: \.self) { item in
Color.red
.frame(width: 100, height: 100)
.overlay {
Text("\(item)")
.padding()
.background()
}
}
}
.scrollTargetLayout()
}
}
.scrollPosition(id: $dataID)
.safeAreaInset(edge: .bottom) {
Text("\(Text("Scrolled").bold()) \(dataIDText)")
Spacer()
Button {
dataID = data.first
} label: {
Label("Top", systemImage: "arrow.up")
}
Button {
dataID = data.last
} label: {
Label("Bottom", systemImage: "arrow.down")
}
Menu {
Button("Prepend") {
let next = String(data.count)
data.insert(next, at: 0)
}
Button("Append") {
let next = String(data.count)
data.append(next)
}
Button("Remove First") {
data.removeFirst()
}
Button("Remove Last") {
data.removeLast()
}
} label: {
Label("More", systemImage: "ellipsis.circle")
}
}
}
var dataIDText: String {
dataID.map(String.init(describing:)) ?? "None"
}
}
Accepted Answer

The new scrollPosition modifier can help you. It associated a binding to an identifier with the scroll view. When changes to the scroll view occur like items being added or the scroll view changing its containing size, it will attempt to keep the currently scrolled item in the same relative position as before the change. The docs on the website have some incorrect information but here's an example to hopefully get you started.

struct ContentView: View {
@State var data: [String] = (0 ..< 25).map { String($0) }
@State var dataID: String?
var body: some View {
ScrollView {
VStack {
Text("Header")
LazyVStack {
ForEach(data, id: \.self) { item in
Color.red
.frame(width: 100, height: 100)
.overlay {
Text("\(item)")
.padding()
.background()
}
}
}
.scrollTargetLayout()
}
}
.scrollPosition(id: $dataID)
.safeAreaInset(edge: .bottom) {
Text("\(Text("Scrolled").bold()) \(dataIDText)")
Spacer()
Button {
dataID = data.first
} label: {
Label("Top", systemImage: "arrow.up")
}
Button {
dataID = data.last
} label: {
Label("Bottom", systemImage: "arrow.down")
}
Menu {
Button("Prepend") {
let next = String(data.count)
data.insert(next, at: 0)
}
Button("Append") {
let next = String(data.count)
data.append(next)
}
Button("Remove First") {
data.removeFirst()
}
Button("Remove Last") {
data.removeLast()
}
} label: {
Label("More", systemImage: "ellipsis.circle")
}
}
}
var dataIDText: String {
dataID.map(String.init(describing:)) ?? "None"
}
}

Thank you very much for this code snippet. I watched the session video but it was not clear for me that scrollPosition modifier together with the scrollTargetLayout keep also the relative scrollview position when you add or delete items. This is exactly what I needed. Many thumbs up for this change in iOS 17.

But what about adding multiple items at the top of the list? I did that and the result was strange, when I start scrolling it couldn't scroll properly. We are developing something really important and we need that ability but Swiftui has a problem with it!

struct SwiftUIView: View {
@State var data: [String] = (0 ..< 25).map { String($0) }
@State var dataID: String?
var body: some View {
ScrollView {
VStack {
Text("Header")
LazyVStack {
ForEach(data, id: \.self) { item in
Color.red
.frame(width: 100, height: 100)
.overlay {
Text("\(item)")
.padding()
.background()
}
}
}
.scrollTargetLayout()
}
}
.scrollPosition(id: $dataID)
.safeAreaInset(edge: .bottom) {
HStack {
Text("\(Text("Scrolled").bold()) \(dataIDText)")
Spacer()
Button {
dataID = data.first
} label: {
Label("Top", systemImage: "arrow.up")
}
Button {
dataID = data.last
} label: {
Label("Bottom", systemImage: "arrow.down")
}
Menu {
Button("Batch Prepend") {
let newDatas = (data.count ..< data.count + 6).map{"New Data \($0)"}
newDatas.forEach { data in
self.data.insert(data, at: 0)
}
// data.insert(contentsOf: newDatas, at: 0) // Does not have any difference.
}
Button("Prepend") {
let next = "New Data 1"
data.insert(next, at: 0)
}
Button("Append") {
let next = String(data.count)
data.append(next)
}
Button("Remove First") {
data.removeFirst()
}
Button("Remove Last") {
data.removeLast()
}
} label: {
Label("More", systemImage: "ellipsis.circle")
}
}
}
}
var dataIDText: String {
dataID.map(String.init(describing:)) ?? "None"
}
}

@hamed8080 Adding multiple items to the top of the scrollview works as expected in my real world use and in your code above (unless I tap the "Prepend" button multiple times, which causes multiple items with the same identity "New Data 1" to be added to the array, which causes scrolling issues, as expected).

Repost of the Accepted Reply with a VStack added to the safeAreaInset and a background to the individual items within to make it readable and all the buttons clickable.

@State var data: [String] = (0 ..< 25).map { String($0) }
@State var dataID: String?
var body: some View {
ScrollView {
VStack {
Text("Header")
//.background(.white)
LazyVStack {
ForEach(data, id: \.self) { item in
Color.red
.frame(width: 100, height: 100)
.overlay {
Text("\(item)")
.padding()
.background()
}
}
}
.scrollTargetLayout()
}
}
.scrollPosition(id: $dataID)
.safeAreaInset(edge: .bottom) {
VStack {
Text("\(Text("Scrolled").bold()) \(dataIDText)")
.background(.white)
Spacer()
Button {
dataID = data.first
} label: {
Label("Top", systemImage: "arrow.up")
.background(.white)
}
Button {
dataID = data.last
} label: {
Label("Bottom", systemImage: "arrow.down")
.background(.white)
}
Menu {
Button("Prepend") {
let next = String(data.count)
data.insert(next, at: 0)
}
Button("Append") {
let next = String(data.count)
data.append(next)
}
Button("Remove First") {
data.removeFirst()
}
Button("Remove Last") {
data.removeLast()
}
} label: {
Label("More", systemImage: "ellipsis.circle")
.background(.white)
}
}
}
}
var dataIDText: String {
dataID.map(String.init(describing:)) ?? "None"
}
}

@Frameworks Engineer I've refined your code a bit with suggestions from other answers here to try and explain the question better.

struct ContentView: View {
@State var data: [String] = (0 ..< 25).map { String($0) }
@State var dataID: String?
var body: some View {
ScrollView {
VStack {
Text("Header")
LazyVStack {
ForEach(data, id: \.self) { item in
Color.red
.frame(width: 100, height: 100)
.overlay {
Text("\(item)")
.padding()
.background()
}
}
}
.scrollTargetLayout()
}
}
.scrollPosition(id: $dataID)
.safeAreaInset(edge: .bottom) {
VStack {
Text("\(Text("Scrolled").bold()) \(dataIDText)")
HStack {
Button {
scrollTo(data.first)
} label: {
Label("Top", systemImage: "arrow.up")
.frame(maxWidth: .infinity)
}
Button {
scrollTo(data.last)
} label: {
Label("Bottom", systemImage: "arrow.down")
.frame(maxWidth: .infinity)
}
Menu {
Button("Prepend") {
let items = createMoreItems()
data.insert(contentsOf: items, at: 0)
}
Button("Append") {
let items = createMoreItems()
data.append(contentsOf: items)
}
Button("Remove First") {
data.removeFirst()
}
Button("Remove Last") {
data.removeLast()
}
} label: {
Label("More", systemImage: "ellipsis.circle")
.frame(maxWidth: .infinity)
}
}
}
.background(Material.ultraThin)
}
}
var dataIDText: String {
dataID.map(String.init(describing:)) ?? "None"
}
private func scrollTo(_ dataID: String?,
animation: Animation? = .easeInOut,
position: UnitPoint = .bottom) {
withAnimation(animation) {
withTransaction(\.scrollTargetAnchor, position) {
self.dataID = dataID
}
}
}
private func createMoreItems(count: Int = 10) -> [String] {
return (0..<count).map { String(data.count + $0) }
}
}
#Preview {
ContentView()
}

While most of the times when adding a single item (append, insert) the dataID scrolled position is kept correctly, when adding multiple items (like a page) on top (prepend) it fails to maintain the right content offset, pushing the whole content down.
Notice that on this scenario dataID won't change its value.
There are a lot of hacky ways to try fix this like transforming the ScrollView and its subviews, accessing UIScrollView directly using introspect,, or re-setting dataID few moments after appending a page, but none of them is robust and most of the times they create weird scroll jumps.
I've also tried to create a custom ScrollTargetBehavior updating the scroll target to the old page content offset, but it works only when new content is added while drugging, and also creates weird scroll jumps.
Unfortunately achieving reverse scroll behavior in SwiftUI 5 feels impossible ATM.

i have same probelm with you but also solved when using .scrollPosition() .scrollTargetLayout() but sadly my app target is iOS15 is There AnyOther answer?

import Foundation
import SwiftUI
struct TestChatUIView: View {
@State var models: [TestChatModel2] = []
@State var modelId: String?
init() {
}
// Identifier
var body: some View {
ZStack(alignment: .top) {
ScrollViewReader { scrollViewProxy in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(models, id: \.id) { message in
VStack {
Text("\(message.id)")
.flipped()
.frame(minHeight: 40)
// .onAppear(perform: {
//
// if message == models.first {
// loadMore()
// } else if message == models.last {
// loadPrev()
// }
// })
}
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $modelId)
.flipped()
}
}
.safeAreaInset(edge: .bottom) {
VStack {
HStack {
Button("Prepend") {
loadPrev()
}
Button("Append") {
loadMore()
}
}
}
.background(Material.ultraThin)
}
.onAppear(perform: {
for int in 0...30 {
models.insert(.init(id: "id \(models.count + 1)", date: Date()), at: 0)
}
})
}
func loadMore() {
for i in 0...20 {
let newModel = TestChatModel2(id: "id \(models.count + 1)", date: Date())
models.insert(newModel, at: 0)
}
}
func loadPrev() {
for i in 0...20 {
let newModel = TestChatModel2(id: "- id \(models.count + 1)", date: Date())
models.append(newModel)
}
}
}
struct TestChatModel2: Identifiable, Equatable {
var id: String = UUID().uuidString
var date: Date
}
extension View {
func flipped() -> some View {
self.rotationEffect(.radians(Double.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
}

for exmaple my code is above it it works on iOS17 Obviously, but i must use ios15 so :( Needs Help!

So I had a similar problem.

I had a calendar-like view where the latest data is at the bottom.

By default, it had 12 items in a LazyVGrid (3 rows of 4 items).

You could tap "show more" to expand the size of the view to 600 in height, and also make it scrollable with the top view triggering a fetch of the next page of results.

The problem was, the act of resizing this view, and triggering the next page of results was causing the scroll view to jump to the top, rather than staying at the bottom. So essentially it would start loading all pages infinitely.

.scrollPosition and the ScrollViewProxy do not seem to work, because at the time of hitting "show more" the bottom is already visible, so the act of changing the scroll position does nothing. The only workaround I found here was to add a delay long enough for the resize animation and for enough results to load to prevent another page load from triggering. Unfortunately, this was not at all reliable.

The only fix I have found was to use .defaultScrollAnchor(.bottom) which seems to cause the scroll view to default to the bottom when it's being redrawn rather than to the top.

But unfortunately, we are on iOS 16, so I can't use that modifier either.

Keep ScrollView position when adding items on the top
 
 
Q