· 23 min read

iPhone Duo for iOS developers: a visual field guide

What actually changes when your iPhone app runs on a foldable: size classes, vertical bars, split views, sheets, and the new Duo-only APIs, with diagrams, simulator screenshots and code you can paste.

  • iPhone Duo
  • SwiftUI
  • UIKit
  • iOS 27.1
  • Adaptive layout

iPhone Duo is the first iPhone that changes size while your app is running. Closed, it’s a compact phone with a slightly unusual shape. Open, it’s an almost iPad-mini-sized canvas. In between, a hinge splits the display in two.

If you’ve shipped iPhone apps for years, most of your instincts still hold. A few of them now quietly produce the wrong layout, though, and some system components you thought you understood (NavigationSplitView, toolbars, sheets) behave differently than they ever did on iPhone.

This guide is for experienced iOS developers who want to know what’s different, what you have to change, and what you can safely ignore. It covers the mental model first, then the specific components, then two playbooks: starting a new project and migrating an existing one.

TL;DR: the five things that change

  1. Your app resizes mid-session. Folding and unfolding is a size and trait change, not a relaunch. Your process keeps running.
  2. Regular × regular is no longer “iPad”. The inner display is horizontally and vertically regular, on an iPhone.
  3. Bars move to the side. In most poses, navigation bars, toolbars and tab bars share a vertical strip along the edge of the display. Only the inner display in portrait keeps classic horizontal bars.
  4. System containers do most of the work. NavigationSplitView, TabView, sheets, alerts and menus adapt to every pose and avoid the fold automatically. Custom chrome doesn’t.
  5. The new Duo APIs are optional. ArrangementView, ReservedRegion and onHingeChange are for custom layouts and special effects. You don’t need them to be a good Duo citizen.

The device, in developer terms

Hardware-wise, iPhone Duo has two displays, each with its own front camera, joined by a hinge. Your app lives on one of them at a time: the outer display when closed, the inner one when open.

  • The outer display is what people use when the phone is closed. It’s wider and shorter than a classic iPhone screen, and its camera sits in a corner and is always visible.
  • The inner display is the large one. Its camera hides behind the screen and only appears while it’s in use.
  • The hinge lets people hold the phone partially folded, like a book, set it on a table like a laptop, or stand it on its edges like a tent.

Apple’s design guidance is very clear on one point: don’t design a layout per pose. Design for two size classes, compact (outer) and regular (inner), and let the layout flow between them. The poses are mostly a combination of which display and which orientation, plus one new thing: the fold.

Click through the poses to see what your app gets in each one:

Interactive: one app, every pose

Classic iPhone
iPhone Duo: closed
iPhone Duo: open
Partially folded
Multitasking
Display
Outer display: wider and shorter than a classic iPhone
Horizontal size class
Compact
Vertical size class
Regular
Bars
Vertical, along the side, sharing space with the status bar and Dynamic Island
Fold region
Not on this display
What to notice
Same size classes as a classic iPhone in portrait, but a different shape. The outer camera always occludes a corner, and your content gets the width next to the bar.
System barsProminent action (e.g. Done)Fold (division region)Camera / Dynamic Island
Schematic, not to scale. Size classes and bar placement follow Apple’s “Prepare your app for iPhone Duo” tech talk and the Human Interface Guidelines. Split View size classes aren’t documented as fixed values, so read them at runtime.

Here’s how that looks in the simulator. I put a small diagnostics screen in the test app that prints the current size classes, the vertical bar edge and the fold region:

Same screen, three poses (iPhone Duo simulator)

Closed: horizontal compact, vertical regular, vertical bar on the trailing edge, no division region
Closedcompact × regular · 382×562 pt
Open: horizontal regular, vertical regular, division region inactive 40×669
Openregular × regular · fold inactive
Partially folded: still regular × regular, division region now active 40×669
Partially foldedstill regular × regular · fold active
Folding didn't change the size classes or the app's size. Only the division region switched from inactive to active. Bars sit on the trailing edge in all three.

Size classes: the new map

If your mental model is “classic iPhone portrait = compact width, Duo closed = compact, Duo open = regular”, it’s correct. Apple’s exact wording from the Prepare your app talk:

On the outer display, like other iPhone models, iPhone Duo has regular vertical size class and compact horizontal size class in portrait, and compact vertical and horizontal size classes in landscape. On the inner display, the additional space lets you show more content like sidebars. As such, it has regular horizontal and vertical size classes.

Where each device state lands

Horizontal: compact
Horizontal: regular
Vertical: regular
Classic iPhone, portraitiPhone Duo closed, portrait
iPad (full screen)iPhone Duo open: portrait, landscape and partially foldedNew for iPhone
Vertical: compact
Classic iPhone, landscapeiPhone Duo closed, landscape
Plus / Pro Max, landscape
iPhone DuoDevices you already support
Split View on the inner display and resizable iPhone Mirroring can produce any combination. Apple: “an iPhone app can have any combination of size classes.”

Three consequences that are easy to miss:

1. “Regular” no longer implies iPad. Any code that says “if regular width, this must be the iPad layout” now runs that layout on an iPhone. Apple’s prepare checklist calls this out explicitly: search for userInterfaceIdiom == .pad and for code that treats horizontalSizeClass == .regular as iPad. There is no new idiom for iPhone Duo. It’s .phone.

2. Folding is not a size class. A partially folded inner display is still regular × regular. The fold shows up as a reserved region (more on that below). If your layout needs to react to the fold, size classes alone won’t tell you.

3. Orientation is not a layout signal anymore. The inner display doesn’t honor your supported interface orientations, so a portrait-locked app still gets landscape-shaped space when opened. Apple’s advice: stop checking orientation for layout decisions and use size classes and your container’s actual size instead.

// ✅ Branch on the size class, not on the device or orientation
struct LibraryRoot: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass

    var body: some View {
        if horizontalSizeClass == .regular {
            TwoColumnLibrary()      // inner display (and iPad)
        } else {
            SingleColumnLibrary()   // outer display (and classic iPhone)
        }
    }
}

And when you need actual dimensions, measure the container, not the screen:

// ❌ UIScreen.main.bounds: ambiguous on a two-display device,
//    and slated for deprecation
// ✅ Measure the space you were actually given
struct HeroHeader: View {
    @State private var width: CGFloat = 0

    var body: some View {
        Image("hero")
            .resizable()
            .aspectRatio(contentMode: width > 600 ? .fit : .fill)
            .onGeometryChange(for: CGFloat.self) { proxy in
                proxy.size.width
            } action: { newWidth in
                width = newWidth
            }
    }
}

What happens when someone folds the phone

On iPhone Duo, “my app changed size” is a normal event, not an edge case:

  • Your process stays alive. Apple’s DTS engineers put it as: treat display transitions as size-class and trait changes, not a scene disconnect or app termination.
  • State mostly survives on its own. Text input and scroll position are generally preserved, video keeps playing, and in-memory form state persists because nothing restarted.
  • Size can change at any moment. Reading the size once at launch or in viewIsAppearing isn’t enough. In UIKit, do size-dependent work in layoutSubviews/viewDidLayoutSubviews (or viewWillTransition(to:with:)); in SwiftUI, use GeometryReader or onGeometryChange.
  • UIRequiresFullScreen doesn’t stop it. The key is still honored, but your app resizes anyway when the device opens or closes. Apple recommends removing it (see TN3192).
  • Everyone does Split View. All apps take part in two-up multitasking on the inner display, and safe areas get asymmetric: vertical bars can sit on either side of your app.

How much of the display your app gets depends on the SDK you build with:

Same app, three SDKs

Built with iOS 26 SDK or earlier
Closed
Open

Runs and resizes, but uses less of the display: left of the status bar when closed, a centered window when open.

Built with iOS 27 SDK
Open

Resizes dynamically and fills most of the inner display, but still leaves room for the status bar on the right edge.

Built with iOS 27.1 SDK or later
Closed
Open

“Optimized for iPhone Duo”: full display, with toolbars and tab bars laid out vertically below the status bar.

Behaviour per SDK as described on Apple’s Prepare for iPhone Duo page. Schematic, not to scale.

This is where seasoned iPhone developers get surprised, because these containers now take code paths that rarely ran on iPhone before.

On a classic iPhone a NavigationSplitView collapses into a stack in practically every case (Plus/Max landscape aside). Plenty of iPhone-first apps use it as “a NavigationStack with a selection binding” and never think about the expanded state.

On iPhone Duo, Apple’s guidance is: closed, the columns collapse to single-stack navigation; open, they appear tiled and as overlays. So the expanded state, which you may never have designed, is now what every Duo owner sees when they open the phone.

One NavigationSplitView, four presentations

Classic iPhoneCollapsed to a stack
Duo closedStill a stack, bars on the side
Duo openExpands into columns
Duo partially foldedColumns go 50/50 around the fold
Sidebar column (its bar stays horizontal)Detail column’s bar (goes vertical)Fold
Only the detail (trailing) column takes part in the vertical bar; the sidebar and content columns keep horizontal bars. If nothing is selected, the detail column shows whatever your detail closure returns when the selection is nil, and on a classic iPhone that view almost never appeared.

Real behaviour (iPhone Duo simulator)

Closed: the split view collapsed into a list, with the Filter button and tabs in the vertical bar
ClosedCollapsed list · Filter + tabs in the side bar
Open: sidebar list on the left, empty detail with No Message Selected
Open, nothing selectedYour empty state is suddenly front and center
Open with a selection: detail toolbar buttons Archive and Reply moved into the vertical bar, sidebar Filter button stays at the top of the sidebar
Open, message selectedDetail items go vertical, sidebar items stay put
Partially folded: the sidebar widened to fill the left half of the display
Partially foldedSidebar snaps to the left half of the fold
The detail's .bottomBar items (Archive, Reply) moved into the vertical bar automatically; the sidebar's Filter button stayed horizontal. When folded, the column split moved to the fold.

What to do:

  • Design the empty detail. Return a real ContentUnavailableView (or a sensible default selection) for the nil case.
  • Decide what a compact launch shows. preferredCompactColumn lets you choose which column wins when collapsed.
  • Put the toolbar on the column it belongs to. Only the detail (trailing) column takes part in the vertical bar. Controls that act on the list should live on the list.
struct InboxSplitView: View {
    @State private var selection: Message.ID?
    @State private var compactColumn = NavigationSplitViewColumn.sidebar

    var body: some View {
        NavigationSplitView(preferredCompactColumn: $compactColumn) {
            List(Message.samples, selection: $selection) { message in
                MessageRow(message: message)
            }
            .navigationTitle("Inbox")
        } detail: {
            if let message = Message.samples.first(where: { $0.id == selection }) {
                MessageDetail(message: message)
            } else {
                // On classic iPhone you almost never saw this. On Duo you will.
                ContentUnavailableView("No Message Selected",
                                       systemImage: "envelope.open")
            }
        }
    }
}

TabView

By default, TabView adapts on its own: tabs are a classic bottom bar when the inner display is in portrait, and move to the bottom of the vertical side bar in every other pose. You don’t need to change anything.

What’s new is an opt-in sidebar for the inner display. Apple recommends it for information-dense apps:

TabView: default vs. sidebar opt-in

TabView { … } default
ClosedTabs at the bottom of the side bar
Open, portraitClassic bottom tab bar
Open, landscapeTabs in the side bar
.tabViewStyle(.sidebarAdaptable) + .defaultTabBarPlacement(.sidebar) opt-in
ClosedStill tabs in the side bar
Open, landscapeSidebar
defaultTabBarPlacement(.sidebar) (iOS 27) turns the tabs into a sidebar on the inner display; closed, they stay tabs in the side bar (verified in the iPhone Duo simulator). Apple recommends the sidebar for information-dense apps.
TabView(selection: $tab) {
    Tab("Inbox", systemImage: "tray", value: .inbox) { InboxSplitView() }
    Tab("Library", systemImage: "books.vertical", value: .library) {
        NavigationStack { LibraryScreen() }
    }
}
.tabViewStyle(.sidebarAdaptable)
.defaultTabBarPlacement(.sidebar)   // iOS 27: sidebar where it fits

UIKit equivalent: tabBarController.mode = .tabSidebar and tabBarController.sidebar.preferredPlacement = .sidebar.

NavigationStack needs no changes. Just make sure your toolbar items are attached inside it (see below). Apple’s Strike a pose talk lists navigation containers (NavigationStack, NavigationSplitView, TabView) and content containers (List, ScrollView) among the things that adapt to the fold for free.

Toolbars: the vertical bar

This is the biggest visual change, and the one most likely to need small code changes.

On the outer display (and on the inner display in landscape), the system takes everything that used to live in the top and bottom bars (back button, toolbar items, tab bar) and stacks it in a single vertical strip along the edge, next to the status bar and Dynamic Island. Because the strip is aligned with the hardware, it stays on the same side even in right-to-left languages.

Anatomy of the vertical bar (outer display)

  1. 1
    Status bar & Dynamic Island

    System elements share the side region with your controls. The Dynamic Island grows vertically for Live Activities.

  2. 2
    Back / Close

    Automatic with a navigation container. Custom close buttons go in .cancellationAction.

  3. 3
    Prominent action

    “Done”, “Add”… use .topBarPinnedTrailing (UIKit: pinnedTrailingGroup).

  4. 4
    Top-bar items, then bottom-bar items

    Your groups keep their order; the system adds a gap between items that came from the top and bottom bars.

  5. 5
    Overflow menu

    When space runs out, items collapse into ⋯, from the bottom up by default. Tune it with visibilityPriority.

  6. 6
    Tab bar

    Tabs sit at the bottom of the same region. Toolbar items vs. tabs: who compresses first is configurable.

Order follows the HIG’s “standard placement order for toolbar items”. The same structure appears on the inner display in landscape.

Do you have to change your toolbar code?

If you use .toolbar inside a NavigationStack/NavigationSplitView (or bar items on a UINavigationController), the vertical bar just works after you rebuild. What needs attention:

If your app… What happens on Duo Fix
Builds its own bar from UIToolbar, UINavigationBar, UITabBar, or a custom view pinned to the bottom Content of custom bars isn’t considered for the vertical bar Move items to the navigation container’s toolbar
Has text-only toolbar items Items with only a title never go vertical; they stay in a horizontal bar Give every item a title and a symbol (Button("Reply", systemImage: …))
Uses custom views (segmented controls, cart totals…) as items Custom views aren’t placed vertically by default Keep them .horizontalOnly, or opt in with .verticalPreferred and adapt the layout
Has many items They overflow into ⋯, bottom-up Set visibilityPriority, and move your own “more” menu into ToolbarOverflowMenu
Adds fixed spacers for alignment Flexible spacers collapse to zero vertically; fixed ones don’t Use ToolbarItemGroup instead of manual spacing
Is a full-screen, non-scrolling UI (calculator, video player) The side bar steals width you need toolbarVerticalBehavior(.disabled) for that screen

Here’s a toolbar written the “Duo-friendly” way. The first version uses nothing new, so it compiles with any recent SDK:

MessageDetail(message: message)
    .toolbar {
        ToolbarItem(placement: .cancellationAction) {       // top of the vertical bar
            Button("Close", systemImage: "xmark") { dismiss() }
        }
        ToolbarItemGroup(placement: .bottomBar) {           // grouped, title + symbol
            Button("Archive", systemImage: "archivebox") { }
            Button("Reply", systemImage: "arrowshape.turn.up.left") { }
        }
    }

And here it is tuned with the iOS 27/27.1 APIs:

.toolbar {
    ToolbarItem(placement: .cancellationAction) {
        Button("Close", systemImage: "xmark") { dismiss() }
    }
    ToolbarItem(placement: .topBarPinnedTrailing) {        // prominent action
        Button("Done", systemImage: "checkmark") { }
    }
    ToolbarItemGroup(placement: .bottomBar) {
        Button("Reply", systemImage: "arrowshape.turn.up.left") { }
        Button("Archive", systemImage: "archivebox") { }
    }
    .visibilityPriority(.high)                              // overflow these last

    ToolbarItem {
        Button("Filter", systemImage: "line.3.horizontal.decrease") { }
    }
    .axisBehavior(.horizontalOnly)                          // never in the side bar

    ToolbarOverflowMenu {                                   // your "more" items
        Button("Print", systemImage: "printer") { }
        Button("Move to Junk", systemImage: "xmark.bin") { }
    }
}

Toolbar vs. tab bar: who gives way?

When the side bar runs out of room (often in landscape on the outer display, or when the keyboard appears) something has to compress. By default toolbar items overflow first so tabs stay reachable. That suits navigation-heavy apps. For task-focused screens, flip it:

TabView {
    Tab("Recents", systemImage: "clock") {
        NavigationStack {
            RecentsView()
                .toolbarVerticalCompressionBehavior(.prefersToolbarItems)
        }
    }
}

UIKit: navigationItem.verticalBarCompressionBehavior = .prefersBarItems.

Custom chrome next to the bar

If you draw your own floating controls, read which edge the system bar uses so you don’t collide with it. toolbarVerticalEdge is nil wherever no vertical bar is ever used (classic iPhones, inner display in portrait):

struct PaletteHost: View {
    @Environment(\.toolbarVerticalEdge) private var verticalEdge

    var body: some View {
        FloatingToolPalette()
            .frame(maxWidth: .infinity, maxHeight: .infinity,
                   alignment: verticalEdge == .leading ? .trailing : .leading)
    }
}

UIKit: traitCollection.verticalBarEdge.

Sheets and other presentations

Sheets need no code changes to work, but they look different, and one modifier is worth knowing.

The same .sheet in three poses (iPhone Duo simulator)

Closed: the sheet fills the outer display and its Close button sits in the vertical bar
ClosedClose button moves into a vertical bar
Open: centered sheet with the Close button at the top left, horizontal
OpenCentered, horizontal bar
Partially folded: the sheet slid to the left half of the display, away from the fold
Partially foldedSlides to one side of the fold
Alerts, menus and popovers get the same fold avoidance. Apple: they're repositioned automatically around reserved regions to stay fully visible.

Closed: if the sheet has a toolbar, its items go vertical. For a sheet with a single Close button, that vertical strip wastes width. Apple suggests turning it off there:

.sheet(isPresented: $showHelp) {
    NavigationStack {
        HelpView()
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Close", systemImage: "xmark") { showHelp = false }
                }
            }
    }
    .toolbarVerticalBehavior(.disabled)   // iOS 27.1: keep this sheet's bar horizontal
}

The modifier is resolved per window or presentation, so inside a sheet it affects only the sheet. In a NavigationStack the top view wins, in a TabView the selected tab, in a NavigationSplitView the trailing column. Apple asks you to keep it stable: don’t toggle it as state changes.

Open: sheets are centered by default. With the new presentationPlacement(_:) you can pin them to an edge, which also decides whether they get a vertical bar:

Sheets on the inner display: placement decides the bar

.automatic / .centerCentered · horizontal bar
.leadingLeft side · horizontal bar
.trailingRight side · vertical bar
Set with presentationPlacement(_:) (SwiftUI, iOS 27) or preferredPlacement on UISheetPresentationController. Behaviour per Apple’s Preparing your app for iPhone Duo; schematic.
Map()
    .sheet(isPresented: $isPresented) {
        PlaceDetailView()
            .presentationDetents([.medium, .large])
            .presentationPlacement(.leading)   // iOS 27: keep the map visible
    }

The Duo-only APIs: do you need them?

Short answer: no, not to ship a good Duo app. Everything above uses containers you already know. The new APIs are for custom layouts the system can’t reason about, and for experiences that only make sense on a foldable.

How far do you need to go?

  1. 0
    Rebuild with Xcode 27.1+Required

    Without the iOS 27.1 SDK your app is centered or kept beside the status bar instead of using the full display.

  2. 1
    Resizing hygieneRequired

    Stop reading the screen, idiom or orientation. Lay out from size classes, container size and safe areas.

    horizontalSizeClassonGeometryChangesafeAreaInsets
  3. 2
    Bars & presentationsStrongly recommended

    Use system containers, give every toolbar item a title and a symbol, set priorities, decide per sheet.

    axisBehaviorvisibilityPrioritytoolbarVerticalBehaviorpresentationPlacement
  4. 3
    Fold-aware layoutsOptional

    For custom two-pane or overlay layouts and hand-placed controls that could land in the fold.

    ArrangementViewReservedRegion
  5. 4
    Duo-only experiencesOptional

    Hinge-driven effects, camera previews on the outer display, multiple windows.

    onHingeChangeCameraCaptureAccessory
Levels 0–2 are what “works well on iPhone Duo” means. Levels 3–4 are where you stand out.

The APIs in this section are iOS 27.1+. If you deploy to older versions, wrap them in one place (see Adopting without the mess) instead of sprinkling #available across your views.

ArrangementView: two views, every pose

ArrangementView (UIKit: UIArrangementViewController) lays out a primary and a secondary view based on size class, orientation and the fold. Think of it as a smart HStack/VStack/ZStack that knows about the hinge.

ArrangementView: where primary (P) and secondary (S) go

.arrangementViewStyle(.split)Two views, never on top of each other
PortraitP above S
LandscapeP leading, S trailing
Folded (book)Each on its own side
Closed, landscapePrimary only
.arrangementViewStyle(.overlay)Layered when flat, separated when folded
Flat (open or closed)P floats above S
Folded (laptop)S on top, P at the bottom
Folded (book)S leading, P trailing
Per Apple’s ArrangementView documentation (iOS 27.1). Limit the axes with .split.axes(.horizontal): in a tall container, a horizontal-only split shows just the primary view.
NavigationStack {
    ArrangementView {
        NowPlayingView()          // primary
    } secondary: {
        UpNextView()              // secondary
    }
    .arrangementViewStyle(.split) // or .overlay, or .split.axes(.horizontal)
}

When to reach for it. If you already have an HStack/VStack of two related panes, it translates to .split. If you have a ZStack with a clear foreground/background relationship (controls over video, a reader over an article), that’s .overlay.

Rules from Apple’s docs:

  • Keep navigation outside. Don’t put a NavigationSplitView or TabView inside an arrangement view; wrap the arrangement in your navigation container instead.
  • Don’t put it inside a List, ScrollView, grid or split view either.
  • Use @Environment(\.overlayArrangementZIndex) (UIKit: state(for:)) when a view should collapse while it’s layered underneath.

ReservedRegion: the fold and the cameras as data

The system describes areas you shouldn’t put content in as reserved regions:

  • .occlusion: something covers your view (the outer camera, always; the inner camera, only while active).
  • .division: something splits your view (the fold, active only while partially folded).

Most apps never query these. Split views, tab bars, sheets, alerts and menus already avoid them. Check them yourself only where your own code positions controls: floating buttons, custom overlays, custom Layouts, grids that look bad split down the middle.

A small but nice example. Apple’s HIG suggests preferring an even number of grid columns whenever a fold exists, active or not, so content divides cleanly:

@available(iOS 27.1, *)
struct FoldAwareGrid: View {
    let items = Array(0..<40)

    var body: some View {
        GeometryReader { proxy in
            let hasFold = !proxy.reservedRegions(
                kind: .division, options: .includeInactive
            ).isEmpty
            let fitting = max(1, Int(proxy.size.width / 160))
            let count = hasFold && fitting.isMultiple(of: 2) == false
                ? fitting + 1 : fitting

            ScrollView {
                LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: count)) {
                    ForEach(items, id: \.self) { ItemTile(index: $0) }
                }
                .padding()
            }
        }
    }
}

Including inactive regions also keeps the layout from jumping the moment someone starts folding. In the simulator, the fold reported a 40-point-wide region spanning the full height of the open display.

onHingeChange: for effects, not layout

onHingeChange (UIKit: UIHingeInteraction) streams the hinge status (.closed, .partiallyOpen, .fullyOpen) and its continuous angle. Apple is explicit: use it for interactions and effects, and use arrangement and region APIs for layout. Update rate and granularity are system policy and can change.

@available(iOS 27.1, *)
struct InstrumentView: View {
    @State private var bend: Double = 0

    var body: some View {
        GuitarView(bend: bend)
            .onHingeChange { _, context in
                // context.hinge is nil on devices without a hinge
                if let hinge = context.hinge, hinge.status == .partiallyOpen {
                    bend = 1 - min(max(hinge.angle.degrees / 180, 0), 1)
                } else {
                    bend = 0
                }
            }
    }
}

There’s no single “pose” value. If you really need to detect tabletop vs. book, combine the hinge status, the division region’s direction and the size class. Treat that as a last resort.

Also new: scenes and the camera

  • Multiple windows. iPhone Duo is the first iPhone that supports multiple instances of your UI, but only on the inner display. Request scenes with UIWindowSceneActivationAction, which hides itself when new windows aren’t available, and handle errors.
  • Camera capture accessory. Camera apps can show extra UI on the outer display (a teleprompter, or a preview for the person being photographed) with .sceneAccessory { CameraCaptureAccessory { … } }. It’s only available full screen on the inner display with an active capture session, and the system can withdraw it at any time.
  • Choosing cameras. Opening and closing can flip which way “the front camera” faces. Select cameras by facing direction and let AVCaptureDeviceDirectionCoordinator track it.

Adopting without the mess

The fear with new APIs is ending up with if #available and device checks everywhere. The pattern that works: put every Duo-specific decision behind a small, named abstraction, so feature code reads like normal SwiftUI.

1. Modifiers for one-line opt-ins. Your screens call .horizontalBarsOnly(); only this file knows about iOS 27.1:

extension View {
    /// For control-light sheets: keep the single close button horizontal.
    @ViewBuilder
    func horizontalBarsOnly() -> some View {
        if #available(iOS 27.1, *) {
            self.toolbarVerticalBehavior(.disabled)
        } else {
            self
        }
    }

    /// Opt-in: show the tabs as a sidebar where the layout is wide enough.
    @ViewBuilder
    func sidebarOnInnerDisplay() -> some View {
        if #available(iOS 27.0, *) {
            self.defaultTabBarPlacement(.sidebar)
        } else {
            self
        }
    }
}

2. The same for toolbar content. ToolbarContentBuilder supports if #available, so item-level options work the same way:

extension ToolbarContent {
    @ToolbarContentBuilder
    func horizontalOnlyOnDuo() -> some ToolbarContent {
        if #available(iOS 27.1, *) {
            self.axisBehavior(.horizontalOnly)
        } else {
            self
        }
    }
}

3. A layout component with a fallback. Feature code asks for “a pair of views”. On iOS 27.1+ that’s an ArrangementView; before that, a plain stack that picks its axis from the available space:

struct AdaptivePair<Primary: View, Secondary: View>: View {
    @ViewBuilder var primary: Primary
    @ViewBuilder var secondary: Secondary

    var body: some View {
        if #available(iOS 27.1, *) {
            ArrangementView {
                primary
            } secondary: {
                secondary
            }
            .arrangementViewStyle(.split)
        } else {
            GeometryReader { proxy in
                let layout = proxy.size.width > proxy.size.height
                    ? AnyLayout(HStackLayout(spacing: 0))
                    : AnyLayout(VStackLayout(spacing: 0))
                layout {
                    primary.frame(maxWidth: .infinity, maxHeight: .infinity)
                    secondary.frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            }
        }
    }
}

// Feature code stays boring:
AdaptivePair { NowPlayingView() } secondary: { UpNextView() }

When you eventually raise your deployment target to 27.1, you delete the else branches in one file and you’re done.

4. Never detect “is this an iPhone Duo?” There’s no API for it on purpose. Branch on the capability you care about: size class, available width, toolbarVerticalEdge != nil, a division region. That way the same code is right on iPad, in iPhone Mirroring, in Split View, and on whatever ships next.

Playbook: a new project, Duo-first

If you’re starting fresh, these defaults get you 90% of the way without touching a Duo-specific API:

  1. Build with Xcode 27.1 or later. The full-display experience requires the iOS 27.1 SDK.
  2. SwiftUI lifecycle, scene-based. Plan for more than one window: UserDefaults/@AppStorage are app-wide, so per-window state belongs in @SceneStorage or your model.
  3. Root = TabView with Tab(_:systemImage:) items: icon plus title, always. Add .tabViewStyle(.sidebarAdaptable) + .defaultTabBarPlacement(.sidebar) only if your app is information-dense and your tabs host NavigationStacks.
  4. Each tab = NavigationStack or NavigationSplitView. Use the split view where list → detail is the core flow (mail, notes, files), and design its empty detail state on day one.
  5. Toolbars live inside navigation containers, use semantic placements (.cancellationAction, .topBarPinnedTrailing, .bottomBar), group related items, and give everything a title and a symbol.
  6. Layout from size classes and container size, never UIScreen, idiom or orientation. Support every orientation; the inner display ignores the list anyway.
  7. Two-pane custom screens → AdaptivePair (an ArrangementView underneath).
  8. Test in four places: iPhone Duo simulator (closed, open, folded, rotated, Split View), an iPad simulator, iPhone Mirroring resized to extremes, and a classic iPhone.
@main
struct DuoReadyApp: App {
    var body: some Scene {
        WindowGroup { RootView() }
    }
}

enum AppTab: Hashable { case inbox, library, settings }

struct RootView: View {
    @State private var tab: AppTab = .inbox

    var body: some View {
        TabView(selection: $tab) {
            Tab("Inbox", systemImage: "tray", value: .inbox) {
                InboxSplitView()                 // list → detail
            }
            Tab("Library", systemImage: "books.vertical", value: .library) {
                NavigationStack { LibraryScreen() }
            }
            Tab("Settings", systemImage: "gear", value: .settings) {
                NavigationStack { SettingsScreen() }
            }
        }
    }
}

Playbook: migrating an existing app

Work through this in order. Each step is independently shippable.

Step 1: Rebuild and look

Build with Xcode 27.1, run the iPhone Duo simulator, and walk every screen, sheet and popover in each pose: closed, open, partially folded, rotated, and in Split View on both sides. Note anything centered awkwardly, controls sitting in the fold, and bars that didn’t go vertical.

Step 2: Grep for the classic assumptions

# Screen-based sizing
rg -n "UIScreen\.main|UIWindow\(frame: UIScreen"
# Idiom / device checks
rg -n "userInterfaceIdiom|UIDevice\.current\.model|\.pad\b"
# Orientation-driven layout
rg -n "UIDevice\.current\.orientation|statusBarOrientation|interfaceOrientation"
# Hard-coded device sizes
rg -n "== ?(812|844|852|874|926|932|956)\b"
# Symmetric safe-area math
rg -n "safeAreaInsets\.(left|right) \* 2"
# Custom bars that won't go vertical
rg -n "UIToolbar\(|UITabBar\(|UINavigationBar\("

Typical replacements:

Old New
UIScreen.main.bounds view.bounds, window.bounds, GeometryReader, onGeometryChange
UIScreen.main.scale traitCollection.displayScale / @Environment(\.displayScale)
UIWindow(frame: UIScreen.main.bounds) UIWindow(windowScene:)
Stored UIScreen view.window?.windowScene?.screen
idiom == .pad → iPad layout horizontalSizeClass == .regular → wide layout
Orientation checks Compare the container’s width and height
width - insets.left * 2 bounds.inset(by: safeAreaInsets).width

Step 3: Audit the bars

Move custom bars into navigation containers, give every item a symbol, assign visibilityPriority to your most-used actions, move a home-grown “more” menu into ToolbarOverflowMenu, and decide per sheet whether a vertical bar makes sense.

Step 4: Fix the expanded states

Every NavigationSplitView needs a designed empty state and a sensible column layout. Every “regular width” branch should look intentional at the inner display’s size. Apple’s design guidance: on the inner display, your app should look like a natural extension of its iPad layout, and show an extra level of hierarchy only where it helps.

Step 5: Only now, the fold

Look at the screenshots from step 1 in the folded pose. If something custom sits in the crease, use ArrangementView for two-pane screens or ReservedRegion for individual controls. If nothing does, you’re done.

Step 6: Ship it

Upload iPhone Duo screenshots in App Store Connect to get the “optimized for iPhone Duo” badge on your product page. Starting April 2027, all app submissions must include iPhone Duo screenshots.

Cheat sheet

Goal SwiftUI UIKit Since
Sidebar tabs on the inner display .defaultTabBarPlacement(.sidebar) sidebar.preferredPlacement = .sidebar iOS 27.0
Turn off the vertical bar .toolbarVerticalBehavior(.disabled) override preferredVerticalBarBehavior iOS 27.1
Keep an item horizontal / prefer vertical .axisBehavior(.horizontalOnly / .verticalPreferred) item.axisBehavior iOS 27.1
Overflow order .visibilityPriority(.high) item.visibilityPriority iOS 27.0
Your own overflow items ToolbarOverflowMenu { … } navigationItem.additionalOverflowItems iOS 27.0 / 16.0
Toolbar vs. tab bar compression .toolbarVerticalCompressionBehavior(_:) navigationItem.verticalBarCompressionBehavior iOS 27.1
Which edge is the bar on? @Environment(\.toolbarVerticalEdge) traitCollection.verticalBarEdge iOS 27.1
Sheet on an edge .presentationPlacement(.leading) sheetPresentationController.preferredPlacement iOS 27.0
Two-pane adaptive layout ArrangementView UIArrangementViewController iOS 27.1
Fold / camera geometry proxy.reservedRegions(kind:options:) view.reservedRegions(kind:options:) iOS 27.1
Hinge angle & status .onHingeChange { old, new in } UIHingeInteraction iOS 27.1
Outer-display camera UI .sceneAccessory { CameraCaptureAccessory { … } } registerSceneAccessory(_:) iOS 27.1 (accessory)

Sources

Apple:

Community:

Found something that behaves differently on real hardware? Let me know and I’ll update the guide.