I have an app that displays collections of thumbnails by days, each day is its own LazyVGrid
and all days are bunched up in a VStack
.
Bottom line, it looks like adding multiple LazyVGrid
in a single ScrollView
almost works... but doesn't.
It causes erratic bahaviour while scrolling, here is an example :
Edit 4 : I think I'm making progress. This seems to be happening when the last line of the grid has space, if all lines are full, it seems to be working as expected.
Edit 1 : Changing the code here to use Identifiable (thanks Simone), but the issue persists
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
Grid(400, color: .yellow)
Grid(300, color: .blue)
Grid(300, color: .red)
}
}
}
}
struct Item: Identifiable {
var id: UUID = UUID()
var num: Int
}
struct Grid: View {
@State var items = [Item]()
let itemsCount: Int
let color: Color
init(_ itemsCount: Int, color: Color) {
self.itemsCount = itemsCount
self.color = color
}
var body: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 128.0, maximum: 128.0))],
spacing: 5
) {
ForEach(items) { i in
Text(String(i.num))
.frame(width: 128, height:96)
.background(self.color)
}
}
.onAppear {
for i in 1...itemsCount {
self.items.append(Item(num:i))
}
}
}
}
If you run this code and scroll the view, you will probably see at some point the ScrollView
jumping around.
It's especially visible with narrower windows that makes the list longer
Of course, using a single LazyVGrid
inside of ScrollView
doesn't exhibit the problem
I'va tried playing around the number of views and results seem to be random, sometimes it works for a while, sometimes it's immediately reproductible, but a narrower windows always eventually shows the issue.
I also tried removing the VStack
.
Edit 2 : Here is a visual of what is happening :
Edit 3 : I can reproduce the issue on iOS as well.
Edit 5 : Extremely ugly hack to try and fix it.
Edit 6 : Fully working code sample
The idea is to fill the remaining tiles with dummy ones, it seemed to be easy at first, but there is a problem with LazyVGrid
being completely broken in GeometryReader
.
I found this hack and used it to determine how many additional tiles were needed https://fivestars.blog/swiftui/flexible-swiftui.html
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
Grid(400, color: .yellow)
Grid(299, color: .blue)
Grid(299, color: .red)
}
}
}
}
// This extension provides a way to run a closure every time the size changes
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
extension View {
func readSize(onChange: @escaping (CGFloat) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size.width)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
struct Item: Identifiable {
var id: UUID = UUID()
var num: Int
}
struct Filler: Identifiable {
var id: UUID = UUID()
}
struct Grid: View {
@State var items = [Item]()
@State var width: CGFloat = 0 // We need to store our view width in a property
let itemsCount: Int
let color: Color
let hSpacing = CGFloat(5) // We are going to store horizontal spacing in a property to use it in different places
init(_ itemsCount: Int, color: Color) {
self.itemsCount = itemsCount
self.color = color
}
var body: some View {
// The sole purpose of this is to update `width` state
GeometryReader { geometryProxy in
Color.clear.readSize(onChange: { s in
self.width = s
})
}
// LazyVGrid start
LazyVGrid(columns: [GridItem(.adaptive(minimum: 128.0, maximum: 128.0),spacing: hSpacing)], spacing: 5) {
ForEach(items) { i in
Text(String(i.num))
.frame(width: 128, height:96)
.background(self.color)
}
// Magic happens here, add Filler tiles
ForEach(self.fillers(items.count)) { i in
Rectangle()
.size(CGSize(width: 128, height: 96))
.fill(Color.gray)
}
}
.onAppear {
for i in 1...itemsCount {
self.items.append(Item(num:i))
}
}
}
// And the last part, `fillers()` returns how many dummy tiles
// are needed to fill the gap
func fillers(_ total: Int) -> [Filler] {
guard width > 0 else {
return []
}
var result = [Filler]()
var columnsf = CGFloat(0)
while (columnsf * 128) + (columnsf - 1) * hSpacing <= width {
columnsf = columnsf+1
}
let columns = Int(columnsf - 1)
//let columns = Int(floor((width - hSpacing) / (128 + hSpacing)))
let lastRowTiles = CGFloat(total).truncatingRemainder(dividingBy: CGFloat(columns))
let c = columns - Int(lastRowTiles)
if (lastRowTiles > 1) {
for _ in 0..<c {
result.append(Filler())
}
}
return result
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
It's probably one of the ugliest hack I've had to do ever, I'm only posting it here for people stumbling on this page that are smarter than I am to implement something better.