Stop a SwiftUI geometry loop by removing the path from a measured size back into the frame that produced it. For a Mac window containing labels that share a width, calculate their sizes inside a Layout container instead of storing measurements in state.
This walkthrough builds a small label panel inside your own app’s window. The labels use synthetic local text, share a width, and wrap when the window narrows. The complete example targets macOS 13 or later; later sections explain how to apply the same sizing discipline when your interface includes AppKit text views.
How does a geometry measurement become a layout loop?
SwiftUI layout involves proposals and responses: a parent proposes a size, and its children report the sizes they choose. A custom container participates in that negotiation through ProposedViewSize. Measuring a view repeatedly is therefore normal; repeated measurements alone do not establish that something is wrong. See Apple’s ProposedViewSize documentation.
The problematic dependency appears when a measurement changes an input to the layout being measured. For example, a label reports its padded width, an action stores that width, and a surrounding frame uses the stored value plus more padding. The resulting frame changes the next measurement. Apple explicitly describes this geometry-reader-to-frame feedback loop and recommends using the layout system for this kind of arrangement. See Compose custom layouts with SwiftUI.
A simple calculation shows why an equality check may not help. Suppose an illustrative implementation measures the previous frame width plus 16 points of padding, then assigns the result as the next frame width. Starting at 120 points produces 136, 152, and 168 points. Every result differs from the previous one, so a guard against assigning an unchanged value never stops the growth. These numbers illustrate the dependency; they are not measurements from a running app.
Trace the dependency in your own code before choosing a fix:
- Locate the geometry reader, preference handler, or geometry-change action.
- Identify every state value that receives its measurements.
- Find the frames, padding, font choices, or conditional views that depend on those values.
- Check whether those choices affect the measured view or its ancestors.
- Temporarily replace the measured value with a constant. If the repeated changes stop, inspect that dependency path first.
Changing how a measurement travels does not necessarily remove the cycle. A preference can carry the same size back to the same ancestor. Deferring the state assignment can move the feedback into a later update. The useful question is whether the next measurement still depends on the previous result.
Which sizing mechanism should you use?
Choose the mechanism according to the decision you need to make. Start with built-in layout containers; Apple describes custom Layout implementations as the path for behavior that needs more control. See the Layout documentation.
| Required outcome | Supported approach | Key condition |
|---|---|---|
| Arrange ordinary labels vertically | A built-in stack | Let children participate in normal sizing. |
| Choose a wide or compact presentation | ViewThatFits |
Provide alternatives in preference order. |
| Give several labels a shared content-based width | A custom Layout |
Measure and place children within layout methods. |
| React to an existing view’s geometry | onGeometryChange |
Observe only the value needed by the action. |
| Size an embedded AppKit view | NSViewRepresentable.sizeThatFits |
Return a preferred size for the proposal. |
ViewThatFits selects the first child whose ideal size fits on the constrained axes. It is useful when your actual requirement is choosing among presentations, such as a horizontal arrangement and a vertical fallback. That can eliminate a separate measured-width state variable. See Apple’s ViewThatFits documentation.
For the shared-width label panel below, the sizing rule is explicit: use the widest ideal label width, capped by a finite proposed width. Then measure each label’s height at that chosen width. This lets a narrower window increase the panel’s height through wrapping without sending geometry measurements through application state.
Build a shared-width label panel without geometry state
Create a macOS app project in Xcode using SwiftUI, set its deployment target to macOS 13 or later, and replace the generated app and content-view declarations with this complete single-file example. Keep only one @main app declaration in the target. The program was inspected against the documented APIs; it was not compiled or run here because the available environment is Linux.
import SwiftUI
private struct EqualWidthColumn: Layout {
private let gap: CGFloat = 8
private func columnWidth(
proposal: ProposedViewSize,
subviews: Subviews
) -> CGFloat {
let idealWidth = subviews.reduce(CGFloat.zero) { result, view in
max(result, view.sizeThatFits(.unspecified).width)
}
guard let offeredWidth = proposal.width,
offeredWidth.isFinite else {
return idealWidth
}
return min(idealWidth, max(0, offeredWidth))
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let width = columnWidth(proposal: proposal, subviews: subviews)
let childProposal = ProposedViewSize(width: width, height: nil)
let contentHeight = subviews.reduce(CGFloat.zero) { result, view in
result + view.sizeThatFits(childProposal).height
}
let totalGap = gap * CGFloat(subviews.count - 1)
return CGSize(width: width, height: contentHeight + totalGap)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let childProposal = ProposedViewSize(
width: bounds.width,
height: nil
)
var y = bounds.minY
for view in subviews {
let height = view.sizeThatFits(childProposal).height
view.place(
at: CGPoint(x: bounds.minX, y: y),
anchor: .topLeading,
proposal: childProposal
)
y += height + gap
}
}
}
private struct ContentView: View {
private let labels = [
"Amber tile",
"Blue tile with a longer local label",
"Green tile"
]
var body: some View {
ScrollView(.vertical) {
EqualWidthColumn {
ForEach(labels, id: \.self) { label in
Text(label)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.12))
)
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 220, minHeight: 180)
}
}
@main
struct GeometryLoopDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
The two required layout methods divide the work: sizeThatFits reports the container’s size, while placeSubviews positions its children. Child proxies support measurement and placement without accessing the underlying views directly. Both methods use those documented responsibilities. See Apple’s Layout documentation.
The important implementation details are the width proposal and its reuse. Each child is measured with a definite width and an unspecified height, allowing the text to determine its required height. Placement uses the container’s actual bounds width and the same height policy. Starting at bounds.minX and bounds.minY also avoids assuming that placement begins at the coordinate origin.
The container handles an empty collection by returning zero. With an unspecified width it chooses its ideal width; with a finite width it caps that ideal width. An infinite width proposal also produces the finite ideal width for these labels. These are deliberate rules for this content-sized panel. Apple documents that parents can probe layouts with zero, infinite, unspecified, and other proposals during a single layout pass. See sizeThatFits(proposal:subviews:cache:).
The example has no measured-size state, no geometry callback, and no assignment from a rendered width back into a frame. Its vertical scroll view gives taller wrapped content somewhere to go. The layout assumes children like the supplied labels, which can respond sensibly to a finite width. A fixed-width child that exceeds the available space needs a separate presentation decision, such as horizontal scrolling or an alternative view.
When is observing geometry still appropriate?
Geometry observation remains useful when the measurement drives a limited response. Apple’s onGeometryChange API separates a transform from an action: the transform extracts an equatable value, and the action runs when that transformed value changes. Apple recommends avoiding broad application updates because geometry can change frequently. See onGeometryChange(for:of:action:).
For example, an overlay could display whether a stable containing region exceeds a chosen width. Observe that containing region and return a Boolean from the transform. Keep the overlay from contributing to the region’s size. This design follows the documented transform-and-action model while removing the circular sizing dependency.
Reducing a rectangle to a Boolean limits notifications, but it does not by itself prove stability. If switching to the compact presentation changes the measured region enough to switch back, the Boolean can alternate indefinitely. Measure a region whose width comes from the surrounding window layout, or use ViewThatFits to make the presentation decision inside layout.
Availability also depends on the overload. The single-new-value action supports macOS 13 or later when built with an SDK that includes it; the old-and-new-value overload requires macOS 15 or later. The complete example avoids this distinction by using Layout alone. Check Apple’s geometry-change documentation and old-and-new-value overload before copying a callback signature into an older deployment target.
How should AppKit and TextKit measurements enter SwiftUI layout?
When the content is an AppKit view, use the representable’s sizing boundary. NSViewRepresentable.sizeThatFits(_:nsView:context:) is available on macOS 13 or later, returns a preferred size for a proposal, and may be called multiple times with different proposals in one pass. Returning nil requests the default sizing algorithm. Treat it as a sizing query rather than a place to publish measured dimensions into SwiftUI state. See the representable sizing method.
SwiftUI controls the represented AppKit view’s frame and bounds. Apple explicitly warns against setting those layout properties yourself because doing so conflicts with SwiftUI and produces undefined behavior. Configure content in the representable’s update method and report sizing through the supported sizing mechanism. See NSViewRepresentable.
For text, first establish which layout engine the view uses. TextKit 2 provides NSTextLayoutManager.usageBoundsForTextContainer, available on macOS 12 or later, as a text-container usage measurement. Apple documents it as observable through KVO. That observation can support a resize request, but it does not remove the need to keep width and height dependencies consistent. See usageBoundsForTextContainer.
For an existing TextKit 1 implementation, NSLayoutManager.usedRect(for:) reports the area occupied by glyphs already laid out; it does not generate glyphs or perform layout. When the measurement requires completed layout, call ensureLayout(for:) for the relevant container before reading the used rectangle. Both APIs are available from macOS 10.7. See usedRect(for:) and ensureLayout(for:).
Do not access a TextKit 2 text view’s legacy layoutManager merely to obtain a measurement. Apple explains that legacy layout-manager access can switch a text view to TextKit 1 compatibility mode. Check textLayoutManager first and keep legacy measurement code in the appropriate fallback branch. See What’s new in TextKit and text views.
For a wrapped text view, make the proposed width an input to text layout and derive the preferred height from that configuration. Account explicitly for text-view insets and container padding; a rectangle in container coordinates is not automatically the outer view’s size. Keep AppKit UI work within its actor isolation. Swift’s Concurrency chapter explains main-actor execution; moving work into a task does not change the circular dependency in a sizing formula.
How do you verify that the layout now settles?
Run the example in Xcode and resize its window slowly in both directions. The intended behavior is a shared label width, wrapping at narrower widths, and vertical scrolling when the content exceeds the visible height. Then repeat with your actual view structure after removing the measured-state dependency.
Use these checks to distinguish expected layout work from continuing feedback:
- Leave the window stationary after resizing. Check whether measurements and state assignments continue changing without a content change.
- Test a longer synthetic label and a larger font. Confirm that the panel can grow vertically without clipping text.
- Test an empty label collection. The layout should return zero before the surrounding padding is applied.
- Inspect both measurement and placement proposals. A wrapping child should receive consistent width assumptions.
- If you add caching later, include the sizing inputs in its validity rules; do not reuse heights calculated for a different width.
Several sizing calls during one pass are documented behavior. The practical target is a stable result for unchanged content and constraints. Keep measurements inside the layout negotiation when they determine arrangement, and reserve geometry observation for responses that do not recreate the dimensions they observe.
Sources
- Compose custom layouts with SwiftUI — Apple
- ProposedViewSize — Apple
- Layout — Apple
- ViewThatFits — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- onGeometryChange(for:of:action:) — Apple
- onGeometryChange(for:of:action:) — old and new values — Apple
- sizeThatFits(_:nsView:context:) — Apple
- NSViewRepresentable — Apple
- usageBoundsForTextContainer — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- What’s new in TextKit and text views — Apple
- Concurrency — The Swift Programming Language — Swift.org