Make SwiftUI Toolbars Work In Resizable iPhone Apps
The toolbar that feels fine on a fixed iPhone screen can get weird fast once the app becomes resizable.
Maybe you have a photo editor, a drawing canvas, a document detail screen, or a dense admin view. The toolbar started with Save and Share. Then Undo and Redo joined. Then Crop, Filters, Replace Photo, Export, Delete, More, and a custom status chip. It still looked acceptable in the simulator, so nobody thought much about it.
Then the same iPhone app is resized in iPhone Mirroring, or runs as an iPhone app on iPad, and the toolbar has to adapt to widths you did not design by hand.
That is the new reality Apple called out in the Platforms State of the Union recap: iOS apps are now resizable so people can use them on larger displays, iPad, and iPhone Mirroring. In What’s new in SwiftUI, Apple used exactly this kind of crowded toolbar to introduce new control points for resizable interfaces: ToolbarItemGroup, .visibilityPriority(.high), ToolbarOverflowMenu, .topBarPinnedTrailing, .toolbarMinimizeBehavior(.onScrollDown, for: .navigationBar), appearsActive, size classes, and Xcode 27 Live Preview resizing.
The important shift is this:
Do not ask “how do I keep every toolbar item visible?”
Ask “which commands must stay visible, which commands should adapt, and which commands belong in overflow on purpose?”
The new toolbar pieces below, including visibilityPriority(_:), ToolbarOverflowMenu, .topBarPinnedTrailing, and toolbarMinimizeBehavior(_:for:), follow the WWDC26 and Xcode 27 beta API shape. In current docs, gate the iOS examples with iOS and iPadOS 27 availability, and verify exact platform coverage in your installed SDK before shipping. Treat these as code-level examples, not proof that every line has been compiler-checked against your installed seed.
The crowded version
Here is a realistic starting point. It is not a ridiculous toolbar. It is just what happens when an editor grows a few useful features.
import SwiftUI
struct PhotoEditorView: View {
@State private var document = PhotoDocument.preview
@State private var isShowingClearConfirmation = false
var body: some View {
NavigationStack {
PhotoCanvas(document: document)
.navigationTitle(document.title)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button("Undo", systemImage: "arrow.uturn.backward") {
document.undo()
}
.disabled(!document.canUndo)
Button("Redo", systemImage: "arrow.uturn.forward") {
document.redo()
}
.disabled(!document.canRedo)
Button("Crop", systemImage: "crop") {
document.selectedTool = .crop
}
Button("Filters", systemImage: "camera.filters") {
document.selectedTool = .filters
}
Button("Replace", systemImage: "photo.on.rectangle") {
document.replacePhoto()
}
Button("Export", systemImage: "square.and.arrow.down") {
document.exportImage()
}
Button("Share", systemImage: "square.and.arrow.up") {
document.share()
}
Button("Clear", systemImage: "trash", role: .destructive) {
isShowingClearConfirmation = true
}
}
}
.confirmationDialog("Clear all edits?", isPresented: $isShowingClearConfirmation) {
Button("Clear Edits", role: .destructive) {
document.clearEdits()
}
}
}
}
}
This has two problems.
First, it treats every command as equally important. SwiftUI can adapt the toolbar, but it does not know your product hierarchy. If space gets tight, the system may hide commands you wanted front and center.
Second, it makes overflow accidental. A command ends up in a menu because the toolbar ran out of room, not because you decided that command belonged there.
That difference matters. Undo, Redo, and Share are often immediate editor actions. Replace Photo, Export Image, and Clear Edits are still useful, but they can usually live one tap deeper without hurting the editing flow.
Decide the command hierarchy
Before touching code, write the toolbar down as product behavior.
For this photo editor, I would choose:
- Always try to keep Undo and Redo visible.
- Keep Share pinned at the trailing edge.
- Show active editing tools when there is room.
- Put infrequent or destructive commands in overflow intentionally.
- Let the navigation bar minimize while scrolling through inspector content.
- Use size classes for broad layout choices instead of checking for “iPhone.”
That gives SwiftUI useful information instead of a flat list of buttons.
Give Undo and Redo high visibility
ToolbarItemGroup is the right unit when several commands belong together. Undo and Redo are a pair, so keep them in one group and mark the group as important.
Apple’s ToolbarContent.visibilityPriority(_:) documentation says lower-priority toolbar items move into overflow before higher-priority items when space is limited; the default is .automatic. Majid Jabrayilov’s toolbar article shows the same pattern in a compact SwiftUI example.
ToolbarItemGroup(placement: .topBarLeading) {
Button {
document.undo()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.disabled(!document.canUndo)
Button {
document.redo()
} label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.disabled(!document.canRedo)
}
.visibilityPriority(.high)
There is a subtle design choice here: I am not pinning Undo and Redo. I am saying they are high priority. SwiftUI still gets to adapt the toolbar to the actual space available, but it has a better sense of which group should survive before lower-priority items.
That is usually what you want for editing controls. They are important, but they are not always the one sacred trailing command.
Move secondary work into overflow
Some commands should not fight for toolbar space at all.
ToolbarOverflowMenu lets you make that decision directly. Instead of hoping the system happens to collapse the right items, you place the lower-frequency commands into overflow yourself.
ToolbarOverflowMenu {
Button("Replace Photo", systemImage: "photo.on.rectangle") {
document.replacePhoto()
}
Button("Export Image", systemImage: "square.and.arrow.down") {
document.exportImage()
}
Divider()
Button("Clear Edits", systemImage: "trash", role: .destructive) {
isShowingClearConfirmation = true
}
}
This is not just about saving pixels. It makes the toolbar easier to reason about. The visible toolbar becomes the fast path. The overflow menu becomes the place for commands that are useful, recoverable, or less frequent.
I also like that destructive commands feel less twitchy this way. “Clear Edits” should not live one accidental tap away beside Undo.
Pin the one action that must stay visible
Share is a good candidate for a pinned trailing action in this example. It is a global action for the editor, it is visually expected near the trailing edge, and it should not disappear before lower-value tools.
Apple’s WWDC26 example uses the new pinned trailing placement for Share.
ToolbarItem(placement: .topBarPinnedTrailing) {
Button {
document.share()
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.disabled(!document.canShare)
}
Reserve pinned trailing placement for one or two commands that truly need to stay visible. If everything is pinned, nothing is.
Current docs say pinned trailing items move into overflow when search is active and there is not enough room. Test search explicitly; pinned is still a priority signal, not permission to overcrowd the bar.
Use size classes for broad toolbar choices
Resizable iPhone apps are a good reason to stop writing toolbar logic like this:
if UIDevice.current.userInterfaceIdiom == .phone {
// compact toolbar
} else {
// expanded toolbar
}
That check answers the wrong question. A resizable iPhone app can run in widths that do not map cleanly to your old mental model of “phone screen.” Apple specifically points developers toward size classes and current geometry in the SwiftUI session.
Here is a more useful shape:
struct PhotoEditorView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
@Environment(\.appearsActive) private var appearsActive
@State private var document: PhotoDocument
@State private var isShowingClearConfirmation = false
init(document: PhotoDocument = .preview) {
_document = State(initialValue: document)
}
private var hasRoomForEditingTools: Bool {
horizontalSizeClass == .regular || verticalSizeClass == .compact
}
var body: some View {
NavigationStack {
PhotoCanvas(document: document)
.navigationTitle(document.title)
.toolbar {
editorStatusItem
undoRedoItems
editingToolItems
shareItem
overflowItems
}
.confirmationDialog("Clear all edits?", isPresented: $isShowingClearConfirmation) {
Button("Clear Edits", role: .destructive) {
document.clearEdits()
}
}
}
}
}
Then use that coarse layout signal to decide whether optional editing tools should appear as individual toolbar buttons.
private extension PhotoEditorView {
@ToolbarContentBuilder
var editingToolItems: some ToolbarContent {
if hasRoomForEditingTools {
ToolbarItemGroup(placement: .secondaryAction) {
Button {
document.selectedTool = .crop
} label: {
Label("Crop", systemImage: "crop")
}
Button {
document.selectedTool = .filters
} label: {
Label("Filters", systemImage: "camera.filters")
}
Button {
document.selectedTool = .adjustments
} label: {
Label("Adjust", systemImage: "slider.horizontal.3")
}
}
}
}
}
This does not replace toolbar adaptation. It gives you one extra product-level decision: on broader layouts, show direct editing tools; on tighter layouts, let the fast path be Undo, Redo, Share, and overflow.
Size classes are intentionally coarse, and they are optional. If your target can report nil, choose an explicit fallback or combine size classes with local geometry. If you need exact layout measurements inside the editor canvas, use geometry for that local layout. For the toolbar, I would usually prefer size classes plus SwiftUI’s toolbar adaptation over a pile of width thresholds.
Dim custom toolbar content when inactive
On platforms where appearsActive changes, an app window can appear inactive while still visible. System toolbar items handle a lot of that appearance for you, but custom toolbar content may need help.
Apple’s SwiftUI session calls out the appearsActive environment value for the refreshed iPad and Mac window model, while current documentation still describes mostly macOS-focused behavior. Verify the iPadOS behavior in your current seed. A small editor status item is a good example.
private extension PhotoEditorView {
var editorStatusItem: some ToolbarContent {
ToolbarItem(placement: .principal) {
HStack(spacing: 6) {
Image(systemName: document.isEdited ? "circle.fill" : "checkmark.circle")
.font(.caption)
Text(document.isEdited ? "Edited" : "Saved")
.font(.caption.weight(.semibold))
}
.foregroundStyle(document.isEdited ? .orange : .secondary)
.opacity(appearsActive ? 1 : 0.45)
.accessibilityLabel(document.isEdited ? "Document has unsaved edits" : "Document is saved")
}
}
}
This is not about making the inactive window unreadable. It is about helping your custom content participate in the same active-window language as the rest of the interface.
I would keep this kind of custom toolbar content small. A status chip is fine. A whole dashboard in the principal toolbar area is going to become another resizability problem.
Put the refactor together
Here is the toolbar after the hierarchy is explicit.
private extension PhotoEditorView {
var undoRedoItems: some ToolbarContent {
ToolbarItemGroup(placement: .topBarLeading) {
Button {
document.undo()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.disabled(!document.canUndo)
Button {
document.redo()
} label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.disabled(!document.canRedo)
}
.visibilityPriority(.high)
}
var shareItem: some ToolbarContent {
ToolbarItem(placement: .topBarPinnedTrailing) {
Button {
document.share()
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.disabled(!document.canShare)
}
}
var overflowItems: some ToolbarContent {
ToolbarOverflowMenu {
Button("Replace Photo", systemImage: "photo.on.rectangle") {
document.replacePhoto()
}
Button("Export Image", systemImage: "square.and.arrow.down") {
document.exportImage()
}
Divider()
Button("Clear Edits", systemImage: "trash", role: .destructive) {
isShowingClearConfirmation = true
}
}
}
}
Now the toolbar has a real order of importance:
- Undo and Redo are a high-priority group.
- Share is pinned to the trailing edge.
- Crop, Filters, and Adjust are visible when the size classes suggest enough room.
- Replace, Export, and Clear live in overflow on purpose.
- The custom status item responds to active and inactive windows.
That is a much better place to be than “I hope the right items collapse.”
Minimize the navigation bar while scrolling
Toolbars are not only about horizontal space. A detail or editor screen can also need vertical space, especially when the lower part of the screen is an inspector, filmstrip, layers list, or comment thread.
The new toolbar minimize behavior lets the navigation bar move out of the way while the person scrolls.
struct PhotoEditorView: View {
@State private var document = PhotoDocument.preview
var body: some View {
NavigationStack {
ScrollView {
PhotoCanvas(document: document)
.frame(minHeight: 420)
InspectorPanel(document: $document)
.padding(.horizontal)
}
.navigationTitle(document.title)
.toolbar {
undoRedoItems
editingToolItems
shareItem
overflowItems
}
.toolbarMinimizeBehavior(.onScrollDown, for: .navigationBar)
}
}
}
Use this when scrolling is part of the screen’s primary workflow. If the toolbar contains commands the person needs every few seconds, minimizing it may feel like the interface is dodging them. In an editor with a large canvas and a scrolling inspector, it can be a nice win.
The system adjusts the safe area during toolbar minimization by default. If your canvas or inspector needs different behavior, check toolbarMinimizationSafeAreaAdjustment(_:for:) in the SDK you are using.
Also test how it feels with search, Dynamic Type, hardware keyboards, and VoiceOver. A toolbar that minimizes beautifully for touch scrolling can still be annoying if important commands become harder to rediscover.
Test it in Xcode 27 Live Preview
Xcode 27 Live Previews add resize handles, which is perfect for this kind of work. Apple’s SwiftUI session specifically frames them as a way to test iPhone Mirroring and iPhone-on-iPad resizing behavior.
Start with a preview that loads your real crowded state, not an empty demo document.
#Preview("Crowded photo editor") {
PhotoEditorView(document: .previewWithManyEdits)
}
Then drag the preview through uncomfortable sizes:
- Narrow portrait iPhone width.
- Wider iPhone-on-iPad style width.
- Short landscape height.
- Large Dynamic Type.
- Search active, if the screen has search.
- Inactive window appearance on iPad or Mac.
- Real device or Device Hub run after the preview feels good.
While resizing, ask concrete questions:
- Are Undo and Redo visible before optional editing tools?
- Does Share stay where people expect it?
- Are destructive commands kept out of accidental-tap range?
- Does overflow contain complete labels, not mystery icons?
- Does the toolbar still make sense when the navigation bar minimizes?
- Does custom toolbar content dim correctly when the window is inactive?
That last pass matters because toolbars fail gradually. They usually do not explode. They just become a little less predictable at each new width until the person can no longer tell where the important commands went.
A small production checklist
When you revisit an existing SwiftUI toolbar for resizable iPhone apps, I would work through it in this order:
- List every command and mark it as primary, secondary, overflow, or destructive.
- Group related primary commands with
ToolbarItemGroup. - Apply
.visibilityPriority(.high)to the group that should survive tight widths. - Move lower-frequency commands into
ToolbarOverflowMenu. - Reserve
.topBarPinnedTrailingfor the one trailing action that truly deserves it. - Use size classes for broad toolbar choices instead of device idiom checks.
- Use
@Environment(\.appearsActive)for custom toolbar content that should react to inactive windows. - Add
.toolbarMinimizeBehavior(.onScrollDown, for: .navigationBar)only when hiding the bar helps the scrolling workflow. - Resize the screen in Xcode 27 Live Preview before you trust the layout.
- Re-check exact API names and availability in your current Xcode 27 beta seed.
The good news is that this refactor does not require a new navigation architecture. Most crowded toolbars need hierarchy, not heroics. Tell SwiftUI what matters, put the rest in a deliberate overflow menu, and use resizing as a design input instead of a bug report generator.