iosswiftswiftuiswiftui-scrollviewlazyvgrid

Scroll jump in SwiftUI when using two LazyVGrids in a single ScrollView


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.


Solution

  • 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
            }
        }
    }