Issue #1062
iPhone Duo is Apple’s first multi-display iPhone: a wide inner display split by a hinge, an outer display with its own aspect ratio, and support for split view multitasking and multiple scenes like iPad. If your app assumes a single tall rectangle, it needs new thinking before it opens on this hardware.
Apple covers this across three tech talks:
- Raise the Bar with iPhone Duo - toolbars, navigation bars, and tab bars
- Strike a Pose with Adaptive Layouts on iPhone Duo - the hinge, reserved regions, and the new Arrangement container
- Leverage Multiple Displays and Scenes on iPhone Duo - hinge interactions, multitasking, and scene accessories
Rebuild against the latest SDK first. Nothing about bars, reserved regions, or arrangements changes until you do, so existing layouts on older SDKs stay untouched, but once you rebuild you’re opting into new default behavior worth auditing for.
Bars go vertical
On the outer display, vertical space is scarce and horizontal space is abundant, so navigation bars, toolbars, and tab bars move to the side edges instead of top and bottom. Opening to the inner display in portrait snaps them back to a familiar horizontal layout. It’s the same components on a different axis, not new ones, but this only applies to bars owned by system navigation containers.
Here are the new APIs, roughly in the order you’ll reach for them:
-
Adopt system containers. Pair
toolbarwithNavigationStack/NavigationSplitViewin SwiftUI, or useUINavigationController/UITabBarControllerin UIKit. A hand-rolledUIToolbaris not considered for vertical layout.// SwiftUI var body: some View { NavigationStack { ContentView() .toolbar { ToolbarItem(placement: .bottomBar) { ... } } } } -
ToolbarItemPlacement.cancellationAction/ UIKit’sleadingItemGroupswithleftItemsSupplementBackButton- place a custom back or close button at the top of the vertical stack.// SwiftUI .toolbar { ToolbarItem(placement: .cancellationAction) { ... } } // UIKit navigationItem.leftItemsSupplementBackButton = false navigationItem.leadingItemGroups = [UIBarButtonItemGroup(...)] -
ToolbarItemPlacement.topBarPinnedTrailing/ UIKit’spinnedTrailingGroup- pin a prominent action, like share, near the top.// SwiftUI .toolbar { ToolbarItem(placement: .topBarPinnedTrailing) { ... } } // UIKit navigationItem.pinnedTrailingGroup = UIBarButtonItemGroup(...) -
axisBehavior(_:)/ UIKit’sUIBarButtonItem.axisBehavior- override which axis an item prefers. Use.verticalPreferredfor a custom view that supports a vertical layout (like a compass), and.horizontalOnlyfor an item that shouldn’t go vertical even though it has an icon (like a button that toggles between an icon and “Done” text).// SwiftUI - allow a custom view to go vertical .toolbar { ToolbarItem { CompassView() } .axisBehavior(.verticalPreferred) } // SwiftUI - keep an item horizontal-only .toolbar { ToolbarItem { SelectOrDoneButton() } .axisBehavior(.horizontalOnly) } // UIKit let item = UIBarButtonItem(customView: CompassView()) item.axisBehavior = .verticalPreferred
-
badge(_:)/ UIKit’sUIBarButtonItem.badge- trim a custom view that mixes text and an icon (like an inline unread count) down to an icon plus a badge, so it qualifies for vertical placement.// SwiftUI ToolbarItem(...) { InboxButton().badge(7) } // UIKit let item = UIBarButtonItem(...) item.badge = .count(7) -
toolbarVerticalEdgeenvironment value in SwiftUI, or the equivalent trait in UIKit - read this from inside a custom view to adjust its own layout when it ends up in a vertical bar.// SwiftUI @Environment(\.toolbarVerticalEdge) var edge // UIKit switch traitCollection.verticalBarEdge { ... } -
toolbarVerticalCompressionBehavior(_:)/ UIKit’sverticalBarCompressionBehavior- choose whether the toolbar or the tab bar compresses first when space runs out.// SwiftUI Tab("Recents", systemImage: "clock") { ContentView() .toolbarVerticalCompressionBehavior(.prefersToolbarItems) } // UIKit navigationItem.verticalBarCompressionBehavior = .prefersBarItems -
ToolbarOverflowMenu/ UIKit’sadditionalOverflowItems- fold a custom overflow menu into the system-managed one instead of rolling your own.// SwiftUI .toolbar { ToolbarOverflowMenu { Button("Scan") { ... } Button("Connect") { ... } } } // UIKit navigationItem.additionalOverflowItems = UIDeferredMenuElement { provider in provider(self.persistentOverflowItems()) } -
visibilityPriority(_:)/ UIKit’sUIBarButtonItem.visibilityPriority- control which items survive longest as the bar runs out of room; items overflow bottom to top by default.// SwiftUI ToolbarItem { Button(...) { ... } } .visibilityPriority(.high) // UIKit item.visibilityPriority = .high -
toolbarVerticalBehavior(_:)/ UIKit’spreferredVerticalBarBehaviorwithUIVerticalBarBehavior- opt a view out of vertical bars entirely, for a bottom-heavy layout like a calculator or a single-button sheet.// SwiftUI NavigationStack { ContentView() .toolbarVerticalBehavior(.disabled) } // UIKit override var preferredVerticalBarBehavior: UIVerticalBarBehavior { .disabled }
Reserved regions and the Arrangement container
iPhone Duo has multiple displays, each with its own size class, plus hardware that carves out space: the hinge and the cameras on each display. Apple calls these reserved regions and recommends treating them like window controls on iPadOS, areas your layout flows around. The recommended pattern is displacement: move an element’s frame based on available space rather than letting it straddle the fold. Move elements independently if they behave independently, move them together if they’re related, and don’t move them far from where they started. Continuously scrolling content like feeds and lists shouldn’t displace at all, since scrolling already handles adaptation.
-
reservedRegions(kind:options:)onGeometryProxyin SwiftUI, or onUIViewin UIKit - query the fold (a division region) or a camera housing (an occlusion region). A division region is only active while the device is actually folded; pass.includeInactiveto query it even when flat, useful for decisions like preferring an even number of grid columns regardless of fold state.// SwiftUI GeometryReader { proxy in let regions = proxy.reservedRegions(kind: .division) } // SwiftUI - include inactive regions let regions = proxy.reservedRegions(kind: .division, options: .includeInactive) let frames = regions.map(\.frame) // SwiftUI - occlusion region (e.g. FaceTime camera) let regions = proxy.reservedRegions(kind: .occlusion) // UIKit let regions = view.reservedRegions(kind: .division) let frames = regions.map(\.frame)
-
ArrangementViewin SwiftUI,UIArrangementViewControllerin UIKit - a new layout container, between navigation containers and content containers, that arranges a primary and secondary view for you.// SwiftUI NavigationStack { ArrangementView { PlayerView() } secondary: { UpNextView() } }// UIKit let arrangementVC = UIArrangementViewController() let navController = UINavigationController(rootViewController: arrangementVC) let playerVC = PlayerViewController() arrangementVC.setViewController(playerVC, for: .primary) let upNextVC = UpNextViewController() arrangementVC.setViewController(upNextVC, for: .secondary)
-
arrangementViewStyle(_:)-SplitArrangementViewStyledivides bounds between the two views (main-detail relationships, like a transcript detailing what’s playing);OverlayArrangementViewStylestacks them instead (foreground-background relationships, like controls over readable content). Restrict split to one axis with.axes(_:).// SwiftUI - split, restricted to horizontal ArrangementView { PlayerView() } secondary: { UpNextView() } .arrangementViewStyle(.split.axes(.horizontal)) // SwiftUI - overlay ArrangementView { UpNextView() } secondary: { PlayerView() } .arrangementViewStyle(.overlay) // UIKit arrangementVC.updateArrangement(.split.axes(.horizontal)) -
overlayArrangementZIndexenvironment value in SwiftUI, orUIArrangementViewController.state(for:)in UIKit - react as the secondary view’s stacking order changes in an overlay arrangement, for example switching between a collapsed and expanded layout as the device folds.// SwiftUI struct UpNextView: View { @Environment(\.overlayArrangementZIndex) private var zIndex: Int var minimization: UpNextMinimization { zIndex > 0 ? .collapsed : .expanded } } // UIKit let primaryState = arrangementVC.state(for: .primary) myModel.minimization = (primaryState?.zIndex ?? 0) > 0 ? .collapsed : .expanded
Don’t nest a NavigationSplitView inside an arrangement, and don’t nest an arrangement inside a List or ScrollView, arrangements don’t provide navigation infrastructure and don’t play well inside scrollable containers.
The hinge, multitasking, and scene accessories
Opening iPhone Duo triggers a wallpaper zoom effect tracking the hinge angle. Your app can hook into the same signal.
-
onHingeChange(isEnabled:_:)in SwiftUI,UIHingeInteractionin UIKit - report a status of closed, partially open, or fully open, plus a continuous angle. This is for interactions and effects, not layout, use reserved regions and arrangements for that. Here’s the guitar pitch-bend example from the talk, built up step by step:struct InstrumentView: View { /// Normalized bend, 0 is no bend, 1 is deepest bend @State private var pitchBend: Double = 0 var body: some View { GuitarView(pitchBend: pitchBend) .onHingeChange { _, context in // A null hinge means the device doesn't have one if let hinge = context.hinge, hinge.status == .partiallyOpen { pitchBend = calculatePitchBend(angle: hinge.angle) } else { pitchBend = 0 } } } private func calculatePitchBend(angle: Angle) -> Double { ... } }
-
UIWindowScene.ActivationAction- all apps get side-by-side and stacked multitasking for free via size classes and scene geometry, the same as resizing on iPad. The one iPhone Duo-specific wrinkle: the outer display can’t create new windows, only the inner display can, so requesting a new scene can fail in a way it wouldn’t on iPad.UIWindowScene.ActivationActionhides itself automatically when that’s the case. -
UIViewController.registerSceneAccessory(_:)withUISceneAccessoryin UIKit,sceneAccessory(content:)in SwiftUI - show supplementary content on the other display at the same time as your main UI. New specifically for camera apps:CameraCaptureAccessoryin SwiftUI,UISceneAccessory.cameraCapture(sceneConfiguration:)in UIKit, pairs UI on the outer display (like a teleprompter) while your main camera UI stays on the inner display. Available only when your app is full screen on the inner display with an active camera session.// 1. Register the accessory on the same view as your camera UI. struct CameraRootView: View { @State private var model = TeleprompterModel() var body: some View { CameraView(model: model) .sceneAccessory { CameraCaptureAccessory { TeleprompterView(model: model) } } } }// 2. Add a toolbar toggle bound to the accessory's enabled state. .sceneAccessory { CameraCaptureAccessory(isEnabled: $model.isEnabled) { TeleprompterView(model: model) } } .toolbar { TeleprompterToggle(isEnabled: $model.isEnabled) }// 3. Observe availability so the toggle disables itself, e.g. when the device closes. .sceneAccessory { CameraCaptureAccessory(isEnabled: $model.isEnabled) { TeleprompterView(model: model) } .onAvailabilityChange { newValue in model.isAvailable = newValue } } .toolbar { TeleprompterToggle(isEnabled: $model.isEnabled) .disabled(!model.isAvailable) }In UIKit,
registerSceneAccessory(_:)returns aUISceneAccessoryRegistrationwhoseisAvailableproperty you observe with Swift’s observation tracking, the same roleonAvailabilityChangeplays in SwiftUI.
Start the conversation