swiftuigeometryreaderswiftui-gridrow

GridRow clickable area


In SwiftUI, I have a Grid containing some GridRow.
My goal is to display a popover on this GridRow when it is clicked.
The issue is that if I apply a view modifier to my GridRow, the modifier is applied to every child view of the GridRow, leading to a buggy behavior as it is trying to display a popover on every child view.
I tried to wrap the GridRow with VStack, HStack or ZStack but then the Grid doesn't work as it recognizes the stack as the only child.
overlay or background modifiers on the GridRow don't work either as it is applied to every child.

Here is a code sample that reproduce the issue:

struct MyList: View {
    @State private var showPopover = false

    var body: some View {
        Grid {
            GridRow {
                Text("Hello")
                Text("World")
            }
            .overlay { // Apply on every view
                Rectangle().fill(Color.red.opacity(0.2))
            }
            .onTapGesture {
                showPopover = true
            }
            .popover(isPresented: $showPopover) {
                Text("This is a popover!")
            }
        }
    }
}

So I'm looking for some kind of trick, maybe with a GeometryReader and some Rectangle with offset between each row to cover them but I can't come up with a proper solution. I'm also stuck with Grid for my use case (the others kinds of grids don't work in my use case).


Solution

  • GridRow is not a "real" view, in a similar way to how Group, EmptyView, and Section are not "real" views. GridRows merely adds some information about which views belong to the same row, and this information is then used by the Grid to layout the views. We cannot access the internals of Grid to see the position and height of each row.

    One way around this is to measure the position and height of the row using onGeometryChange on each cell in the row. We send that information up the view hierarchy using a PreferenceKey, and then the overlay for that row can be put on the Grid using an overlayPreferenceValue.

    struct RowGeometry: Hashable {
        var minY: CGFloat
        var height: CGFloat
    }
    
    struct RowGeometryKey: PreferenceKey {
        static let defaultValue: [String: RowGeometry] = [:]
        
        static func reduce(value: inout [String: RowGeometry], nextValue: () -> [String: RowGeometry]) {
            value.merge(nextValue(), uniquingKeysWith: {
                RowGeometry(minY: min($0.minY, $1.minY), height: max($0.height, $1.height))
            })
        }
    }
    
    struct RowHeightPreferenceWriter: ViewModifier {
        @State private var geometry = RowGeometry(minY: 0, height: 0)
        let rowName: String
        @Environment(\.gridCoordinateSpace) var coordinateSpace
        
        func body(content: Content) -> some View {
            content
                .onGeometryChange(for: RowGeometry.self, of: { proxy in
                    let frame = proxy.frame(in: coordinateSpace)
                    return RowGeometry(minY: frame.minY, height: frame.height)
                }) { newValue in
                    geometry = newValue
                }
                .preference(key: RowGeometryKey.self, value: [rowName: geometry])
        }
    }
    
    extension EnvironmentValues {
        @Entry var gridCoordinateSpace: any CoordinateSpaceProtocol = .global
    }
    

    The idea is to modify each view in the row with RowHeightPreferenceWriter, and then reduce in the preference key will calculate the minimum y coordinate of all the views (i.e. the y coordinate of the whole row), as well as the maximum height of all the views (i.e. the height of the whole row).

    RowHeightPreferenceWriter takes a name, so that different rows can be identified. This is so that you can add different overlays to different rows. It also expects a gridCoordinateSpace from the environment. This is expected to be the coordinate space of the Grid. We will inject this environment using this modifier:

    struct GridCoordinateSpaceModifier: ViewModifier {
        @Namespace var ns
        
        func body(content: Content) -> some View {
            content
                .coordinateSpace(.named(ns))
                .environment(\.gridCoordinateSpace, .named(ns))
        }
    }
    

    Let's make these ViewModifiers into View extensions,

    extension View {
        func row(name: String) -> some View {
            modifier(RowHeightPreferenceWriter(rowName: name))
        }
        
        func grid() -> some View {
            modifier(GridCoordinateSpaceModifier())
        }
    }
    

    Now we can finally create the grid:

    Grid {
        GridRow {
            Text("Hello")
                .row(name: "one")
            Text("World")
                .row(name: "one")
        }
        GridRow {
            Text("Row 2")
                .row(name: "two")
            Text("Line 1\nLine 2")
                .row(name: "two")
        }
    }
    .border(.red)
    .grid()
    .overlayPreferenceValue(RowGeometryKey.self) { dict in
        ZStack(alignment: .top) {
            Color.clear
            if let geometry = dict["one"] {
                Rectangle().fill(.green.opacity(0.2))
                    .frame(height: geometry.height)
                    .offset(y: geometry.minY)
            }
            if let geometry = dict["two"] {
                Rectangle().fill(.red.opacity(0.2))
                    .frame(height: geometry.height)
                    .offset(y: geometry.minY)
            }
        }
    }
    

    This example shows two rows, each with their own overlay. Everything is put into a top-aligned ZStack, so that the overlays can be positioned simply using offset.


    Finally, writing .row(name: ...) for every view in a row is tedious. We can write a wrapper around GridRow to simplify this:

    struct OverlayableGridRow<Content: View>: View {
        let name: String
    
        let content: Content
        
        init(name: String, @ViewBuilder content: () -> Content) {
            self.name = name
            self.content = content()
        }
        
        var body: some View {
            GridRow {
                ForEach(subviews: content) {
                    $0.row(name: name)
                }
            }
        }
    }
    
    // ...
    
    Grid {
        OverlayableGridRow(name: "one") {
            Text("Hello")
            Text("World")
        }
        OverlayableGridRow(name: "two") {
            Text("Row 2")
            Text("Line 1\nLine 2")
        }
    }
    ...
    

    Output:

    enter image description here