How to adapt layout for iPhone Duo

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:

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.

Image

Here are the new APIs, roughly in the order you’ll reach for them:

  • Adopt system containers. Pair toolbar with NavigationStack/NavigationSplitView in SwiftUI, or use UINavigationController/UITabBarController in UIKit. A hand-rolled UIToolbar is not considered for vertical layout.

    // SwiftUI
    var body: some View {
        NavigationStack {
            ContentView()
                .toolbar {
                    ToolbarItem(placement: .bottomBar) { ... }
                }
        }
    }
    
  • ToolbarItemPlacement.cancellationAction / UIKit’s leadingItemGroups with leftItemsSupplementBackButton - 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’s pinnedTrailingGroup - pin a prominent action, like share, near the top.

    // SwiftUI
    .toolbar {
        ToolbarItem(placement: .topBarPinnedTrailing) { ... }
    }
    
    // UIKit
    navigationItem.pinnedTrailingGroup = UIBarButtonItemGroup(...)
    
  • axisBehavior(_:) / UIKit’s UIBarButtonItem.axisBehavior - override which axis an item prefers. Use .verticalPreferred for a custom view that supports a vertical layout (like a compass), and .horizontalOnly for 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
    
Image
  • badge(_:) / UIKit’s UIBarButtonItem.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)
    
  • toolbarVerticalEdge environment 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’s verticalBarCompressionBehavior - 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’s additionalOverflowItems - 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’s UIBarButtonItem.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’s preferredVerticalBarBehavior with UIVerticalBarBehavior - 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 }
    
Image

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:) on GeometryProxy in SwiftUI, or on UIView in 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 .includeInactive to 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)
    
Image
  • ArrangementView in SwiftUI, UIArrangementViewController in 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)
    
Image
  • arrangementViewStyle(_:) - SplitArrangementViewStyle divides bounds between the two views (main-detail relationships, like a transcript detailing what’s playing); OverlayArrangementViewStyle stacks 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))
    
  • overlayArrangementZIndex environment value in SwiftUI, or UIArrangementViewController.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
    
Image

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, UIHingeInteraction in 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 { ... }
    }
    
Image
  • 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.ActivationAction hides itself automatically when that’s the case.

  • UIViewController.registerSceneAccessory(_:) with UISceneAccessory in 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: CameraCaptureAccessory in 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 a UISceneAccessoryRegistration whose isAvailable property you observe with Swift’s observation tracking, the same role onAvailabilityChange plays in SwiftUI.

Image
Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation