Preserve scroll position when list changes

I am working on a View that has following requirements:

  • Show list of Sections with "items" in each section.
  • A header with pills that allows filtering the "items" based on different criteria. Some pills may trigger a new API call to fetch items.
  • support two way infinite-scroll/ paginate
  • scrollTo a particular section everytime the view is loaded for the first time.
  • when user interacts with the scrollView, retain the scroll position when new data is added to the top of the list, or if the list changes based on a selected filter.

When I apply the filters and only a small number of rows are added or removed the View is well-behaved. If im adding new items to the bottom of the list, that too works well. But if a large number of fields are hidden or added by the header filter, of im adding items to the top of the list, the scroll position shifts dramatically. I want to tame this behavior as much as possible to make this less disorienting for the user. Does LazyVStack or List offer more controls that can help me achieve this? I am not experienced with UIKit but read somewhere that the Table or List in UIKit may offer more controls?

Should I do this manually? --- keep track of which sections(headers) are visible to the user using GeometryReader and then scrollTo(sectionId). Maybe wrap it inside a DispatchQueue to delay the scroll a bit so the data can load and UI had enough time to render? That seems like sad way to retain scroll position and will definitely break of the network calls occasionally takes too long.

demo.mov

import SwiftUI

@main
struct TestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class ViewModel: ObservableObject {
    var items: [Item] = []
    @Published var filteredItems: [Item] = []

    var filterEnabled: Bool {
        gotVowelSelected || shortWordsSelected || longWordsSelected
    }

    @Published var gotVowelSelected = false {
        didSet { filterData() }
    }
    @Published var shortWordsSelected = false {
        didSet { filterData() }
    }
    @Published var longWordsSelected = false {
        didSet { filterData() }
    }

    init() {
        items = createItemList(start: -2, end: 30)
        filterData()
    }

    private func createItemList(start:Int, end:Int) -> [Item] {
        var items: [Item] = []
        for num in (start...end) {
            var names: Set<String> = []
            for n in (1...Int.random(in: 2..<10)) {
                while (names.count < n) {
                    let uuid =  String(UUID().uuidString.lowercased().shuffled())
                        .components(separatedBy: CharacterSet.decimalDigits)
                        .joined().filter { $0 != "-" }
                    let name = String(num) + uuid.prefix(Int.random(in: 3..<8))
                    names.insert(name)
                }
            }
            items.append(Item(index: num, names: Array(names)))
        }
        return items
    }

    func addTop() {
        let endIndex = items.first!.index
        let newItems = createItemList(start: endIndex - 7, end: endIndex)
        filteredItems.insert(contentsOf: newItems, at: 0)
    }

    private func filterData() {
        var filteredItems: [Item] = []
        for item in items {
            var filteredNames: [String] = []
            for name in item.names {
                if (
                    !filterEnabled || // no filters selected
                    (gotVowelSelected && name.rangeOfCharacter(from: CharacterSet(charactersIn: "aeiou")) != nil) ||
                    (shortWordsSelected && name.count < 5) ||
                    (longWordsSelected && name.count >= 5)
                ) {
                    filteredNames.append(name)
                }
            }
            filteredItems.append(Item(index: item.index, names: filteredNames))
        }
        return self.filteredItems = filteredItems
    }
}

struct Item: Identifiable {
    var id: Int { return index }
    var index: Int
    var names: [String]
}

struct ContentView: View {
    @StateObject var viewModel: ViewModel = ViewModel()

    var body: some View {
        VStack {
            HStack {
                Button {
                    withAnimation {
                        viewModel.gotVowelSelected.toggle()
                    }
                } label: {
                    Text("GotVowel")
                        .foregroundColor(viewModel.gotVowelSelected ? .purple : .black )
                }
                Button {
                    withAnimation {
                        viewModel.shortWordsSelected.toggle()
                    }
                } label: {
                    Text("ShortWords")
                        .foregroundColor(viewModel.shortWordsSelected ? .purple : .black )
                }
                Button() {
                    withAnimation {
                        viewModel.longWordsSelected.toggle()
                    }
                } label: {
                    Text("LongWords")
                        .foregroundColor(viewModel.longWordsSelected ? .purple : .black )
                }
                Button() {
                    withAnimation {
                        viewModel.addTop()
                    }
                } label: {
                    Text("AddTop")
                        .foregroundColor(.black)
                }
                Spacer()
            }
            .padding([.leading, .trailing])

            Divider()

            ScrollViewReader { proxy in
                ScrollView(.vertical, showsIndicators: true) {
                    LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
                        ForEach(viewModel.filteredItems) { item in
                            Section {
                                if let names = item.names, !names.isEmpty {
                                    ForEach(names, id: \.self) { name in
                                        CellView(name: name)
                                    }
                                } else {
                                    Text("-- No Results --")
                                }
                            } header: {
                                SectionHeader(num: item.index)
                            }
                            .id(item.index)
                        }
                    }
                }
                .onAppear {
                    withAnimation {
                        proxy.scrollTo( 0, anchor: .top)
                    }
                }
            }
        }
    }
}

struct SectionHeader: View {
    var num: Int
    var body: some View {
        HStack {
            Text(String(num))
            Spacer()
        }
        .padding()
        .background(.gray)
    }
}

struct CellView: View {
    var name: String
    var body: some View {
        Text(name)
            .padding()
        Divider()
    }
}

struct CellHider: ViewModifier {
    var num: Int
    var boundaryNum: Int
    func body(content: Content) -> some View {
        if num < boundaryNum {
            content
                .frame(height: 0)
        } else {
            content
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Sorry, I can't give you a solution but only some input about changing lists like that:
The problem is always: Where do you scroll to when adding or removing elements? When you're half-way down the list and add or remove a single element manually, then you usually wouldn't want to lose your scrolling position. In your case the whole list changes, adding or removing huge chunks of data, and the part the user is scrolled to might not even exist anymore, which could get confusing easily if the app scrolls to the next/previous one automatically. My tip: Don't remember the position, just reset it to the top of the list, so the user sees everything that changed and is in the same position after every change.