Search code examples
iosswiftswiftuilayoutgrid

How to achieve adaptive LazyVGrid that prefers 1 or 2 columns?


I have varying amount of data that needs to be displayed as per image below, notice how when there is odd amount of data, grid adopts to stretch respected items to span across 2 columns

enter image description here

So far, my best stab at this was to have a LazyVGrid like this

LazyVGrid(
  columns: [GridItem(.adaptive(minimum: thirdOfScreen, maximum: .infinity))], 
  spacing: 8
) {
 // ...
}

here thirdOfScreen is essentially what it says, a third of the screen width. This was my attempt to force only 2 items per row (used third and not half to account for various paddings between and around items).

This worked to an extent, however if there are 1 or 3 items, they don't stretch to full width of the row.


Solution

  • It is possible to do this with a LazyVStack with HStacks inside it. You want the items in each HStack to fill the HStack equally.

    All you need to do is to split the data you want to display into chunks of 2, and use .frame(maxWidth: .infinity) on each item so they fill the HStack equally.

    Here is an example implementation that uses chunks(ofCount:) from Swift Algorithms, and Extract from View Extractor.

    import Algorithms
    import ViewExtractor
    
    struct MyGrid<Content: View>: View {
        let content: Content
        
        init(@ViewBuilder content: () -> Content) {
            self.content = content()
        }
        
        var body: some View {
            Extract(content) { views in
                let chunksOf2 = views.chunks(ofCount: 2)
                LazyVStack {
                    // use the id of the first view in the chunk of 2 as the id of the ForEach
                    ForEach(chunksOf2, id: \.first?.id) { rowViews in
                        HStack {
                            ForEach(rowViews) { view in
                                view
                                    .frame(maxWidth: .infinity)
                            }
                        }
                    }
                }
            }
        }
    }
    

    You technically don't need to add any dependencies if you don't like dependencies. It's not hard to write a chunks(ofCount:) yourself, and View Extractor is small enough that you can easily look at its source code and write something similar yourself.

    Usage:

    struct ContentView: View {
        var body: some View {
            MyGrid {
                ForEach((0..<10).map { String($0) }, id: \.self) { i in
                    Text(i)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(.yellow)
                }
            }
        }
    }
    

    The slight downside of this approach is that animations look a bit janky if an item is inserted in/removed from the "grid", except in the last row, where the animation looks fine.