7

I create a simple vertical list that contain items to show.

The problem is when I insert items at the top of the list via insertAtTop(). The scroll view does not stay in the same place.

See the video: https://streamable.com/i7ywab


How can I make the scroll view stay in the same position?

Here is the sample code.

struct TestView: View {
    
    @State private var items: [Item] = []
    
    var body: some View {
        NavigationView {
            ScrollView {
                LazyVStack {
                    ForEach(items, id: \._id) { item in
                        Text(item.text)
                            .background(Color.green)
                            .padding(.bottom, 10)
                    }
                }
            }
            .navigationBarItems(trailing: HStack {
                Button("[Insert at top]", action: {
                    insertAtTop()
                })
                Spacer(minLength: 20)
                Button("[Load]", action: {
                    load()
                })
            })
            .navigationTitle("Item List")
        }
    }
    
    private func load() {
        items = (1...50).map { _ in Item() }
    }
    
    private func insertAtTop() {
        let newItems = (1...20).map { _ in Item() }
        items.insert(contentsOf: newItems, at: 0)
    }
    
}

struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView()
    }
}

struct Item {
    var _id = UUID()
    var text = UUID().uuidString.prefix(5)
}
nRewik
  • 8,958
  • 4
  • 23
  • 30
  • Well, actually it maintains scroll position but changes content and, as I assume, you want to change scroll position to preserve previously visible content. – Asperi Jun 27 '20 at 08:44
  • Yeah, that what I want to solve. As you mentioned, the scroll offset actually does not change, but the content size has changed. I can solve this with UITableView, by setting offset after reloadData. – nRewik Jun 27 '20 at 08:59
  • Did you figure this one out @nRewik? I’m having a similar issue here: https://stackoverflow.com/questions/65614647/infinite-vertical-scrollview-both-ways-add-items-dynamically-at-top-bottom-tha – Barrrdi Jan 07 '21 at 15:26

2 Answers2

0

The problem only seems to arise if you add new views at the top of the list. If you modify or replace existing views, there isn't a problem.

...so one workaround is to loop your ForEach over a placeholder array with a very large number of views in the negative direction (e.g. 10000). Then in your ForEach, check the index to see whether it exists in the real/non placeholder array.

If so -> the real view If not -> a cheap view (e.g. Rectangle() with the right background colour and a height of 1).

LazyVStack {
    ForEach(placeholderIndices, id: \.self) { index in
        if indices.contains(index) {
            REAL VIEW
        } else {
            Rectangle()
                .fill(Color.bg)
                .frame(height: 0)
        }
    }
}

When you expand your view, placeholderIndices stays the same; only the 'real' indices change

Olcay Ertaş
  • 5,987
  • 8
  • 76
  • 112
Jack Solomon
  • 880
  • 1
  • 8
  • 19
0

I faced the same problem, but decided to take another way to solve it by simply saving the id of the top element (I'm inserting items to the top) to @State before fetching a new pack of items. Maybe it could help someone in the future :)

@State private var lastId = -1

ScrollView {
    ScrollViewReader { value in
        LazyVStack {
            
            ForEach(Array(items.enumerated()), id: \.element) { index, item in
                
                Element
                    .id(item.id)
                    .onAppear {
                       if (items.first == message) {
                          lastId = item.id
                          doFetch()
                       }
                    }
               
            }
            // listening to the changes in items list (the one I update) p.s. I have items in the viewmodel
            .onChange(of: items, perform: { new in
                // on first fetch I scroll to the bottom
                if (lastId == -1) {
                    value.scrollTo(items.last?.id ?? 0)
                } else {
                    // on any next I scroll to the last saved id
                    value.scrollTo(self.lastId)
                }               
            })       
        }    
    }
}