To make changing dropdown labels rewrap smoothly, measure each control at its ideal size, place it with a shared wrapping algorithm, and animate selection changes on the container. This tutorial builds a small macOS control panel using synthetic options.
Separate wrapping from animation
The reader task is specific: build a desktop panel where choosing a longer dropdown label can move neighboring controls onto another row. The controls should retain their identity while their positions change, and the panel should report its new height to the surrounding interface.
There are two separate responsibilities. A custom Layout computes the destination sizes and positions. An animation tells SwiftUI how to move between the old and new geometry. Apple documents Layout as the extension point for arrangements that need more complex behavior than the built-in containers provide. Its two required methods report the container’s size and place its subviews. Layout
For this example, each dropdown is a Menu whose actions update a selection. Apple supports menus with custom label builders, so the displayed label can include both a category and the selected option. The layout arranges the closed controls in the window; this example does not attempt to control the geometry of the menu’s presented contents. Menu
The design uses three explicit policies:
- Controls keep their ideal size rather than compressing their labels to preserve a row.
- Controls wrap in their existing order when the next control would exceed the proposed width.
- A control wider than the available width occupies its own row and makes the layout report that wider size.
That last policy matters. Wrapping a collection cannot make one indivisible control narrower. If your application must fit arbitrarily long labels into a narrow panel, you also need a separate label policy, such as a deliberately bounded label design. Apple notes that fixedSize() preserves ideal size and can therefore make a view exceed its parent’s bounds. fixedSize()
Build one calculation for measurement and placement
SwiftUI’s layout system passes size proposals down the hierarchy. A proposal with unspecified dimensions requests a view’s ideal size; it is different from a proposal containing zero or infinity. Here, ideal measurements determine each control’s footprint, while the parent’s proposed width determines row breaks. ProposedViewSize
The essential contract is consistency: the container must reserve the same space that its placement algorithm uses. Apple explicitly requires consistent calculations between sizing and placement, including spacing and responses to proposals. It also specifies that placement bounds are in the parent’s coordinate space, so placement must account for bounds.minX and bounds.minY. placeSubviews(in:proposal:subviews:cache:)
Create a macOS SwiftUI app in Xcode and set its deployment target to macOS 14 or later. The example uses Layout, available from macOS 13, and geometryGroup(), available from macOS 14. Put the following layout declaration and the app code in the next section into one Swift file, replacing the generated app entry point. Together, the two blocks form the complete example. Layout availability, geometryGroup() availability
import SwiftUI
struct WrappingControls: Layout {
var spacing: CGFloat = 8
struct Plan {
var size: CGSize
var origins: [CGPoint]
}
func plan(
proposal: ProposedViewSize,
subviews: Subviews
) -> Plan {
guard !subviews.isEmpty else {
return Plan(size: .zero, origins: [])
}
let gap = max(0, spacing)
let limit: CGFloat
if let width = proposal.width, width.isFinite {
limit = max(0, width)
} else {
limit = .infinity
}
var origins: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var usedWidth: CGFloat = 0
var rowHasItems = false
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let nextX = rowHasItems ? x + gap : 0
if rowHasItems && nextX + size.width > limit {
y += rowHeight + gap
x = 0
rowHeight = 0
rowHasItems = false
}
if rowHasItems {
x += gap
}
origins.append(CGPoint(x: x, y: y))
x += size.width
rowHeight = max(rowHeight, size.height)
usedWidth = max(usedWidth, x)
rowHasItems = true
}
let width = limit.isFinite ? max(limit, usedWidth) : usedWidth
return Plan(
size: CGSize(width: width, height: y + rowHeight),
origins: origins
)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
plan(proposal: proposal, subviews: subviews).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let result = plan(proposal: proposal, subviews: subviews)
for (index, subview) in subviews.enumerated() {
let origin = result.origins[index]
subview.place(
at: CGPoint(
x: bounds.minX + origin.x,
y: bounds.minY + origin.y
),
anchor: .topLeading,
proposal: .unspecified
)
}
}
}
Both layout methods call plan, using the proposal supplied by SwiftUI. Measurement and placement also use the same child proposal, .unspecified. Apple’s subview measurement API returns the size a subview chooses for the supplied proposal; the example uses that response rather than estimating width from character counts. sizeThatFits(_:)
For a finite proposed width, the layout fills at least that width and reports any oversized child honestly. For an unspecified or infinite width, it returns a finite, single-row ideal size for these finite-sized controls. Height is content driven; the layout intentionally ignores the proposed height and reports the space its rows require.
This implementation uses a Void cache and recomputes its small plan. Apple recommends adding a cache only when profiling demonstrates a benefit. If you later cache measurements, preserve the requirement that recreating the cache must not change the layout result. makeCache(subviews:)
Connect changing selections to stable controls
The synthetic panel has three categories: Shape, Pattern, and Arrangement. Each category owns its selected string, and every option is defined in the example. The label changes within the existing Menu; the program does not replace a menu whenever a selection changes.
Append this code after the layout declaration. Keep only one @main app declaration in the project.
struct ControlChoice: Identifiable, Equatable {
let id: Int
let title: String
let options: [String]
var selected: String
}
struct DropdownPanel: View {
@State var choices: [ControlChoice] = [
ControlChoice(
id: 1,
title: "Shape",
options: ["Circle", "Rounded rectangle"],
selected: "Circle"
),
ControlChoice(
id: 2,
title: "Pattern",
options: ["Solid", "Alternating diagonal lines"],
selected: "Solid"
),
ControlChoice(
id: 3,
title: "Arrangement",
options: ["Rows", "Evenly spaced columns"],
selected: "Rows"
)
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Layout options")
.font(.headline)
WrappingControls(spacing: 8) {
ForEach(choices) { choice in
Menu {
ForEach(choice.options, id: \.self) { option in
Button(option) {
select(option, for: choice.id)
}
}
} label: {
Text("\(choice.title): \(choice.selected)")
.lineLimit(1)
}
.fixedSize()
.geometryGroup()
}
}
.animation(
.easeInOut(duration: 0.25),
value: choices.map(\.selected)
)
Text("Choose a longer option, then narrow the window.")
.foregroundStyle(.secondary)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 320, minHeight: 240)
}
func select(_ option: String, for id: Int) {
guard let index = choices.firstIndex(where: { $0.id == id }),
choices[index].options.contains(option) else {
return
}
choices[index].selected = option
}
}
@main
struct RewrappingMenusApp: App {
var body: some Scene {
WindowGroup {
DropdownPanel()
}
}
}
The animation modifier watches an array of selected strings. Apple requires the watched value to conform to Equatable; this array satisfies that contract. Watching the selection values also means this modifier does not request a new animation merely because the window width changes. The layout still recalculates its wrapping when its proposal changes. animation(_:value:)
Stable category IDs connect each menu across updates. ForEach builds views from identified data, while changing a view’s explicit .id resets its identity and associated state. Consequently, adding .id(choice.selected) would work against the continuity this example needs. The option strings are unique within each synthetic category, which makes id: \.self suitable for those menu actions. ForEach, id(_:)
Apple’s custom-layout sample demonstrates that preserving structural identity allows animation(_:value:) to animate layout changes. This example applies that principle to changing measurements inside one layout type. The selection string changes immediately; the intended animation is the resulting movement and resizing, rather than an interpolation between the characters of two labels. Composing custom layouts with SwiftUI
The geometryGroup() modifier gives each complete menu a geometry boundary. Apple describes this boundary as resolving and animating parent geometry before passing it to descendants. Its use here follows that documented mechanism to keep each control’s internal content moving together; it does not determine row breaks or repair an incorrect wrapping calculation. geometryGroup()
Check row breaks before tuning the animation
A small worked example makes the wrapping policy inspectable. These are illustrative dimensions, not measurements of macOS controls: assume three controls are 80, 100, and 90 points wide, all 24 points tall, with 8-point spacing and 300 points available.
The initial row requires 286 points: 80 + 8 + 100 + 8 + 90. If the middle control grows to 150 points, the complete row would require 336 points. The first two controls still fit, using 238 points, so the third moves to the next row. The new content height is 56 points: 24 + 8 + 24.
| Condition | Expected result from this algorithm |
|---|---|
| Combined width exactly equals the limit | Keep the controls on that row. |
| The next control would exceed the limit | Start a new row before that control. |
| Controls have different heights | Advance by the tallest control in the completed row. |
| One control exceeds the proposed width | Put it alone and report the wider required size. |
| Width is unspecified or infinite | Return one row at the controls’ combined ideal width. |
| There are no controls | Return zero size. |
The packing arithmetic was checked separately with deterministic synthetic dimensions, including longer labels, an oversized control, and unspecified width. The SwiftUI app itself was not compiled or visually tested in this environment. Build and run it on macOS to verify the native control appearance and animation.
In the running app, first choose longer options while keeping the window width fixed. Adjust the window until a selection change crosses a row boundary, then alternate between the short and long options. Confirm that the explanatory text below the controls moves to accommodate the new row height and that returning to short labels removes unused vertical space.
Next, resize the window with selections unchanged. This distinguishes a wrapping defect from an animation defect: incorrect final positions point to measurement or placement, while correct final positions with undesirable motion call for checking animation scope and geometry grouping.
Diagnose the remaining failure conditions
If labels change but row breaks do not, inspect what the layout actually measures. A fixed-width wrapper around every menu can make short and long labels report the same outer width. In this implementation, each menu deliberately preserves its ideal size, and the layout measures that full control rather than a separate text estimate. fixedSize(), sizeThatFits(_:)
If controls overlap or the next section covers the second row, compare sizing and placement first. Keep the same gap, child proposal, wrap condition, and row-height calculation in both methods. Do not compute sizing from one width and placement from another. The shared plan function exists to make those discrepancies harder to introduce. placeSubviews(in:proposal:subviews:cache:)
If controls disappear and reappear during selection changes, inspect identity before adding more animation modifiers. Keep the category ID stable and update only its selection. Apple documents that changing an explicit view ID resets the view’s identity, so a changing label is a poor identity key for a persistent control. id(_:)
For a macOS 13 deployment, remove .geometryGroup() and retain the Layout implementation and selection-driven animation. That preserves the wrapping approach, but removes the documented geometry boundary available from macOS 14; evaluate the resulting motion on your supported systems. Layout, geometryGroup()
The finished behavior rests on three concrete decisions: measure the actual controls, use the same row plan for reported size and placement, and preserve control identity when labels change. Once those decisions produce correct final geometry, the selection-driven animation can make the rewrap easier to follow.
Sources
- Layout — Apple Developer Documentation.
- ProposedViewSize — Apple Developer Documentation.
- placeSubviews(in:proposal:subviews:cache:) — Apple Developer Documentation.
- sizeThatFits(_:) — Apple Developer Documentation.
- makeCache(subviews:) — Apple Developer Documentation.
- Menu — Apple Developer Documentation.
- fixedSize() — Apple Developer Documentation.
- animation(_:value:) — Apple Developer Documentation.
- ForEach — Apple Developer Documentation.
- id(_:) — Apple Developer Documentation.
- Composing custom layouts with SwiftUI — Apple Developer Documentation.
- geometryGroup() — Apple Developer Documentation.