To measure a label in your Mac app without stretching it, attach onGeometryChange to the view you want to inspect. For an older deployment target, put a GeometryReader in its background. The modifier’s position determines which width you read.

This walkthrough measures a synthetic label, including its padding, while keeping its surrounding layout independent of the result. It also explains how to distinguish the label’s current width from its ideal width and its container’s width.

Why does a geometry reader make the layout expand?

SwiftUI layout involves proposals and responses: a parent proposes space, and its children report their computed sizes. Adding a frame introduces another view into that hierarchy, so the label and its frame can have different widths. Apple explains this process in Laying out a simple view.

A GeometryReader is a container with a flexible preferred size. Wrapping a compact label in one therefore changes the layout participant presented to the parent. You are measuring the reader’s container, which can occupy substantially more space than the text needs. See Apple’s GeometryReader documentation.

For observation, attach the measurement to the existing view. When a background reader is necessary, its placement matters: Apple documents that a background receives the size of the view to which it is attached. This lets the label determine the measurement area. See Adding a background to your view.

Before choosing an implementation, decide which question your interface needs to answer:

Your question Appropriate approach
How wide is this label in its current layout? Observe the label’s geometry.
How wide is the padded badge around the label? Observe after padding.
How wide is its containing row? Observe the row or its frame.
How wide would the entire text prefer to be? Investigate ideal sizing separately.
Which of several presentations fits? Consider ViewThatFits.
How should several children be measured and positioned together? Consider a custom Layout.

The distinction prevents a common mistake: treating the width of a constrained, possibly truncated label as the space required to display its entire string.

Measure the current width on macOS 13 and later

Apple’s current documentation lists the single-value action overload of onGeometryChange(for:of:action:) as available on macOS 13 and later. It transforms a geometry proxy into an equatable value and invokes an action when that value changes. Use an SDK that includes this API, and keep the one-parameter action shown here. See onGeometryChange(for:of:action:).

Create a macOS app project in Xcode using SwiftUI and a deployment target of macOS 13 or later. Replace the generated app and content-view declarations with this complete program, leaving only one @main app declaration in the target. The sample uses only local synthetic labels.

import SwiftUI
import Foundation

@main
struct WidthDemoApp: App {
    var body: some Scene {
        WindowGroup {
            WidthDemoView()
        }
    }
}

struct WidthDemoView: View {
    @State private var measuredWidth: CGFloat = 0
    @State private var offeredWidth: Double = 260
    @State private var showLongLabel = false

    private var label: String {
        showLongLabel
            ? "Sample collection of blue circles"
            : "Blue circles"
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(label)
                .font(.system(size: 16))
                .lineLimit(1)
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(Color.blue.opacity(0.12))
                .onGeometryChange(for: CGFloat.self) { proxy in
                    proxy.size.width
                } action: { newWidth in
                    measuredWidth = newWidth
                }
                .frame(
                    width: CGFloat(offeredWidth),
                    alignment: .leading
                )

            Text(String(
                format: "Measured badge: %.1f pt",
                Double(measuredWidth)
            ))
            .monospacedDigit()
            .frame(width: 340, alignment: .leading)

            Slider(value: $offeredWidth, in: 120...340)

            Button("Change sample label") {
                showLongLabel.toggle()
            }
        }
        .frame(width: 360, alignment: .leading)
        .padding(20)
    }
}

The observer reads proxy.size.width; Apple defines GeometryProxy.size as the container view’s size. Here, the observed view includes the text and padding, while the subsequent frame provides an independently controlled width proposal. See GeometryProxy.size.

The measurement updates a separate readout. It does not set the measured badge’s width. The fixed demo area and readout width make it easier to inspect the result without allowing the diagnostic text to resize the surrounding stack.

Returning only CGFloat also keeps the observation focused on width: a height-only change does not change the transformed value. Apple recommends transforming geometry into the specific data you need because geometry can change frequently. See onGeometryChange(for:of:action:).

These examples were inspected against the retrieved API declarations. They were not compiled or run here because the available environment is Linux without a Swift toolchain; build and inspect the interface in Xcode on a Mac.

Where should the measurement modifier go?

Read the modifier chain as a succession of wrapped views. The measurement belongs immediately after the last size-affecting modifier you want included. Apple’s layout explanation describes how frames wrap views and padding changes the space available to their contents. See Laying out a simple view.

Measurement location in the example What it observes
Before both padding modifiers The text view’s current layout width
After both padding modifiers The padded badge’s current width
After the explicit width frame The outer frame’s width
On the surrounding VStack The stack’s width

As an illustrative calculation, suppose a text view reports 96 points under its current proposal. Adding 12 points on each horizontal edge produces a padded width of 120 points. An outer frame of 260 points is another distinct measurement. These numbers illustrate the modifier boundaries; they are not measured results for the sample string.

To investigate an unexpected value, temporarily place a border at the same position in the modifier chain as the observer. Compare that boundary with the intended target. A reader attached to a whole row can correctly report the row’s width even when the visible label occupies only a small portion of it.

Use a background reader for a macOS 12 target

For a macOS 12 deployment target, use a background GeometryReader to publish the width through a preference. The trailing-closure background API used below is available from macOS 12. Apple recommends backgrounds when the modified view should dominate layout. See background(alignment:content:).

This is a complete alternative program. Replace the previous example with it instead of adding a second app declaration.

import SwiftUI
import Foundation

private struct BadgeWidthKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }

    static func reduce(
        value: inout CGFloat,
        nextValue: () -> CGFloat
    ) {
        value = nextValue()
    }
}

@main
struct LegacyWidthDemoApp: App {
    var body: some Scene {
        WindowGroup {
            LegacyWidthDemoView()
        }
    }
}

struct LegacyWidthDemoView: View {
    @State private var measuredWidth: CGFloat = 0
    @State private var showLongLabel = false

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(showLongLabel ? "Blue circles collection" : "Blue circles")
                .font(.system(size: 16))
                .lineLimit(1)
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(Color.blue.opacity(0.12))
                .background {
                    GeometryReader { proxy in
                        Color.clear.preference(
                            key: BadgeWidthKey.self,
                            value: proxy.size.width
                        )
                    }
                }
                .onPreferenceChange(BadgeWidthKey.self) { newWidth in
                    measuredWidth = newWidth
                }

            Text(String(
                format: "Measured badge: %.1f pt",
                Double(measuredWidth)
            ))
            .frame(width: 340, alignment: .leading)

            Button("Change sample label") {
                showLongLabel.toggle()
            }
        }
        .frame(width: 360, alignment: .leading)
        .padding(20)
    }
}

PreferenceKey carries a value toward ancestors, and onPreferenceChange receives changes to that value. The reader emits the measurement rather than assigning state inside its content closure. See Apple’s PreferenceKey and onPreferenceChange(_:perform:) documentation.

This reducer deliberately assumes one emitting badge. Reusing the same key for several children would combine their values, with this implementation retaining the last reduced value. For independently tracked labels, design a keyed collection of measurements instead of allowing one scalar to stand for several views.

How can you verify that measurement stays independent?

Use the first program as a small diagnostic sequence:

  1. Start with Blue circles and a generous offered width. Compare the colored badge with the readout.
  2. Drag the slider. The outer frame changes independently; the badge can remain compact while sufficient space remains.
  3. Select Change sample label, then reduce the offered width. Inspect the current constrained badge rather than expecting the full string’s ideal width.
  4. Move the observer below the explicit frame. It should now report that frame’s width.
  5. Restore the observer above the frame before using the badge measurement elsewhere.

Keep the observed width out of the formula that determines that same view’s width. For example, setting its frame to the observed width plus padding can repeatedly feed a larger result back into layout. This follows directly from the dependency you create: observation changes state, state changes size, and the new size produces another observation.

If your actual task is choosing between a full label and a compact alternative, ViewThatFits on macOS 13 and later can select the first child whose ideal size fits the constrained axes. That can remove the need to store a width at all. See ViewThatFits.

If several children must be measured and positioned together, use a custom Layout on macOS 13 and later. Its measurement and placement methods let the container query subviews before positioning them, keeping that decision inside the layout process. See Layout.

Can ideal sizing or AppKit text metrics answer the same question?

Use .fixedSize(horizontal: true, vertical: false) only when you intend to preserve the text’s ideal width. Apple documents that this can make text exceed its parent’s bounds. Adding it to obtain a measurement changes the displayed view’s sizing behavior, so it does not preserve the original constrained layout. See fixedSize(horizontal:vertical:).

Character counts are also unsuitable as width measurements. Swift’s String.count counts Character values represented by extended grapheme clusters; it does not return typographic dimensions. Multiplying that count by a guessed character width supplies no measurement of the SwiftUI view. See Strings and Characters.

For an AppKit drawing task, NSAttributedString.size() returns the size needed to draw that attributed string, potentially including fractional dimensions. Apple advises rounding upward when using that result to size a view. This answers a text-drawing question; it does not include a SwiftUI view’s padding or enclosing frame. See size().

For an existing TextKit 1 layout, NSLayoutManager.usedRect(for:) reports the area occupied by glyphs currently laid out in its text container. It does not itself generate glyphs or perform layout; call ensureLayout(for:) when layout must first be completed. These APIs measure that configured text container, so keep their results separate from SwiftUI geometry. See usedRect(for:) and ensureLayout(for:).

For the label already displayed in your SwiftUI Mac interface, observe its geometry at the boundary you need, keep the readout independent, and test changes to both content and available space. That produces a useful width while preserving the layout you intended to inspect.

Sources