SwiftUI's Toolbar placement API
I'm exploring SwiftUI as a beginner iOS developer, and the toolbar API demonstrates something both impressive and frustrating: the framework decides what to show and what to hide based on context you didn't necessarily ask it to consider.
You describe placement intent—"I want this button in the top-right corner"—and SwiftUI interprets based on device, orientation, and available space. Sometimes it honors your intent. Sometimes it moves items to overflow menus. Sometimes it hides them entirely.
This post covers:
- How to use ToolbarItem (the basics)
- The four corners pattern with actual behavior
- Why this automatic behavior frustrates simple use cases
- What happens with too many toolbar items
- When this approach helps vs when it hurts
ToolbarItem Basics
SwiftUI's toolbar API uses a toolbar
modifier that takes ToolbarItem
elements. Each ToolbarItem
needs placement (where you want it) and content (what to show). Apple's ToolbarItemPlacement documentation lists placements like .topBarLeading
, .topBarTrailing
, .bottomBar
, .status
, .title
, and .subtitle
.
Here's the simplest example:
import SwiftUI
struct BasicToolbarDemo: View {
var body: some View {
NavigationStack {
Text("Content goes here")
.navigationTitle("Basic Toolbar")
.toolbar {
// Single toolbar item in top-right
ToolbarItem(placement: .topBarTrailing) {
Button { } label: {
Image(systemName: "plus")
}
}
}
}
}
}
Key points:
- Must be inside NavigationStack - Toolbars attach to navigation bars
- placement parameter - Tells SwiftUI where you want it
- Content closure - What to display (usually a Button)
Common placements:
.topBarLeading
- Top-left corner (menu, back button).topBarTrailing
- Top-right corner (add, edit, share).bottomBar
- Bottom toolbar area.status
- Centered in bottom bar (non-interactive status text).title
- Custom title view in navigation bar.subtitle
- Subtitle text below title
Now let's see what happens when you use multiple placements.
The Four Corners Pattern
I wanted to place toolbar items in all four screen corners. This is common in file browsers (Files app), editing apps (Pages, TextEdit), and photo apps (Photos). Here's the simplest version:
struct FourCornersDemo: View {
var body: some View {
NavigationStack {
// DemoData.generatePeople() creates 50 placeholder names
List(DemoData.generatePeople(), id: \.self) { person in
Text(person)
}
.navigationTitle("Four Corners")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { } label: {
Image(systemName: "line.3.horizontal")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button { } label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .bottomBar) {
Button { } label: {
Label("Back", systemImage: "chevron.backward")
}
}
ToolbarItem(placement: .bottomBar) {
Spacer()
}
ToolbarItem(placement: .bottomBar) {
Button { } label: {
Label("Next", systemImage: "chevron.forward")
}
}
}
}
}
}
Five ToolbarItems: top-left, top-right, bottom-left, a Spacer in the middle, and bottom-right. The Spacer pushes the bottom buttons to the edges.
On iPhone, all four buttons appear exactly where specified. On iPad, same buttons with more spacing. On macOS, the top buttons appear in the window toolbar, but bottom buttons disappear—macOS windows don't have bottom toolbars, so SwiftUI hides them.
More Toolbar Placements: Kitchen Sink
SwiftUI actually has more toolbar placements beyond the four corners. Here's a kitchen sink example showing .title
, .subtitle
, and .status
:
struct ToolbarKitchenSink: View {
@State private var selectedCount = 3
private let people = DemoData.generatePeople()
var body: some View {
NavigationStack {
List(people, id: \.self) { person in
Text(person)
}
.navigationTitle("Kitchen Sink")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { } label: {
Image(systemName: "line.3.horizontal")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button { } label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .title) {
HStack(spacing: 6) {
Image(systemName: "tray.fill")
Text("Inbox")
}
}
ToolbarItem(placement: .subtitle) {
Text("\(selectedCount) of \(people.count) people selected")
.foregroundStyle(.secondary)
}
ToolbarItem(placement: .status) {
Button { } label: {
Text("\(selectedCount) of \(people.count) people selected")
}
.controlSize(.large)
}
ToolbarItem(placement: .bottomBar) {
Button { } label: {
Label("Back", systemImage: "chevron.backward")
}
}
ToolbarItem(placement: .bottomBar) {
Button { } label: {
Label("Next", systemImage: "chevron.forward")
}
}
}
}
}
}
This demonstrates:
.title
- Custom title view in navigation bar (replaces standard title).subtitle
- Subtitle text below title.status
- Centered in bottom bar (can be button or text)
The Frustration: Portrait-Only iPhone Apps
Here's my issue: if you're building a portrait-only iPhone app, SwiftUI's automatic cross-platform adaptation is solving a problem you don't have.
I want to build simple iPhone apps with fixed button placement. But SwiftUI's toolbar API assumes universal apps that might run on iPad or macOS, rotate to landscape, and need adaptive layouts.
Apple's documentation doesn't make the constraints clear upfront. You discover them by testing. The docs say:
"In compact horizontal size classes, the system limits both the leading and trailing positions of the navigation bar to a single item each."
Translation: iPhone portrait gets one button in .topBarLeading
and one button in .topBarTrailing
. Want more? They go to overflow menus automatically.
Too Many Toolbar Items: Automatic Overflow
What happens when you add 10 toolbar buttons? SwiftUI automatically creates an overflow menu. I wanted to see this in action, so I created a demo with a star button (left) and 10 action icons (right):
struct TenItemsTopTrailing: View {
@State private var isStarred = false
@State private var showAlert = false
@State private var alertMessage = ""
private let trailingIcons = [
"pencil", "trash", "square.and.arrow.up", "doc.on.doc",
"folder", "tag", "link", "clock", "bell", "flag"
]
var body: some View {
NavigationStack {
List(DemoData.generatePeople(), id: \.self) { person in
Text(person)
}
.navigationTitle("Lots of Actions")
.alert(alertMessage, isPresented: $showAlert) {
Button("OK") { }
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
isStarred.toggle()
alertMessage = isStarred ? "Starred" : "Unstarred"
showAlert = true
} label: {
Image(systemName: isStarred ? "star.fill" : "star")
.foregroundColor(isStarred ? .yellow : .primary)
}
}
ToolbarItemGroup(placement: .topBarTrailing) {
ForEach(trailingIcons, id: \.self) { icon in
Button {
alertMessage = "\(icon.capitalized) clicked"
showAlert = true
} label: {
Image(systemName: icon)
}
}
}
}
}
}
}
The overflow menu appears automatically. I didn't write code to create it. SwiftUI saw 10 items, measured available space, and moved the extras to an overflow menu. But here's the frustrating part: SwiftUI doesn't give you any control over this overflow behavior. No API to customize it, no way to prioritize which items stay visible, no option to use a different overflow UI. The API feels incomplete—it just happens to you.
On iPhone, you see 4-5 icons plus a "•••" overflow button. On iPad, 5-7 icons before overflow. On macOS, all 10 might fit if the window is wide enough. Which items get hidden? SwiftUI decides. Want to keep the most important actions visible? Too bad. SwiftUI picks based on its own logic.
If you're building a simple iPhone app and wanted all 10 icons visible in a compact layout, you can't. SwiftUI decides based on Human Interface Guidelines, and you get no say in the matter.
What Apple Wants vs What Developers Often Need
Apple wants:
- Universal apps that run on iPhone, iPad, macOS, visionOS
- Consistent adherence to Human Interface Guidelines
- Automatic adaptation to different contexts
- Apps that feel native on each platform
Many developers want:
- Simple iPhone portrait app
- Exact control over button placement
- Predictable behavior (button always appears here)
- No hidden overflow menus
These goals conflict. SwiftUI's toolbar API optimizes for Apple's vision—universal apps that adapt everywhere. That's impressive engineering for cross-platform development. But for simple iPhone apps, it creates friction. The framework makes decisions for you, and you can't override them.