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:
EDIT: I have simplified the example code a lot, the bug is still happening.
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
}
}
}