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?

Accepted Reply

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"
    }
}
  • @Frameworks Engineer is it possible to preserve visible item position for List when new row is inserted above?

  • Adding a VStack to the safeAreaInset and a background to the individual items within will make it readable and all the buttons clickable.

  • None of the codes in this page works. They cannot keep the scroll position at all.

Add a Comment

Replies

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"
    }
}
  • @Frameworks Engineer is it possible to preserve visible item position for List when new row is inserted above?

  • Adding a VStack to the safeAreaInset and a background to the individual items within will make it readable and all the buttons clickable.

  • None of the codes in this page works. They cannot keep the scroll position at all.

Add a Comment

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 tried your code on iOS 17.0.1 and iOS 17.4 simulators. It fails to maintain the content offset when prepend, append, remove first, remove last. The scroll position doesn't work at all

Add a Comment

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!