Skip to content

SwiftUI layout, safe areas, stacks, grids, and custom layout

SwiftUI layout is a negotiation. A parent proposes a size, a child reports the size it wants within that proposal, then the parent places the child.

Read a frame as a request

Text(note.title)
.frame(maxWidth: .infinity, alignment: .leading)

The frame offers its child space and reports a framed size upward. It does not mutate the intrinsic size of Text. Fixed width and height values can be appropriate for icons or bounded artwork, but they are poor defaults for localized text and resizable windows.

Use stacks for relationships

VStack(alignment: .leading, spacing: 8) {
Text(note.title).font(.headline)
HStack {
Label(note.placeName, systemImage: "mappin")
Spacer()
Text(note.updatedAt, format: .dateTime.hour().minute())
}
.font(.caption)
.foregroundStyle(.secondary)
}

Alignment and spacing express relationships. Spacer consumes flexible space. Layout priority helps resolve competition, but it does not repair a composition that has no workable compact form.

Let grids adapt to available width

let columns = [
GridItem(.adaptive(minimum: 140), spacing: 12)
]
LazyVGrid(columns: columns, spacing: 12) {
ForEach(notes) { note in
NoteCard(note: note)
}
}

Adaptive columns respond to container space instead of device names. Lazy containers create children as they become needed, which matters for large collections. They do not remove the need for stable IDs or cheap row computation.

Safe areas carry system meaning

Content normally respects bars, sensors, rounded corners, and system regions. Backgrounds may extend farther than interactive content:

ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
NoteList()
.safeAreaPadding(.horizontal)
}

Do not ignore the safe area for the entire screen merely to make a color reach the edge. Keep controls, text, and gestures in usable regions. Keyboard and container safe areas also need observation in the actual app.

A custom tag flow layout

Stacks cannot wrap tags. The Layout protocol can measure and place them while preserving ordinary child views:

struct TagFlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let result = rows(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let result = rows(proposal: proposal, subviews: subviews)
for item in result.items {
subviews[item.index].place(
at: CGPoint(x: bounds.minX + item.origin.x,
y: bounds.minY + item.origin.y),
proposal: .unspecified
)
}
}
}

The omitted rows helper performs one deterministic measurement pass: place a chip on the current row if it fits, otherwise start a new row. Production code should cache measurements when profiling shows repeated work and should account for spacing, empty input, narrow proposals, and right-to-left placement.

Use it like any container:

TagFlowLayout {
ForEach(note.tags) { tag in
TagChip(name: tag.name)
}
}

Test the layout as a matrix

One screenshot proves one proposal. Check:

PressureExpected response
narrow phonechips wrap without horizontal clipping
wide windowrows use space without extreme fixed gaps
accessibility textchip height grows and rows reflow
long localizationlabels wrap or expand by declared policy
right-to-leftsemantic order and placement remain coherent
empty tagscontainer reports a stable empty size

Geometry readers and preference keys have valid uses, but adding measurement feedback can create update loops. Prefer standard containers and the layout protocol before measuring global coordinates.

Validation boundary

The layout code is illustrative and was not compiled or rendered. The custom helper is intentionally incomplete until a later companion-app implementation supplies executable tests. Xcode, previews, Simulator sizes, Dynamic Type, and right-to-left placement remain Not verified.

Series navigation

References