Search code examples
swiftuiswiftui-listswiftui-view

SwiftUI ScrollView doesn't compute custom StaggeredGrid height


I am trying to create a new grid structure in SwiftUI to show cards with variable heights. To that extent, I use several LazyVStacks in a HStack and have a condition to display my data items in the correct order. This view works alone, adapts the number of columns to the size of the screen, but when using it in a ScrollView, its size is not computed properly and the following views end up beneath the grid instead of below it. Here is the code I used for the grid :

struct StaggeredGrid<Element, Content>: View where Content: View {

    var preferredItemWidth: CGFloat
    var data: [Element] = []

    var content: (Element) -> Content

    init(preferredItemWidth: CGFloat, data: [Element], @ViewBuilder content: @escaping (Element) -> Content) {
        self.preferredItemWidth = preferredItemWidth
        self.data = data
        self.content = content
    }

    var body: some View {
        GeometryReader { geometry in
            HStack(alignment: .top, spacing: 20) {
                ForEach(1...Int(geometry.size.width / preferredItemWidth), id: \.self) { number in
                    LazyVStack(spacing: 20) {
                        ForEach(0..<data.count, id: \.self) { index in
                            if (index+1) % Int(geometry.size.width / preferredItemWidth) == number % Int(geometry.size.width / preferredItemWidth) {
                                content(data[index])
                            }
                        }
                    }
                }
            }
            .padding([.horizontal])
        }
    }
}

And the preview to show the behavior :

struct StaggeredGrid_Previews: PreviewProvider {
    static var previews: some View {
        ScrollView {
            VStack {
                StaggeredGrid(preferredItemWidth: 160, data: (1...10).map{"Item \($0)"}) { item in
                    Text(item)
                        .foregroundColor(.blue)
                }
                Text("I should be below the grid")
            }
        }
    }
}

Here is a picture of the preview: Wrong appearance in a ScrollView

And a picture when the ScrollView is commented out: Expected behavior, ScrollView removed

Thank you in advance for any help or clue about this behavior I do not understand.


Solution

  • I quote @Asperi : "ScrollView has no own size and GeometryReader has no own size, so you've got into chicken-egg problem, ie. no-one knows size to render, so collapsed. You must have definite frame size for items inside ScrollView."

    Here you have to set the height of your StaggeredGrid, or the GeometryReader it contains. In your case its height depends of course on the height of its content (i.e. the HStack). You can read this height (the height of its background / overlay for example) with a Reader. And use it to definite the frame size of your GeometryReader

    For example :

    struct StaggeredGrid<Element, Content>: View where Content: View {
    
        var preferredItemWidth: CGFloat
        var data: [Element] = []
    
        var content: (Element) -> Content
    
        init(preferredItemWidth: CGFloat, data: [Element], @ViewBuilder content: @escaping (Element) -> Content) {
            self.preferredItemWidth = preferredItemWidth
            self.data = data
            self.content = content
        }
        
        @State private var gridHeight: CGFloat = 100
    
        var body: some View {
            GeometryReader { geometry in
                HStack(alignment: .top, spacing: 20) {
                    ForEach(1...Int(geometry.size.width / preferredItemWidth), id: \.self) { number in
                        LazyVStack(spacing: 20) {
                            ForEach(0..<data.count, id: \.self) { index in
                                if (index+1) % Int(geometry.size.width / preferredItemWidth) == number % Int(geometry.size.width / preferredItemWidth) {
                                    content(data[index])
                                }
                            }
                        }
                    }
                }
                .overlay(GeometryReader { proxy in
                    Color.clear.preference(
                        key: HeightPreferenceKey.self,
                        value: proxy.size.height
                    )
                })
                .onPreferenceChange(HeightPreferenceKey.self) {
                    gridHeight = $0
                }
                .padding([.horizontal])
            }
            .frame(height: gridHeight)
        }
    }
    
    private struct HeightPreferenceKey: PreferenceKey {
        static let defaultValue: CGFloat = 0
    
        static func reduce(value: inout CGFloat,
                           nextValue: () -> CGFloat) {
            value = nextValue()
        }
    }