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).
GridRow
is not a "real" view, in a similar way to how Group
, EmptyView
, and Section
are not "real" views. GridRow
s 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: