4

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 :

GIF showing issue

Edit 3 : I can reproduce the issue on iOS as well.

GIF showing issue on iOS

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.

Yann Bizeul
  • 403
  • 3
  • 10
  • Thanks very much for your 'Edit 6' Yann. I found very similar behaviour in my project and your solution (with a few edits for my particular situation) solved it for me. In my case it worked great in Portrait, but in landscape, when the height of a cell in the last section exceeded the view height, and only the last section was visible on screen, when scrolling up, it wouldn't load the previous section anymore. It just kept loading the last section over and over. Adding filler cells according to your method solved it! Indeed an ugly workaround, but until Apple solves this bug, necessary! – guido Jul 25 '22 at 17:02

1 Answers1

0

your Grid is lazy, so is not loading all the items in once. Your ID in foreach is not unique and is in conflict with your previous integer in Grid. Build something like identifiable struct

enter image description here

    struct SwView: View {
        var body: some View {
       
            ScrollView {
                Grid(100, color: .yellow)
                Grid(200, color: .blue)
                Grid(200, color: .red)
            }
            
    }
}

struct Item: Identifiable {
    var id: UUID = UUID()
    var num: Int
}

struct Grid: View {
    
    @State var items: [Item] = []
    var color: Color = .white
    let itemsCount: Int
    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(color)
                }
            }
        .onAppear() {
            for i in 1...itemsCount {
                self.items.append(Item(num: i))
            }
            print(items.count)
        }
    }
}
Simone Pistecchia
  • 2,746
  • 3
  • 18
  • 30