-
Notifications
You must be signed in to change notification settings - Fork 0
Book 07 Toolbars And Tab Views
Part III — The User Interface · Claude's Xcode 26 Swift Bible
← Book-06-Controls-Buttons-Toggles-Pickers · Chapters and Appendices · Book-08-Lists-Grids-And-ForEach →
Every concept in this chapter ships in real apps you can install or clone. CryoTunes Player, Claudes LockBox, QuickNote, Audio Universe, and Snap & ScanKeeper all use the
.toolbarmodifier andTabViewpatterns this chapter teaches. Tap through any of them, browse their source via Under the Hood, and you'll see the same shapes the chapter walks through. The most concentrated example is CryoTunes Player — itsCryoTransportControls.swiftshows toolbar-equivalent controls (transport buttons, mode toggles, share buttons) drawn from a clean SwiftUIHStackrather than the.toolbarmodifier; reading it side-by-side with this chapter shows when the modifier is the right tool and when a custom layout serves better. Source: github.com/fluhartyml/CryoTunesPlayer. See Source Tour 18 for the architecture.
Liquid Glass applies to the topmost layer of the interface — exactly where toolbars and tabs live. Apple's word: "Key navigation elements like tab bars and sidebars float in this Liquid Glass layer to help people focus on the underlying content."[[t1]] Three new APIs land in X26 to take advantage of that.
If your tab bar has a search tab, mark it with the search role and the system handles the rest:
TabView {
Tab("Browse", systemImage: "rectangle.stack") {
BrowseView()
}
Tab("Library", systemImage: "books.vertical") {
LibraryView()
}
Tab(role: .search) {
SearchView()
}
}What the role buys you: the search tab gets pulled out of the regular tab lineup and pinned to the trailing end of the bar, by itself. Every app that uses the role agrees on that location, so users learn it once and use it everywhere.[[t1]]
X26 will minimize the tab bar out of the way while the user is actively scrolling, freeing up vertical space for whatever they're reading. It's an opt-in — one modifier flips it on:[[t1]]
TabView {
// ...
}
.tabBarMinimizeBehavior(.onScrollDown)Two flavors. .onScrollDown tucks the bar away as the user scrolls down through new content and brings it back when they scroll up. .onScrollUp reverses the trigger. The right pick is whichever direction lines up with how the content in this tab is meant to be read.
A tab bar that works on iPhone is often the wrong shape for iPad or Mac — those screens have room for a sidebar that an iPhone doesn't. .sidebarAdaptable is the SwiftUI tab style that lets one TabView serve both: the system promotes the tabs into a sidebar when the layout has space and falls back to a tab bar when it doesn't. One declaration, two presentations, no parallel code paths.[[t1]]
[t1] Apple Developer Documentation, *Adopting Liquid Glass*. [developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass](https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass) — verified 2026-04-29.
The .toolbar modifier is the single entry point for adding buttons, menus, and controls to navigation bars, bottom bars, and keyboard accessories.
struct ItemListView: View {
var body: some View {
List(items) { item in
Text(item.name)
}
.navigationTitle("Items")
.toolbar {
Button("Add", systemImage: "plus") {
addItem()
}
}
}
}Without specifying a placement, the system puts the button where it makes sense for the platform — trailing on iOS, trailing in the toolbar on macOS.
Use ToolbarItem when you need to control exactly where a button lands.
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Save") { save() }
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .destructiveAction) {
Button("Delete", role: .destructive) { delete() }
}
}Group multiple items in the same placement.
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button("Share", systemImage: "square.and.arrow.up") { share() }
Button("Edit", systemImage: "pencil") { edit() }
Button("Add", systemImage: "plus") { add() }
}
}These let the system decide the exact position based on platform conventions.
| Placement | iOS | macOS | Use For | |-----------|-----|-------|---------| | .automatic | Trailing nav bar | Toolbar area | Default, system decides | | .primaryAction | Trailing nav bar | Trailing toolbar | Main action (Save, Add) | | .secondaryAction | Overflow menu | Toolbar customization | Less-used actions | | .cancellationAction | Leading nav bar | Leading toolbar | Cancel/Dismiss | | .confirmationAction | Trailing nav bar | Trailing toolbar | Confirm/Done in sheets | | .destructiveAction | Trailing nav bar | Trailing toolbar | Delete/Remove | | .navigation | Leading nav bar | Leading toolbar | Back-like navigation |
| Placement | Where | |-----------|-------| | .topBarLeading | iOS: left side of nav bar | | .topBarTrailing | iOS: right side of nav bar | | .bottomBar | iOS: bottom bar above tab bar | | .keyboard | iOS: above the keyboard | | .tabBar | Inside the tab bar area |
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button("Previous", systemImage: "chevron.left") { previous() }
Spacer()
Text("Page \(currentPage) of \(totalPages)")
Spacer()
Button("Next", systemImage: "chevron.right") { next() }
}
}Add a "Done" button above the keyboard. Essential for dismissing number pads and other keyboards that lack a return key.
TextField("Amount", value: $amount, format: .number)
.keyboardType(.decimalPad)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
isFocused = false
}
}
}-
.keyboardplacement only shows when the keyboard is visible. If you have multiple text fields, the keyboard toolbar is shared — whichever field is focused gets it. -
.bottomBardoes not appear if the view is inside aTabView. The tab bar occupies that space. Use.tabBarplacement instead if you need items alongside tabs.
Hide or show toolbars explicitly.
.toolbar(.hidden, for: .navigationBar) // hide the nav bar
.toolbar(.visible, for: .bottomBar) // force bottom bar visible
.toolbar(.hidden, for: .tabBar) // hide tab bar (e.g., in detail views)The for: parameter targets: .navigationBar, .bottomBar, .tabBar, .windowToolbar (macOS).
- Hiding
.tabBaronly works from a view inside aTabView. If you hide it from outside the tab hierarchy, nothing happens. - Use
.toolbar(.hidden, for: .tabBar)on detail views where you want a full-screen experience. It animates smoothly.
WindowGroup {
ContentView()
}
.windowToolbarStyle(.unified) // toolbar merges with title bar
.windowToolbarStyle(.unifiedCompact) // thinner merged toolbar
.windowToolbarStyle(.expanded) // toolbar below title barUsers can customize the macOS toolbar by default. To control what is customizable:
.toolbar(id: "main") {
ToolbarItem(id: "add", placement: .primaryAction) {
Button("Add", systemImage: "plus") { }
}
ToolbarItem(id: "share", placement: .secondaryAction) {
Button("Share", systemImage: "square.and.arrow.up") { }
}
}
.toolbarRole(.editor) // changes toolbar behavior and appearance- On macOS,
.primaryActionitems are always visible..secondaryActionitems go into the customization palette and may be hidden by default. -
ToolbarItem(id:)requires a stable string identifier for toolbar customization persistence. Without IDs, user customizations are lost between launches.
struct RootView: View {
@State private var selectedTab: AppTab = .home
var body: some View {
TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house", value: .home) {
NavigationStack {
HomeView()
}
}
Tab("Library", systemImage: "books.vertical", value: .library) {
NavigationStack {
LibraryView()
}
}
Tab("Profile", systemImage: "person", value: .profile) {
NavigationStack {
ProfileView()
}
}
}
}
}
enum AppTab: Hashable {
case home, library, profile
}Tab("Inbox", systemImage: "tray", value: .inbox) {
InboxView()
}
.badge(unreadCount) // integer badge
.badge("New") // text badgeBadges appear as a small indicator on the tab icon. On iOS, it is a red circle with the count. On macOS, it is a text overlay.
On iPadOS with sufficient width, TabView can render as a sidebar. Group tabs with TabSection.
TabView {
Tab("Home", systemImage: "house", value: .home) {
HomeView()
}
TabSection("Library") {
Tab("Books", systemImage: "book", value: .books) {
BooksView()
}
Tab("Audiobooks", systemImage: "headphones", value: .audiobooks) {
AudiobooksView()
}
}
TabSection("Account") {
Tab("Profile", systemImage: "person", value: .profile) {
ProfileView()
}
Tab("Settings", systemImage: "gear", value: .settings) {
SettingsView()
}
}
}
.tabViewStyle(.sidebarAdaptable)For swipeable pages (onboarding, image galleries).
TabView {
OnboardingPage1()
OnboardingPage2()
OnboardingPage3()
}
.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))- Each tab should own its own
NavigationStack. Wrapping the entireTabViewin a singleNavigationStackcauses the nav bar to appear above tabs and navigation pushes replace the whole tab interface. -
.tabViewStyle(.page)hides the tab bar entirely. It shows dots instead. Do not mix page style with labeled tabs. - Tab order is the order you declare them. There is no reordering API like UIKit's "More" tab — though the sidebar-adaptable style on iPad does support reordering.
When the built-in tab bar does not meet your needs, build your own.
struct CustomTabBar: View {
@Binding var selectedTab: AppTab
var body: some View {
HStack {
ForEach(AppTab.allCases, id: \.self) { tab in
Button {
selectedTab = tab
} label: {
VStack(spacing: 4) {
Image(systemName: tab.icon)
.font(.title2)
Text(tab.title)
.font(.caption)
}
.foregroundStyle(selectedTab == tab ? .blue : .gray)
.frame(maxWidth: .infinity)
}
}
}
.padding(.vertical, 8)
.background(.bar)
}
}
// Usage
struct RootView: View {
@State private var selectedTab: AppTab = .home
var body: some View {
VStack(spacing: 0) {
Group {
switch selectedTab {
case .home: NavigationStack { HomeView() }
case .library: NavigationStack { LibraryView() }
case .profile: NavigationStack { ProfileView() }
}
}
.frame(maxHeight: .infinity)
CustomTabBar(selectedTab: $selectedTab)
}
}
}- Custom tab bars do not get the system safe area handling for free. You need to account for the home indicator on modern iPhones.
- You lose automatic state preservation that
TabViewprovides. Eachswitchcase re-creates the view. Use@Stateor a view model to preserve state across tab switches.
-
Use semantic placements (
.primaryAction,.cancellationAction) over positional ones (.topBarTrailing). Semantic placements adapt correctly across platforms. -
Keyboard toolbar is essential for number pads. Users cannot dismiss a
.decimalPador.numberPadkeyboard without a Done button. -
Hide the tab bar in detail views with
.toolbar(.hidden, for: .tabBar)for immersive content like photo viewers or media players. -
Keep toolbar items minimal. Two to three items max per placement. Overloaded toolbars confuse users and look cramped on smaller devices.
-
Test on both iPhone and iPad. Toolbar items can shift positions dramatically between compact and regular size classes. What looks right on one screen may be wrong on another.
-
18pt minimum applies to toolbar labels too. If you use custom toolbar views with text, keep them readable.
← Book-06-Controls-Buttons-Toggles-Pickers · Chapters and Appendices · Book-08-Lists-Grids-And-ForEach →
Feedback: Found something off? Open an issue · Discuss it · Email Michael
Claude's X26 Swift6 Bible | GPL v3 | Built with Claude by Anthropic | Repo