LazyVGrid and scrollTo issue in SwiftUI
I have encountered an issue while implementing the scrollTo mechanism for a lengthy list of products displayed using LazyVGrid
in SwiftUI.
Does anyone have a potential solution for this problem?
Below is the code to reproduce the issue:
import SwiftUI
struct Product: Identifiable {
let id = UUID()
let name: String
let price: Double
let imageName: String
}
struct Category: Identifiable {
let id = UUID()
let name: String
let products: [Product]
}
class MockData {
static func generate() -> [Category] {
var categories = [Category]()
for i in 1...30 {
let productCount = Int.random(in: 5...50)
var products = [Product]()
for j in 1...productCount {
products.append(Product(name: "Product \(j)", price: Double.random(in: 10...100), imageName: "photo"))
}
categories.append(Category(name: "Category \(i)", products: products))
}
return categories
}
}
struct ContentView: View {
let categories = MockData.generate()
@State private var selectedCategoryIndex: Int? = nil
var body: some View {
NavigationView {
ScrollViewReader { scrollView in
VStack {
ScrollView(.horizontal) {
HStack {
ForEach(categories.indices, id: \.self) { index in
Button(action: {
withAnimation {
scrollView.scrollTo(categories[index].id, anchor: .top)
}
}) {
Text(categories[index].name)
.padding()
.background(Color.gray.opacity(0.2))
.cornerRadius(8)
}
}
}
.padding()
}
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())]) {
ForEach(categories) { category in
Section(header: Text(category.name).font(.headline).padding()) {
ForEach(category.products) { product in
VStack {
Image(systemName: product.imageName)
.resizable()
.frame(width: 120, height: 120)
Text(product.name)
Text("$\(product.price, specifier: "%.2f")")
.font(.subheadline)
.foregroundColor(.gray)
}
.padding()
.background(Color.white)
.cornerRadius(8)
.shadow(radius: 2)
}
}
}
}
}
}
}
}
}
}
EDIT: I found that it also happens with LazyVStack
.
ScrollView {
LazyVStack {
ForEach(categories.indices, id: \.self) { index in
Section(header: Text(categories[index].name)
.font(.headline)
.padding()
.id(categories[index].id)) {
ForEach(categories[index].products) { product in
HStack {
Image(systemName: "photo")
.resizable()
.frame(width: 50, height: 50)
.padding()
VStack(alignment: .leading) {
Text(product.name)
Text(String(format: "$%.2f", product.price))
}
Spacer()
}
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 2)
.padding(.horizontal)
}
}
}
}
.padding(.bottom, 50)
}
I would expect that after tapping on category my list will be scrolled like this:
But when going through categories and tapping on them I randomly end up with some offset, eg. it looks then like this:
Any Idea how I can fix it?
Answer
I am not sure if I have a direct solution for this issue, but the code below might just be less glitchy than the initial tests and maybe it will get you closer to figuring it out.
This glitch behaviour does indeed seem to be related to the use of lazy stacks. If you try switching to a regular grid or replacing the grid with simply a VStack, does it still happen?
Also, try to replicate the issue without the section header text with the category name. Chances are it will be considerably less buggy.
Based on my findings, the main issue may be with the use of grid items of multiple sizes. In your case, although every category box appears of same size, you do have the section header text which is not the same size of the grid items.
In iOS 17, there is the .scrollPosition modifier that works directly with ScrollView, without the need for a ScrollViewReader. I used that instead, in order to move the category name above the buttons and have it be updated automatically based on whatever product is in view. That's pretty nifty. You can read more about it here.
(In this case, given the 2 column grid, it may not really apply, but that's up to you.)
So basically what happens now, is that when a button is clicked, it sets the state variable $targetID with the id of the category. That variable is used by the scroll view which monitors the target layout (the area of the lazy stack) for when the category is in view (also know as its identity state). Supposedly, at least.
The thing is, that the .scrollPosition modifier only returns the top most item in the view. And I think because of the double ForEach, in this case it's actually a product. The video session linked above shows things done differently, with just one loop and a child view binding. You can attempt that as an exercise.
Anyway, so by moving the title out of the scroll view, hopefully everything in it is more equal with less size variations and less glitch overall.
Note: You can actually remove ScrollViewReader entirely if using .scrollPosition, I left it there for testing.
import SwiftUI
struct Product: Identifiable {
let id = UUID()
let name: String
let price: Double
let imageName: String
}
struct Category: Identifiable {
let id = UUID()
let name: String
let products: [Product]
}
class MockData {
static func generate() -> [Category] {
var categories = [Category]()
for i in 1...30 {
let productCount = Int.random(in: 5...50)
var products = [Product]()
for j in 1...productCount {
products.append(Product(name: "Product \(j)", price: Double.random(in: 10...100), imageName: "photo"))
}
categories.append(Category(name: "Category \(i)", products: products))
}
return categories
}
}
struct TestView: View {
let categories = MockData.generate()
@State private var selectedCategoryName: String? = nil
@State private var targetID: Category.ID? = nil
var body: some View {
NavigationView {
ScrollViewReader { scrollView in
VStack(spacing: 0) {
//since .scrollPosition will return a product ID on scrolling, need to know if we're dealing with an ID from the button or an id from the scroll position
let targetIsProduct = categories.contains(where: {$0.products.contains{$0.id == $targetID.wrappedValue}})
let products = categories.map{$0.products}.first(where: {$0.contains(where: {$0.id == $targetID.wrappedValue})})
let productName = products?.first(where: {$0.id == $targetID.wrappedValue})?.name ?? "Product 1"
let categoryName = categories.first(where: {$0.products.contains{ $0.id == $targetID.wrappedValue}})?.name
Text((targetIsProduct ? categoryName : selectedCategoryName) ?? "Category 1").font(.headline).bold()
Text(productName)
ScrollView(.horizontal) {
HStack {
ForEach(categories) { category in
Button(action: {
withAnimation {
//scrollView.scrollTo(category.id, anchor: .top)
selectedCategoryName = category.name
targetID = category.id
}
}) {
Text(category.name)
.padding()
.background(Color.gray.opacity(0.2))
.cornerRadius(8)
}
}
}
.padding()
}
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())]) {
ForEach(categories) { category in
Section/*(header: Text(category.name).font(.headline).padding())*/ {
ForEach(category.products) { product in
VStack {
Image(systemName: product.imageName)
.resizable()
.frame(width: 120, height: 120)
Text(product.name)
Text("$\(product.price, specifier: "%.2f")")
.font(.subheadline)
.foregroundColor(.gray)
}
.padding()
.background(Color.white)
.cornerRadius(8)
.shadow(radius: 2)
}
}
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $targetID)
}
}
}
}
}