Field notes · iOS

From Android foldables to iPhone Duo

Notes from mapping a Pixel Fold app onto Apple's first folding iPhone, written five weeks before the SDK. Most of it carries over. The posture state machine doesn't.

Apple published the Human Interface Guidelines for iPhone Duo on 9 September, together with six tech talks, five weeks before the device ships. I have an Android app built around the Pixel Fold's tabletop posture and wanted to know what a port would take. This is the map I made: the Android vocabulary (Jetpack WindowManager, postures, window size classes, insets) against the iOS one (size classes, reserved regions, arrangements, vertical bars).

Status: the SDK does not exist yet

iPhone Duo and its HIG page are real. This guide targets the iOS 27.1 SDK, which Apple has announced for Xcode 27.1 beta "later this month" and which ships with the device on 23 October 2026. As of 12 September there is no beta to download, no simulator, and no reference documentation for the new APIs. Nothing in this guide can be compiled today. The code blocks are pseudocode in Swift syntax, written against the names Apple used in its tech talks: a specification to check against the beta, not source to paste. Every API and number carries a provenance mark; unmarked names are existing, shipping APIs.

AApple reference documentation existsstable
Tnamed in an Apple tech talk; no reference page yetname likely, signature may change
Isyntax inferred by analogy from a talkcheck against Xcode 27.1
3third-party source (blogs, press)unofficial

What you can do now, without the SDK: sections 2, 4, 6 and 10 are design and architecture work — the size-class subtrees, hoisting state, replacing custom bars with system toolbars, the adapter pattern — and need nothing that is not in Xcode 27 today. Sections 1, 3 and 5 are the parts to revisit the day the beta drops.

1. The device and the API map

iPhone Duo is a book-style foldable like the Pixel Fold: a phone-sized outer display when closed, a wide inner display with a centre hinge when open. Held open like a book the hinge is vertical; rotated by 90° and half-folded, the hinge is horizontal and the device stands like a laptop. Every posture you support on Android exists on iPhone Duo. The outer display is wider and shorter than other iPhones, which is why the system bars sit on its side (section 6).

Closed · portrait 466 × 678 pt compact · regular Closed · landscape 678 × 466 pt compact · compact Open · hinge vertical 951 × 669 pt · book pose regular · regular Open · hinge horizontal 669 × 951 pt · tabletop regular · regular system bar region outer camera, same corner in both poses fold (division region) width · height size class
Where the system bars sit in each pose (bar placement A, point sizes 3). Status bar, Dynamic Island, navigation bar, toolbar and tab bar share one vertical region on the camera edge, except on the inner display in portrait, where bars are horizontal. The inner display is regular in both size classes in every orientation.
ConceptAndroid (Pixel 9 Pro Fold)iPhone DuoSrcNotes
Outer display6.3″, about 412 × 924 dp5.4″, 466 × 678 pt @3x (1398 × 2034 px), aspect 1.453
Inner display8″, about 2076 × 2152 px, nearly square7.6″, 669 × 951 pt @3x (1878 × 2670 px), aspect 1.42 ≈ √23
FoldFoldingFeature (state, orientation, bounds, occlusionType) from WindowInfoTracker.windowLayoutInfoA reserved region of kind division: GeometryProxy.reservedRegion(kind: .division) / UIView.reservedRegion; types ReservedRegion / UIViewReservedRegionTActive only while partially folded, zero width when flat; .includeInactive to see it flatT; frame in view coordinates (pt), no density conversion; the kind: label and proxy.frame(of:) conversionI
Fold statestate == HALF_OPENED / FLATRegion active / inactiveTNo FLAT-with-bounds state on iOS
Fold orientationorientation HORIZONTAL / VERTICALShape of the region frame: wider than tall means a horizontal hinge (tabletop)I
Hinge angleSensor.TYPE_HINGE_ANGLE.onHingeChange { previous, context in … } / UIHingeInteractionTStatus closed, partially open, fully open, plus the continuous angle; context.hinge is nil without a hinge; for effects and interactions, not layout
CamerasPunch-hole on both displays; DisplayFeature occlusionOuter camera always visible in a corner, in line with the side bar, grows into the Dynamic Island; inner camera under the display, an occlusion region only while activeA
Size classesWindowSizeClass (compact < 600 dp, medium < 840 dp, expanded), optional; BoxWithConstraints@Environment(\.horizontalSizeClass), verticalSizeClass / UITraitCollection; mandatory: outer display compact width (regular height in portrait, compact in landscape), inner display regular × regular in every orientationTDetermine layout from size classes, never from idiom or orientation
OrientationscreenOrientation, LocalConfiguration.orientation, honoured on both displaysThe inner display does not honour supported interface orientations; the outer behaves as any iPhoneTUse the window's aspect ratio if a decision is needed
System chromeStatus bar top, gesture nav bottom, on both displaysOuter display and inner display in landscape: status bar, Dynamic Island, toolbars and tab bars all on one side (the camera edge); inner display in portrait: horizontal barsA
Two panesListDetailPaneScaffold, SlidingPaneLayout, two-pane RowNavigationSplitView / UISplitViewControllerACollapses to one column at compact width, expands on the inner display, adapts columns to the fold on its own
BarsNavigationBar / bottom navigation, TopAppBarTabView, NavigationStack + .toolbar / UITabBarController, UINavigationControllerThese get the vertical barT; a hand-built UIToolbar or custom bar does not
Custom two panes at the hingeRow / Column with a hinge SpacerArrangementView + .arrangementViewStyle(.split) / UIArrangementViewController + UISplitArrangementTSplits horizontally when wider than tall, vertically when taller, at the fold when folded; an axis restriction exists (axes)T, the chained .split.axes(.vertical) formI
Floating panelBox with a panel over contentArrangementView, overlay styleTStacked when flat, side by side when folded; @Environment(\.overlayArrangementZIndex)T
InsetsenableEdgeToEdge, WindowInsets.safeDrawingsafeAreaInsets per edge, .ignoresSafeArea() on backgrounds onlyAAsymmetric on iPhone Duo; never mirror one side
Saved UI staterememberSaveable@SceneStoragePer scene
MultitaskingSplit screen, activity embedding, opt-inEvery app takes part in Split View; two apps side by side on the inner display, plus a video-over-app stacking layoutTUIRequiresFullScreen no longer prevents resizing3
Multiple instanceslaunchMode, resizeableActivityMultiple scenes via UIApplicationSupportsMultipleScenes (first iPhone); new windows only on the inner displayTHandle UIWindowScene.ActivationAction errorsT
Rear-display useRear display mode for the cameraScene accessories (.sceneAccessory, .onAvailabilityChange); CameraCaptureAccessory for camera appsTAvailability is controlled by the system
Toolbar overflow, prioritiesOverflow ⋮ menuToolbarItemVisibilityPriority, ToolbarOverflowMenu / UIBarButtonItemVisibilityPriority, additionalOverflowItemsAThe HIG links to these reference pages
Custom toolbar viewsCustom action viewstoolbarVerticalEdge environment value, AxisBehavior, toolbarVerticalBehaviorT
Screen lookupsResources.displayMetricsDo not use UIScreen.main: ambiguous with two displays, to be deprecatedTEnvironment, trait collection or scene bounds
BiometricsFingerprint on the power keyTouch ID rather than Face ID3Branch UI strings on LAContext.biometryType
SimulatorAVD, adb shell cmd device_state state 0|1|2, adb emu rotateXcode 27.1 Device Hub: open, close, rotate, foldT
Window sizes in points, before insets (sizes 3, bars A)
PoseWindowBarsEach leaf when folded
Closed, portrait466 × 678vertical, on the camera edge
Closed, landscape678 × 466vertical
Open, hinge horizontal669 × 951horizontal (top / bottom)669 wide × about 475 tall
Open, hinge vertical951 × 669verticalabout 475 wide × 669 tall

2. The mental model changes

On Android, foldable support is usually posture-driven: observe WindowLayoutInfo, derive tabletop, book or flat, and pick a layout per posture. Apple asks for the opposite order:

  1. Size classes first. Two layouts, compact width for the outer display and regular width for the inner, cover every pose. "Don't reinvent your app when it resizes; allow the existing layout to expand."
  2. Reserved regions second. The fold and the cameras are areas your layout adapts around, like the window controls on iPad. System containers do this on their own; custom layouts query the regions.
  3. Displacement, not rearrangement. "Avoid extreme layout changes as people fold the device… favor small adjustments over rearrangement." Move a button, widen a gap, split a pane at the fold. Don't swap in a different screen. This is an architectural requirement, not a styling one; section 4 shows why.
  4. Same functions in every pose. Controls may overflow and content may move, but nothing may exist in one pose only.
  5. Bars are vertical. The single biggest visible difference. Standard bars move to the side for free; custom bars have to follow or justify why not.

What stays the same: the postures, the "content on the standing leaf, controls on the flat leaf" tabletop idea, the need to keep interactive elements away from the hinge, and the rule that continuous scrolling content such as articles and feeds may run across the fold.

3. Deriving posture, if you still need it

Most apps don't: size classes plus system containers cover them. Apps with a bespoke pose layout (games, media, creative tools with a tabletop mode) can derive the familiar Android postures from two inputs:

Pseudocode against tech-talk names · will not build until Xcode 27.1 beta ships

enum Posture: Equatable {
    case compact                 // outer display, or a narrow Split View slot
    case open                    // inner display, flat
    case tabletop(CGRect)        // inner display, horizontal fold; the division region's frame
    case book(CGRect)            // inner display, vertical fold
}

struct PostureReader<Content: View>: View {
    @Environment(\.horizontalSizeClass) private var width
    @ViewBuilder var content: (Posture) -> Content

    var body: some View {
        FoldReader { fold in                        // section 10: nil when flat, closed, or unsupported
            let posture: Posture = switch (width, fold) {
                case (.regular, let r?) where r.width > r.height: .tabletop(r)
                case (.regular, let r?):                            .book(r)
                case (.regular, nil):                               .open
                default:                                            .compact
            }
            content(posture)
        }
    }
}

Differences from FoldingFeature to remember:

4. Architecture: one tree, posture as numbers

This is the part of the transition that is underestimated. "Displacement instead of rearrangement" reads like a layout preference; it is a constraint on how the view layer is structured.

The Android shape

A foldable Android app almost always has a posture state machine with a screen per state:

when (posture) {                                   // Android, typical
    is Posture.Tabletop -> TabletopScreen(posture.hinge)
    is Posture.Book     -> BookScreen(posture.hinge)
    Posture.Flat        -> FlatScreen()
}

Ported literally:

Pseudocode · the shape to avoid

switch posture {                                   // same shape in SwiftUI: wrong result
case .tabletop(let fold): TabletopScreen(fold: fold)
case .book(let fold):     BookScreen(fold: fold)
default:                  FlatScreen()
}

Each case is a different view identity. On every fold SwiftUI tears one subtree down and builds another: @State inside it resets, scroll positions jump, focus is lost, and the only animation possible is a crossfade between two screens, which is precisely the "extreme layout change" the HIG forbids. The architecture produces the violation before any design decision is made.

The shape the HIG asks for

One tree per size class, chosen once at the top. Below that branch, the tree has the same identity in every pose, and the posture changes only numbers: where a split sits, how wide a gap is, which alignment a control uses.

Pseudocode · builds today except for FoldReader, which needs the 27.1 beta

struct RootScreen: View {
    @Environment(\.horizontalSizeClass) private var width
    var body: some View {
        if width == .compact { CompactScreen() }     // outer display and Split View slots: one identity
        else { RegularScreen() }                     // inner display, flat or folded, either hinge: one identity
    }
}

struct RegularScreen: View {
    @Environment(\.model) private var model          // state lives above the layout
    var body: some View {
        FoldReader { fold in                         // CGRect? — nil when flat
            GeometryReader { g in
                let horizontalHinge = (fold?.width ?? 0) > (fold?.height ?? 0)
                let layout = horizontalHinge
                    ? AnyLayout(VStackLayout(spacing: fold?.height ?? 0))
                    : AnyLayout(HStackLayout(spacing: fold?.width ?? 0))
                layout {                             // same children, same identity, animated frame changes
                    PrimaryPane(model)
                        .frame(height: horizontalHinge ? (fold?.minY ?? g.size.height / 2) : nil)
                    SecondaryPane(model)
                }
                .animation(.snappy, value: fold)
            }
        }
    }
}

AnyLayout swaps the arrangement of the same children without changing their identity; ArrangementViewT does the same with system rules and system animation. Either way the children are stable.

Rules

  1. Branch on size class once, at the top. Two subtrees, compact and regular. Nothing else selects a view.
  2. Posture is data, never a branch. It feeds split positions, gaps, spacing and alignment into the regular subtree. If you find yourself writing if posture == .tabletop { SomeOtherView() }, stop.
  3. Hoist state above the layout. Models, selection, scroll position (@SceneStorage), playback, text: all above the pane views, so panes can move, resize and re-parent without losing anything.
  4. Elements that travel keep identity. A floating control that moves from a column to the centre, a panel that goes from overlay to side-by-side: same view, animated frame, or matchedGeometryEffect. Never one view disappearing while another appears.
  5. Modes are not postures. A real state machine is fine for modes (edit / preview, learn / play). Modes may change what is shown; postures may not.

What this costs

Be honest in the estimate. Kotlin and Compose to Swift and SwiftUI is a rewrite of the view layer regardless of foldables; models, parsing, networking, engines and native cores port mechanically. So the cost of this section is not a migration of the Android view layer, since that layer is not coming along, but designing the SwiftUI tree correctly before writing it, and deleting the posture state machine rather than porting it. Plan the view layer from scratch around the two size-class subtrees, budget the models as a mechanical port, and treat "port the posture switch" as the one thing on the list that must not happen.

5. Layout patterns per pose

Closed (outer display, compact width)

Same as your phone layout, with two twists: the bar is on the side and vertical space is precious; and the outer camera is always in the corner, aligned with that bar. Standard bars, safe areas and layout margins handle both. Content that scrolls stays inset; immersive, non-scrolling content (a calculator, a player, a game) may span the full width as long as it avoids the Dynamic Island and status bar.

Open, flat (inner display, regular × regular)

Show one more level of hierarchy: list and detail, document and inspector, player and queue. Mail shows list or message when closed and both when open. NavigationSplitView does this; a custom two-pane layout translates to a split ArrangementViewT. Prefer even column counts in grids. The inner display is regular in both dimensions in both orientations, so a landscape-only or portrait-only assumption produces a wrong layout in one of them.

Half-folded, hinge vertical (book pose)

The fold divides the inner display into a left and a right region. Split views keep both columns visible with an even split; alerts move to the trailing side, closer to where they will be when the device closes; grids keep their outer margins and widen the gutter over the hinge. Interactive elements stay off the fold; scrolling text may cross it.

Half-folded, hinge horizontal (tabletop / laptop pose)

The top region suits content viewed at a distance while the bottom suits interactive controls.

A vertical split arrangement puts the primary view on the standing leaf and the secondary on the flat leaf, splitting at the fold; when flat, the same arrangement splits at the middle, so folding only moves the divider. That is the pattern for video and controls, camera preview and shutter, game screen and gamepad, document and keyboard.

The overlay case

A floating panel over content (mini player, reader controls, a picture-in-picture-like preview) is the overlay arrangementT: primary atop secondary when flat, side by side when folded. Use overlayArrangementZIndexT to switch the panel between its compact floating form and its full-leaf form.

Rules for all poses

6. Bars: from top/bottom to the side

On the outer display and on the inner display in landscape, the status bar, Dynamic Island, navigation bar, toolbar and tab bar share one vertical region on the camera edge: rotate the usual bars by 90° and stack them. It stays on that side in right-to-left languages because it is aligned with the hardware. Only the inner display in portrait keeps horizontal bars. In Split View each app puts its bar on its own outer edge.A

You get this by building against the iOS 27.1 SDK and using bars owned by navigation containers: NavigationStack or NavigationSplitView with .toolbar, and TabView; in UIKit UINavigationController and UITabBarController. Standalone UIToolbar, UINavigationBar, UITabBar and hand-built bottom bars are not moved.T

Translating an Android bar
AndroidiPhone Duo
Top app bar: navigation icon, title, actionsTop of the vertical region: Back or Close first, then prominent actions (Done); the title stays with the content
Bottom navigation (3 to 5 destinations)TabView; tabs go into the same vertical region
Bottom app bar / FABToolbar items; the FAB becomes a prominent toolbar item with a high visibility priority
Overflow ⋮ menuThe system overflow menu (ToolbarOverflowMenu / additionalOverflowItemsA); reserve the ellipsis for it
Text-only actions ("Save")Prefer a symbol with a title; text-only items stay in a horizontal bar and cost vertical space
Custom action viewsMust fit the bar's fixed width or provide a vertical form; read toolbarVerticalEdgeT; declare AxisBehaviorT

Housekeeping the HIG asks for:

7. Safe areas, cameras, asymmetry

8. Games and full-screen media

9. Multitasking, scenes, and the other display

10. Deployment target and fallbacks

One binary has to serve iPhones on your minimum iOS, current iPhones on iOS 27.1, and iPhone Duo. Three facts make this simpler than it looks; one pattern keeps it out of the application code.

Facts

  1. Checks are on the OS version, never on the device. iPhone Duo ships with iOS 27.1, and every iPhone on 27.1 has the same APIs. There is no "is this a Duo" test and none is needed.
  2. The new APIs are no-ops without a fold. Reserved regions return nothing, the hinge context is nilT, and an arrangement view with no active division region behaves like the stack it replaces. One code path covers a flat Duo, an iPhone 17 and an iPad.
  3. Building with the 27.1 SDK does not raise the deployment target. Choose the target from your user base as usual (iOS 26, say); gate the 27.1 APIs at runtime with #available. #if is compile-time and only matters while part of your CI still builds with an older Xcode. Once the team is on Xcode 27.1, there is no #if at all.

The pattern

Every availability check lives inside a small adapter view or modifier. Application code calls the adapter and never sees #available.

Pseudocode against tech-talk names · will not build until Xcode 27.1 beta ships

// 1. The fold. nil on flat, closed, older iOS and non-folding devices alike.
//    Callers cannot tell the difference, which is the point.
struct FoldReader<Content: View>: View {
    @ViewBuilder var content: (CGRect?) -> Content
    var body: some View {
        GeometryReader { proxy in
            if #available(iOS 27.1, *) {
                content(proxy.reservedRegion(kind: .division).map { proxy.frame(of: $0) })   // [T] name, [I] labels
            } else {
                content(nil)
            }
        }
    }
}

// 2. Two panes. The system arrangement where it exists, a plain stack everywhere else.
struct AdaptiveSplit<Primary: View, Secondary: View>: View {
    @ViewBuilder var primary: () -> Primary
    @ViewBuilder var secondary: () -> Secondary
    var body: some View {
        if #available(iOS 27.1, *) {
            ArrangementView(primary: primary, secondary: secondary)       // [T]
                .arrangementViewStyle(.split)                             // [T]
        } else {
            ViewThatFits {
                HStack(spacing: 0) { primary(); secondary() }
                VStack(spacing: 0) { primary(); secondary() }
            }
        }
    }
}

// 3. Toolbar overflow priority. Applied unconditionally by toolbar code; a no-op before 27.1.
extension ToolbarContent {
    @ToolbarContentBuilder
    func overflowPriority(high: Bool) -> some ToolbarContent {
        if #available(iOS 27.1, *) {
            self.visibilityPriority(high ? .high : .low)                  // [A] type, [I] modifier spelling
        } else {
            self
        }
    }
}

The hinge (onHingeChange) and scene accessories take the same treatment: one modifier each, availability inside, an empty implementation below 27.1.

Test matrix

Every row must pass before the app is called ready.

ConfigurationWhat must hold
iPhone on your minimum iOSEvery adapter takes the fallback branch; the layout is what ships today
Current iPhone on iOS 27.1APIs present but return nothing; result identical to the row above. This proves the no-op path
iPhone Duo closed, portrait and landscapeCompact subtree, vertical bar, camera-edge inset
iPhone Duo open flat, both orientationsRegular subtree, inactive fold, split at the middle
iPhone Duo half-folded, both hinge directionsActive fold; only positions and gaps change, no view is replaced
iPhone Duo Split View, left and right slotCompact subtree, bar on the app's outer edge

The legacy risk to watch is not the new APIs but the old opt-outs: apps that relied on UIRequiresFullScreen or a portrait lock to avoid ever being resized will be resized on the inner display regardlessT3. Resizing is the prerequisite; the adapters above are the extras.

11. Build, ship, and the refactoring list

Built withWhat you get on iPhone Duo T
Older SDKThe app runs in a familiar iPhone-shaped frame on the inner display
iOS 27 SDKContent extends to the left of the status bar on the inner display
iOS 27.1 SDKEdge to edge, vertical bars, arrangements, reserved regions, hinge API

Refactoring: don't, why, do

One row per Android habit that breaks on iPhone Duo; the "do" column is the acceptance criterion, to be met in every configuration of the test matrix above.

Don'tWhy it failsDo
One screen per posture, chosen from FoldingFeatureRearranges the screen on every fold and destroys view identity (section 4)Two size-class subtrees; posture feeds numbers only
Decide by orientation, idiom or screen size (displayMetrics, UIScreen.main)The inner display ignores orientation and is regular × regular in every pose; two displays make the screen ambiguousSize classes; environment, trait collection or scene bounds
Treat FLAT bounds as a divider positionNo such state on iOS; the region is inactive when flatSplit at the middle when no region is active; .includeInactive only for coarse decisions
Symmetric insets (padding(horizontal = …))Bar and camera on one edge only; a Split View neighbour on the otherPer-edge safe-area insets; backgrounds under the bars, interactive content inside
Custom bottom bar, FAB, own overflow menuThe system bar region is vertical; a custom bar is not moved and covers contentNavigation containers own the bars; every item has a symbol and a title; priorities assigned; one system overflow menu
Popover cards positioned by handMay land on the fold or under the barSystem sheets, popovers, alerts and menus
Interactive elements over the hinge; odd grid columnsThe fold divides the inner displayNothing interactive on the fold; scrolling content may cross it; even column counts
Crossfade between screens on foldThe "extreme layout change" the HIG forbidsFolding moves elements; NavigationSplitView or a split arrangement for two panes, an overlay arrangement for floating panels
Hinge angle sensor for layoutApple reserves it for interactions and effectsReserved regions and arrangements
Assume one window; rely on UIRequiresFullScreen or a portrait lockSplit View and multiple scenes; the inner display resizes the app regardlessSupport resizing; decide on multi-scene explicitly; consider scene accessories if the outer display can add value
#available scattered through viewsUnreadable, untestable, each site a fallback bugAdapters (section 10); fallback tested on the minimum iOS and on a current iPhone
Ship on tech-talk namesT and I names may change before GARe-check every marked name against the shipping Xcode 27.1 SDK; build with it; screenshot every pose in Device Hub

Sources

Apple HIG: Designing for iPhone Duo · Layout. Tech talks: Prepare your app for iPhone Duo · Raise the bar with iPhone Duo · Strike a pose with adaptive layouts on iPhone Duo · Leverage multiple displays and scenes on iPhone Duo · Build a great camera experience for iPhone Duo · Design for iPhone Duo. Third-party numbers 3: iPhone Duo for Developers: The 1.42 Problem, iPhone Duo: First Developer Good-to-Knows.

If you are porting one too

I'd like to compare notes once the beta is out, especially on whether the split arrangement really lands on the fold and what the reserved region API is actually called. Easiest way: a message on LinkedIn, or the contact on rogerboesch.com.