- Published on
- 9 min read Intermediate
> Vertical Toolbars on iPhone Duo: Controlling Bar Placement in SwiftUI
The first thing most people will notice about apps on iPhone Duo is where the buttons went. On the outer display, the status bar, the navigation bar's buttons, the toolbar, and the tab bar all move to a single column on the side of the screen. The outer display is wider and shorter than other iPhones, so giving the full height to content makes sense, but it changes how your toolbar has to behave.
If your bars come from SwiftUI's navigation containers, you get the vertical layout for free. What you don't get for free is a good result. A vertical bar only has room for a few symbols, so this post is about deciding which items go there, which ones overflow, and the rare cases where you should opt out. Everything here uses the iOS 27.1 SDK in Xcode 27.1 beta.
When Bars Go Vertical
On the outer display, the vertical layout is what you get when the device is closed. The inner display keeps normal horizontal bars in portrait, because there's enough height for them, and moves them to the side in landscape so the layout stays consistent as someone opens and closes the device.
There are a few context-specific rules in Apple's Preparing your app for iPhone Duo guide. In a split view showing several columns, the sidebar and content columns keep horizontal bars while the detail column gets the vertical one. Inspectors always use horizontal bars. Sheets on the outer display use vertical bars by default, while sheets on the inner display only go vertical when they're placed on the trailing side, which you can influence with presentationPlacement(_:).
When two apps share the inner display in Split View, each one puts its controls on its outer edge. So don't hard-code a side. Your app can end up with the bar on the leading edge in one configuration and the trailing edge in another.
Getting It for Free
The system only moves bars it owns. Attach your items with toolbar(content:) to something inside a NavigationStack or NavigationSplitView and they'll move. A custom HStack of buttons pinned to the bottom of the screen won't, and on the outer display it'll eat into the height the system was trying to give back to you.
Order matters more on a vertical bar because people scan it from the top. Apple's guidance is to keep navigation controls like Back or Close at the top, followed by the prominent action, such as Done or Save. Semantic placements tell the system which item is which:
struct TripEditorView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
TripForm()
.navigationTitle("Edit Trip")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") { dismiss() }
}
ToolbarItem(placement: .topBarPinnedTrailing) {
Button("Save", systemImage: "checkmark") { dismiss() }
}
ToolbarItemGroup(placement: .bottomBar) {
Button("Add Stop", systemImage: "mappin.and.ellipse") { }
Button("Invite", systemImage: "person.badge.plus") { }
}
}
}
}
}
cancellationAction is the placement Apple recommends for a custom Close button, and topBarPinnedTrailing is the one for a prominent action. Pinned items only move to the overflow menu when search is active and there isn't enough room. On the vertical bar, the system also leaves a gap between the items that came from the top bar and the ones that came from the bottom bar so the two groups stay distinct.
Icons, Titles, and Axis Behavior
Notice that every button above has both a title and a symbol. That's deliberate. A vertical bar shows only the icon, a horizontal bar shows the icon or the title (preferring the icon), and the overflow menu shows both. If you give an item only a title, the system won't put it in the vertical bar at all, and the same goes for items that use a custom view.
That default comes from ToolbarItemAxisBehavior. With .automatic, image items can go on either axis while text and custom views are limited to horizontal bars. You can override it per item with the new axisBehavior(_:) modifier:
struct MapScreen: View {
@State private var showsLayers = false
@State private var syncProgress = 0.4
var body: some View {
NavigationStack {
TrailMap()
.toolbar {
ToolbarItem {
SyncRing(progress: syncProgress)
}
.axisBehavior(.verticalPreferred)
ToolbarItem {
Button("Layers", systemImage: "square.3.layers.3d") {
showsLayers = true
}
}
.axisBehavior(.horizontalOnly)
}
}
}
}
.verticalPreferred lets an item use either axis and prefers the vertical bar when both kinds of bar are on screen. That's how you get a compact custom view, like the sync ring above, onto the side bar. The layers button has a symbol, so by default it could go vertical too, and .horizontalOnly keeps it off the side bar. That's the one to be careful with. An item limited to horizontal bars simply isn't shown when no horizontal bar exists, which is the normal state of the outer display. The HIG asks you to keep the same functionality available in every pose, so only use it for controls that are also reachable somewhere else, like a layers button that duplicates an option in your map settings.
Running Out of Room
A vertical bar runs out of room quickly, and when it does, items move into the overflow menu starting from the bottom. You can change that order with visibilityPriority(_:), which is new in iOS 27. Lower-priority items overflow first. Apple suggests setting priorities on whole groups before tuning individual items, and keeping anything that shows status, like a badge, visible longer.
.toolbar {
ToolbarItem {
Button("Compose", systemImage: "square.and.pencil") { }
}
.visibilityPriority(.high)
ToolbarItemGroup {
Button("Archive", systemImage: "archivebox") { }
Button("Move", systemImage: "folder") { }
}
.visibilityPriority(.low)
ToolbarOverflowMenu {
Button("Export as PDF", systemImage: "doc.richtext") { }
Button("Print", systemImage: "printer") { }
}
}
ToolbarOverflowMenu is for actions that always belong in the overflow menu, regardless of how much space there is. If your app has its own ellipsis menu in the toolbar, this is the time to fold it into the system one. Otherwise people end up with two "more" buttons on a bar that only has room for a handful of symbols.
Tab bars add a second kind of pressure. In an app with a TabView, the tab bar and the toolbar share the same vertical strip. When there isn't room for both, something has to give. By default the system keeps the tab bar and pushes toolbar items into the overflow menu, which suits navigation-heavy apps where switching tabs is the main thing people do.
For task-focused screens, such as an editor or a scanner, the toolbar actions matter more than the tabs. toolbarVerticalCompressionBehavior(_:) lets you flip the priority:
struct ReceiptsApp: View {
var body: some View {
TabView {
Tab("Scan", systemImage: "doc.viewfinder") {
NavigationStack {
ReceiptScanner()
.toolbar {
Button("Flash", systemImage: "bolt") { }
Button("Crop", systemImage: "crop") { }
}
.toolbarVerticalCompressionBehavior(.prefersToolbarItems)
}
}
Tab("History", systemImage: "clock") {
NavigationStack { ReceiptHistory() }
}
}
}
}
With .prefersToolbarItems, the tab bar compresses before the toolbar does. .prefersTabBar does the opposite, and .automatic leaves the decision to the system.
Reading the Edge and Opting Out
Custom floating UI needs to know where the system bar is. The toolbarVerticalEdge environment value reports the leading or trailing edge the system prefers in the current context, even when the bar isn't visible right now, and it's nil on hardware or in size classes that never use a vertical bar. Here I keep a record button on the opposite side so it never collides with the bar:
struct VoiceMemoView: View {
@Environment(\.toolbarVerticalEdge) private var barEdge
var body: some View {
ZStack(alignment: barEdge == .trailing ? .bottomLeading : .bottomTrailing) {
WaveformView()
RecordButton()
.padding()
}
}
}
Opting out entirely is possible with toolbarVerticalBehavior(.disabled). Bar content falls back to horizontal bars at the top and bottom, and the status bar goes back to its usual position. Apple limits the legitimate uses to interfaces that are better with horizontal bars, like a full-screen video player with playback controls or a non-scrolling layout like a calculator. Treat it as a fixed decision for that screen rather than something you toggle as state changes, and reach for toolbarVisibility(_:for:) if you only want to hide bars.
struct ClipPlayerScreen: View {
var body: some View {
NavigationStack {
ClipPlayer()
.toolbar {
Button("Share", systemImage: "square.and.arrow.up") { }
}
.toolbarVerticalBehavior(.disabled)
}
}
}
Where you apply it matters. A NavigationStack uses the preference of its topmost view, a TabView uses the selected tab, and a NavigationSplitView uses its trailing-most column. When the value changes, the system animates the switch and adds or removes the leading or trailing safe area inset the vertical bar was using. For hero images that should run under a vertical bar instead, use backgroundExtensionEffect().
UIKit Equivalents
UIKit gets the same controls, spread across the objects you'd expect. UIBarButtonItem has an axisBehavior property with the same three cases and a visibilityPriority. UINavigationItem gains verticalBarCompressionBehavior, with .prefersBarItems and .prefersTabBar. The trait collection reports verticalBarEdge, and view controllers opt out by overriding preferredVerticalBarBehavior:
final class PlaylistViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let shuffle = UIBarButtonItem(title: "Shuffle", image: UIImage(systemName: "shuffle"))
shuffle.visibilityPriority = .high
let speed = UIBarButtonItem(customView: PlaybackSpeedControl())
speed.axisBehavior = .verticalPreferred
navigationItem.rightBarButtonItems = [shuffle, speed]
navigationItem.verticalBarCompressionBehavior = .prefersBarItems
}
}
final class FullScreenVideoViewController: UIViewController {
override var preferredVerticalBarBehavior: UIVerticalBarBehavior {
.disabled
}
}
If that preference depends on state, call setNeedsUpdateOfVerticalBarConfiguration() after it changes. Container controllers forward the question to their active child, so a navigation controller asks its top view controller. The older APIs still apply too: pinnedTrailingGroup for a prominent action, leadingItemGroups for a custom Back or Close button, and additionalOverflowItems for overflow-only actions.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
Wrapping Up
Most of the vertical bar work is housekeeping. Put your bars in navigation containers, give every item a symbol and a title, and decide which items matter most so the overflow menu takes the right ones. Then run through the outer display, the inner display in landscape, and Split View in the simulator, and reach for axisBehavior or the compression behavior only where the defaults pick wrong. For the rest of the iPhone Duo checklist, see How to Get Your App Ready for iPhone Duo.
// Continue_Learning
Building a Teleprompter for iPhone Duo's Outer Display with CameraCaptureAccessory
When iPhone Duo is open, its outer display faces the same way as the rear camera. CameraCaptureAccessory lets your app put content there while it records. Here's how to build a scrolling teleprompter with it in SwiftUI and UIKit.
Reading the iPhone Duo Hinge Angle with onHingeChange in SwiftUI
iOS 27.1 lets you read iPhone Duo's hinge angle and fold status with onHingeChange in SwiftUI and UIHingeInteraction in UIKit. Here's how the APIs work, why they're for interactive effects rather than layout, and a fold-to-scrub example.
Keeping Content Clear of the iPhone Duo Fold with ReservedRegion
iPhone Duo reserves parts of its displays for the fold and the front cameras. Learn which regions exist, when system views handle them for you, and how to query ReservedRegion in SwiftUI and UIKit to move custom content out of the way.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.