Use a custom SwiftUI Layout to give the first cell two column slots, then fill the remaining cells across successive rows. This macOS example recalculates its columns when the window changes width and measures each row’s height from its content.
The task is a small desktop palette: one introductory tile followed by eleven numbered buttons. The first tile is wider whenever at least two columns fit. At narrow widths, it becomes a single-column tile so the content can continue vertically. All labels and selections in the example are synthetic.
Which SwiftUI container supports this arrangement?
Apple’s Grid supports a cell spanning multiple columns through gridCellColumns(_:) inside a GridRow. However, you supply the rows yourself. A view placed directly inside Grid, outside a GridRow, spans the entire grid instead. Neither arrangement automatically packs a flat collection into width-dependent rows. See Apple’s Grid documentation and gridCellColumns(_:) documentation.
LazyVGrid supplies automatic row filling: it places children across columns and continues into subsequent rows. Its documented column configuration does not expose the GridRow mechanism for an individual cell spanning columns. Making the first child visually wider does not establish a documented two-column allocation. See Apple’s LazyVGrid documentation.
Choose the container according to the behavior you need:
| Requirement | Suitable approach |
|---|---|
| Explicit rows with a cell spanning two columns | Grid, GridRow, and gridCellColumns(2) |
| Automatically filled rows of ordinary cells | LazyVGrid |
| Automatically filled rows plus a first cell spanning two slots | A custom Layout implementing that packing rule |
For this last case, Apple’s supported extension point is the Layout protocol. Its two essential methods report the container’s size and place its children. The implementation below is an original packing algorithm built on those documented methods. Layout — Apple
Define the sizing rules before writing the layout
This example uses a minimum ordinary column width of 96 points and an 8-point gap. Columns expand equally to use the available width. The first child consumes two slots, including the gap between them; every other child consumes one. Children fill rows in their existing order, and each row is as tall as its tallest child.
For available width W, minimum column width m, and gap g, the column count is max(1, floor((W + g) / (m + g))). The actual ordinary column width is (W - (columns - 1) × g) / columns. A two-slot tile therefore receives 2 × columnWidth + g points.
For example, a 408-point content area holds four 96-point columns separated by three 8-point gaps. The introductory tile is 200 points wide. Two ordinary buttons fit beside it, and subsequent buttons fill four slots per row. These are illustrative calculations from the chosen constants, not measurements of a running app.
SwiftUI can propose an unspecified, zero, or infinite size while investigating a layout’s flexibility. These proposals need deliberate responses. This implementation uses up to four columns for its ideal width, one column for its minimum width, and enough columns for every child to share one row when asked for maximum width. It always returns a finite width. Apple explains these proposal meanings in ProposedViewSize.
Implement the layout in a macOS app
Create a SwiftUI macOS app in Xcode and set its deployment target to macOS 13 or later, where Layout is available. Add a Swift file named WideFirstGrid.swift containing the following complete layout type. Layout — Apple
import SwiftUI
struct WideFirstGrid: Layout {
let minimumColumnWidth: CGFloat = 96
let gap: CGFloat = 8
struct Plan {
var size: CGSize
var frames: [CGRect]
}
func makePlan(width proposedWidth: CGFloat?, subviews: Subviews) -> Plan {
guard !subviews.isEmpty else {
return Plan(size: .zero, frames: [])
}
let totalSlots = subviews.count + (subviews.count > 1 ? 1 : 0)
let idealColumns = min(4, totalSlots)
let idealWidth = CGFloat(idealColumns) * minimumColumnWidth
+ CGFloat(idealColumns - 1) * gap
let maximumWidth = CGFloat(totalSlots) * minimumColumnWidth
+ CGFloat(totalSlots - 1) * gap
let width: CGFloat
if let proposedWidth {
if proposedWidth.isFinite {
width = max(minimumColumnWidth, proposedWidth)
} else {
width = maximumWidth
}
} else {
width = idealWidth
}
let columns = max(1, Int(floor(
(width + gap) / (minimumColumnWidth + gap)
)))
let columnWidth = (width - CGFloat(columns - 1) * gap)
/ CGFloat(columns)
var frames: [CGRect] = []
var column = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for (index, subview) in subviews.enumerated() {
let span = index == 0 ? min(2, columns) : 1
if column + span > columns {
y += rowHeight + gap
column = 0
rowHeight = 0
}
let cellWidth = CGFloat(span) * columnWidth
+ CGFloat(span - 1) * gap
let measured = subview.sizeThatFits(
ProposedViewSize(width: cellWidth, height: nil)
)
frames.append(CGRect(
x: CGFloat(column) * (columnWidth + gap),
y: y,
width: cellWidth,
height: measured.height
))
rowHeight = max(rowHeight, measured.height)
column += span
}
return Plan(
size: CGSize(width: width, height: y + rowHeight),
frames: frames
)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
makePlan(width: proposal.width, subviews: subviews).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let plan = makePlan(width: bounds.width, subviews: subviews)
for (subview, frame) in zip(subviews, plan.frames) {
subview.place(
at: CGPoint(
x: bounds.minX + frame.minX,
y: bounds.minY + frame.minY
),
anchor: .topLeading,
proposal: ProposedViewSize(width: frame.width, height: nil)
)
}
}
}
The same planning function drives measurement and placement. Each child is measured at its allocated width with an unspecified height, then placed using that same child-size proposal. This keeps wrapping text and row-height calculations consistent. Apple specifically requires compatible computations across sizing and placement. placeSubviews(in:proposal:subviews:cache:) — Apple
Placement adds bounds.minX and bounds.minY to each calculated position. The parent’s allocated bounds need not start at zero. Each child receives one placement call, and those calls occur only inside placeSubviews, as required by the LayoutSubview placement contract.
Add the synthetic tiles and button actions
Replace the generated app entry file with the following code, leaving exactly one @main declaration in the project. Together with WideFirstGrid.swift, this is the complete example. The introductory text is the first layout child; each iteration supplies one button.
import SwiftUI
@main
struct WideFirstGridDemoApp: App {
var body: some Scene {
WindowGroup {
PaletteView()
.frame(minWidth: 240, minHeight: 260)
}
}
}
struct PaletteView: View {
@State private var selectedNumber: Int? = nil
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Tile palette")
.font(.title2)
Text(selectedNumber.map { "Selected tile: \($0)" }
?? "Choose a numbered tile")
ScrollView {
WideFirstGrid {
Text("Choose a tile from this sample collection")
.font(.headline)
.padding(12)
.frame(
maxWidth: .infinity,
minHeight: 64,
alignment: .leading
)
.background(
Color.blue.opacity(0.12),
in: RoundedRectangle(cornerRadius: 10)
)
ForEach(1...11, id: \.self) { number in
Button {
selectedNumber = number
} label: {
Text("Tile \(number)")
.padding(12)
.frame(maxWidth: .infinity, minHeight: 64)
.background(
Color.gray.opacity(0.12),
in: RoundedRectangle(cornerRadius: 10)
)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
}
}
The fixed numeric collection gives each button a unique, stable identifier through id: \.self. The layout computes geometry through subview proxies; selection remains in the view’s state and changes only in the button action. Apple documents that ForEach requires identifiable elements or an explicit identifier. ForEach — Apple
The ScrollView accommodates additional rows. The layout intentionally computes its natural content height rather than compressing rows to fit the proposed height. A taller text label increases its row’s height, and later rows move down accordingly. Shorter children stay aligned at the top of that row.
Check resizing, narrow widths, and layout lifecycle
Build and run the macOS target, then resize the window horizontally. The following expected results use the grid’s available content width, after the surrounding padding. They assume the introductory tile plus eleven buttons, for twelve children total.
| Grid content width | Columns | First-cell width | Total rows |
|---|---|---|---|
| 96 points | 1 | 96 points | 12 |
| 200 points | 2 | 200 points | 7 |
| 304 points | 3 | 200 points | 5 |
| 408 points | 4 | 200 points | 4 |
The window’s minimum width means the 96-point case needs a separate narrow test container or a temporarily reduced minimum window width. Also test an empty layout, a layout containing only the introductory tile, and a longer introductory label. Empty content returns zero size; a lone first tile can occupy both available slots without requiring another child.
The packing arithmetic was checked with a deterministic Python calculation at the four widths above, including checks for overlapping slots and column overflow. The SwiftUI application was inspected but was not compiled or run in the research environment, which had no Swift compiler. Confirm the visual behavior in Xcode before adapting the example.
SwiftUI may call sizeThatFits repeatedly with different proposals before choosing a size. Keep measurement free of selection changes, child placement, or other application actions. This example uses the default empty cache and recomputes its plan, so a previous width cannot leave behind stale row positions. Apple describes this repeated measurement behavior in sizeThatFits(proposal:subviews:cache:).
Understand the boundaries before extending it
The 96-point minimum is a deliberate layout policy. If a parent offers less width, the layout still reports 96 points and may extend beyond that allocation. The supplied window minimum avoids that situation. To support narrower containers, lower the minimum column width and check the labels at that width.
A size proposal is a request: a child can choose a different size. The supplied tiles are designed to accept their allocated width. Replacing them with content that insists on a larger fixed width can cause overlap, so revise the sizing policy before introducing such children. place(at:anchor:proposal:) — Apple
This implementation also uses physical left-to-right placement and measures every child. It does not implement direction-dependent ordering or lazy creation. For substantially larger collections, profile the result before selecting a different container. Apple recommends moving from a regular grid to a lazy grid when profiling demonstrates a worthwhile performance improvement; that change still requires reconsidering the spanning requirement. Grid — Apple
For a small Mac palette, the shared planning function makes the behavior straightforward to adapt: change the column minimum or gap, keep measurement and placement consistent, and retain the one-column fallback. The first tile then stays wide when space permits while every remaining button flows into the next available slot.
Sources
- Grid — Apple
- gridCellColumns(_:) — Apple
- LazyVGrid — Apple
- Layout — Apple
- ProposedViewSize — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- placeSubviews(in:proposal:subviews:cache:) — Apple
- place(at:anchor:proposal:) — Apple
- ForEach — Apple