0

In my project I have two LazyVGrids inside the same ScrollView, which are dynamically loading content: pages of the first grid are requested every time the user is reaching the end, and when all the elements are loaded, this process is repeated with the second one. The problem is that when I go down the content of the second grid, the scroll makes a jump that puts me back to the end of the first one (much higher than where I was). From what I've seen I think it's not that the scroll actually jumps, but that for some reason the size of the first grid suddenly increases to a completely wrong one.

The bug occurs on iOS 16.4, with Xcode 15.4, and I've managed to reproduce it in a separate project of only 100 lines:

import SwiftUI

struct ContentView: View {
    @State private var primaryItems = [Int]()
    @State private var secondaryItems = [Int]()
    @State private var primaryLoading = false
    @State private var secondaryLoading = false
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(alignment: .topLeading)],
                      alignment: .leading,
                      spacing: .zero,
                      pinnedViews: .sectionHeaders) {
                Section(header: header) {
                    ForEach(primaryItems, id: \.self) { item in
                        cellView(width: UIScreen.main.bounds.size.width)
                    }
                    Color.clear
                        .frame(height: 1)
                        .onAppear {
                            loadMorePrimaryItems()
                        }
                }
            }
            LazyVGrid(columns: [GridItem(spacing: 5, alignment: .topLeading),
                                GridItem(alignment: .topLeading)],
                      alignment: .leading,
                      spacing: .zero) {
                ForEach(secondaryItems, id: \.self) { item in
                    cellView(isSecondary: true, width: (UIScreen.main.bounds.size.width - 5) / 2)
                }
                Color.clear
                    .frame(height: 1)
                    .onAppear {
                        loadMoreSecondaryItems()
                    }
            }
            .background(Color.gray)
        }
    }
    
    var header: some View {
        Text("This is a header")
            .frame(width: UIScreen.main.bounds.size.width, height: 100)
            .background(Color.red)
    }
    
    func cellView(isSecondary: Bool = false, width: CGFloat) -> some View {
        VStack(spacing: .zero) {
            (isSecondary ? Color.blue : Color.green)
                .frame(width: width, height:  200)
            
            Text("Hello, world!")
        }
    }
    
    func loadMorePrimaryItems() {
        guard !primaryLoading && primaryItems.count < 100 else { return }
        primaryLoading = true
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            let startIndex = primaryItems.count
            let endIndex = min(startIndex + 20, 100)
            let newItems = Array(startIndex..<endIndex)
            primaryItems.append(contentsOf: newItems)
            primaryLoading = false
            // Start loading secondary items if primary items reached 100
            if primaryItems.count == 100 {
                loadMoreSecondaryItems()
            }
        }
    }
    
    func loadMoreSecondaryItems() {
        guard !secondaryLoading, primaryItems.count == 100 else { return }
        secondaryLoading = true
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            let startIndex = secondaryItems.count
            let newItems = Array(startIndex..<startIndex+20)
            secondaryItems.append(contentsOf: newItems)
            secondaryLoading = false
        }
    }
}

Here's a video to better understand the problem:

enter image description here

EDIT: I have simplified the example code a lot, the bug is still happening.

2
  • You put the onAppear / load on each row. Add an empty view after each ForEach on which you set the onAppear/load Commented Jul 24, 2024 at 15:21
  • @PtitXav I just tried removing the onAppear from each row and adding a color.clear at the end, outside each ForEach. I put the load only on the onAppear of that color.clear and the error still occurs. As far as I can see, the error occurs when updating the view with the page, even if it is only requested once. Commented Jul 25, 2024 at 7:58

1 Answer 1

0

It seems that it due to redraw of ContentView caused bu secondaryItems array change. The full view is redrawn , so it seems that it loses the scroll view position. A possible solution it to handle secondaryItems in a second view :

let maxPrimaryItems = 100

struct ContentView: View {
    @State private var primaryItems = Array(0..<60)
    @State private var primaryLoading = false
    
    var body: some View {
        ScrollView {
            primaryItemsView // code lisibility
            if primaryItems.count >= maxPrimaryItems { // add secondary items when limit reached
                SecondaryItemsView() // use a separate view having its own state vars
            }
        }
    }
    
    var primaryItemsView: some View {
        LazyVGrid(columns: [GridItem(alignment: .topLeading)],
                  alignment: .leading,
                  spacing: .zero,
                  pinnedViews: .sectionHeaders) {
            Section(header: header) {
                ForEach(primaryItems, id: \.self) { item in
                    CellView(item: item, isSecondary: false, width: UIScreen.main.bounds.size.width)
                }
                Color.clear
                    .frame(height: 1)
                    .onAppear {
                        loadMorePrimaryItems()
                    }
            }
        }
    }
    
    @ViewBuilder
    var header: some View {
        Text("This is a header")
            .frame(width: UIScreen.main.bounds.size.width, height: 10)
            .background(Color.red)
    }
    
    func loadMorePrimaryItems() {
        guard !primaryLoading && primaryItems.count < maxPrimaryItems else { return }
        primaryLoading = true
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            let startIndex = primaryItems.count
            let endIndex = min(startIndex + 20, maxPrimaryItems)
            let newItems = Array(startIndex..<endIndex)
            primaryItems.append(contentsOf: newItems)
            primaryLoading = false
        }
    }
}

// to refuse same cell in both views
struct CellView: View {
    let item: Int
    let isSecondary: Bool
    let width: CGFloat
    var body: some View {
        VStack(spacing: .zero) {
            (isSecondary ? Color.blue : Color.green)
                .frame(/*width: width,*/ height:  20)
            Text("\(item) Hello, world!")
        }
    }
}

struct SecondaryItemsView: View {
    @State private var secondaryItems = [Int]() // define secondary item local to second view
    @State private var secondaryLoading = false
    
    var body: some View {
        LazyVGrid(columns: [GridItem(spacing: 5, alignment: .topLeading),
                            GridItem(alignment: .topLeading)],
                  alignment: .leading,
                  spacing: .zero) {
            ForEach(secondaryItems, id: \.self) { item in
                CellView(item: item, isSecondary: true, width: (UIScreen.main.bounds.size.width - 5) / 2)
            }
            Color.clear
                .frame(height: 1)
                .onAppear {
                    loadMoreSecondaryItems()
                }
        }
                  .background(Color.gray)
                  .onAppear() {
                      loadMoreSecondaryItems()
                  }
    }
    
    func loadMoreSecondaryItems() {
        guard !secondaryLoading else { return }
        secondaryLoading = true
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            let startIndex = secondaryItems.count
            let newItems = Array(startIndex..<startIndex+20)
            secondaryItems.append(contentsOf: newItems)
            secondaryLoading = false
        }
    }
}
Sign up to request clarification or add additional context in comments.

1 Comment

Well, it doesn't solve my real case so easily but I think it does point in the direction of trying to isolate the updates from the first grid as we move through the second grid, so I think this is the right answer.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.