102 lines
27 KiB
Markdown
102 lines
27 KiB
Markdown
# iPhone Duo: two displays, many poses, and the transitions between them
|
|
|
|
**Status: MANDATORY for every iPhone app and for every UI framework that runs on an iPhone.** iPhone Duo is Apple's foldable iPhone (announced September 2026). It has an outer display (5.4 inch) and an inner display (7.6 inch), a hinge between them, and many ways to hold it: closed, open, partly folded like a book, standing on a table, on its edges. An app on it is resized by the person, at any moment, while it runs: folding or unfolding the device changes the space, the size classes and the place of the controls under the app's feet. The app is built so that a resize is a re-layout of the same app, never a restart of it, and is tested in each pose and across the changes between poses.
|
|
|
|
Each rule carries its basis: **documented** (an Apple page, named with its address, read on 2026-10-28), **observed** (found by running it, with the build), or **design** (a choice, with the reason). None of these pages is in the Cupertino corpus yet. The claims below rest on the pages themselves, read in full, and where a page is silent the rule says so (see "What Apple has not documented"). Availability is stated as the SDK states it (the Swift interface of the iOS 27.1 SDK and the UIKit headers, read on 2026-10-28): the arrangement, reserved-region, hinge and vertical-toolbar APIs are `anyAppleOS 27.1`, and the full-screen experience needs **Xcode 27.1**. Two facts the pages do not carry: **the macOS SDK shipped with Xcode 27.1 is 27.0 and does not contain those symbols**, and **a type listed for a platform is not a promise that every member of it is**: `ToolbarItemAxisBehavior.horizontalOnly` and `.verticalPreferred`, `ToolbarOverflowMenu` and the `prefers...` cases of `ToolbarVerticalCompressionBehavior` are marked unavailable on macOS.
|
|
|
|
Sources (Apple): the Human Interface Guidelines page *Designing for iPhone Duo* (`developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo`); the documentation page *Preparing your app for iPhone Duo* (`developer.apple.com/documentation/technologyoverviews/preparing-your-app-for-iphone-duo`) and its page of three steps (`developer.apple.com/iphone-duo/prepare/`); the documentation of `ArrangementView` and of `GeometryProxy.reservedRegions(kind:options:layoutDirectionBehavior:)`; the Tech Talks *Prepare your app for iPhone Duo* (111461), *Raise the bar with iPhone Duo* (111462), *Strike a pose with adaptive layouts on iPhone Duo* (111463), *Leverage multiple displays and scenes on iPhone Duo* (111464), *Design for iPhone Duo* (111466), and the WWDC26 session *Modernize your UIKit app* (278); the announcement (`apple.com/newsroom/2026/09/apple-unveils-iphone-duo/`).
|
|
|
|
## Why
|
|
|
|
- **The size is no longer the device's.** An app on iPhone Duo is one size on the outer display and another on the inner, and another again in Split View, partly folded, or rotated. Code that reads the size once, or decides the layout from the device, orientation or screen, is wrong the first time the person folds the phone.
|
|
- **A fold is a transition the app lives through.** Apple says that "as iPhone Duo is opened, closed, partially folded, and rotated, content transitions between displays and adjusts for rotated positions", and that "apps resize when the device folds or unfolds". What the person was doing (a selection, a scroll position, a half-typed field, an unsaved edit, the page they were on) is still there on the other side, or the app is broken.
|
|
- **The system does most of it, for an app that lets it.** Standard containers (split view, tab view, navigation stack) and standard bars adapt to every pose, and keep controls away from the fold. A hand-built layout has to do all of it and usually does not.
|
|
- **The part Apple has not written down is the part most likely to bite.** The pages say little about what happens to state, scenes and animation across a fold. An app that relies on a behavior nobody documented has a bug that only shows on the device.
|
|
|
|
## Rules
|
|
|
|
1. **Build with the latest SDK, or the app is letterboxed.** Without a rebuild the app runs but the open display shows the usual iPhone size centered, and the closed display leaves the status bar and camera to its right. With the iOS 27 SDK it fills most of the inner display; with the **iOS 27.1 SDK (Xcode 27.1)** it is edge to edge and the toolbars and tab bars lay out vertically. The page of the documentation says that an app built with Xcode 26 or earlier does not extend under the status bar and camera. *Basis: documented (Preparing your app for iPhone Duo; the three steps; Tech Talk 111461).*
|
|
2. **Read the size on every change, from the view, never from the screen or the launch.** A fold resizes the app in the middle of a session, so a size read at launch or in `viewIsAppearing` is stale. Use `view.bounds` or `window.bounds`; in SwiftUI `GeometryReader` or `onGeometryChange`; lay out in `layoutSubviews` or `viewDidLayoutSubviews`, and use `viewWillTransition(to:with:)` for work that must run on a change of size. In a window-scene delegate, `windowScene(_:didUpdateEffectiveGeometry:)` reports the scene's geometry changing and `windowScene.effectiveGeometry.coordinateSpace.bounds` is the space. Do not cache a size. *Basis: documented (the three steps: "reading size once at launch or in `viewIsAppearing` is not enough"; WWDC26 278).*
|
|
3. **Layout is decided by size classes and the space, not by the device.** Outer display: compact width (portrait regular height, landscape compact height). Inner display: regular width and regular height. A phone app can have any combination of size classes, so `horizontalSizeClass == .regular` does not mean an iPad. Do not use `userInterfaceIdiom`, a model, a pixel size (`bounds.height == 844`), `UIDevice.current.orientation`, `statusBarOrientation`, `interfaceOrientation` or `windowScene.effectiveGeometry.interfaceOrientation` to lay out. Decide wide or tall by comparing the width and the height of the space. The inner display does not honor the orientations an app declares; they are a preference the system may ignore. Do not design a layout for each pose: design two size classes, compact and regular, and let one layout grow into the other. *Basis: documented (HIG: Device poses; the three steps; Tech Talk 111461 and 111466).*
|
|
4. **No `UIScreen.main`.** On a two-display device it is ambiguous and "will be deprecated in a future release". Get a screen from the window scene (`view.window?.windowScene?.screen`), never store one, make a window with `UIWindow(windowScene:)`, and take the scale from `traitCollection.displayScale` (UIKit) or `@Environment(\.displayScale)` (SwiftUI). The scene lifecycle (`UISceneDelegate`) is required when building with the latest SDKs: without it the app no longer launches. *Basis: documented (the three steps; Tech Talk 111461; WWDC26 278).*
|
|
5. **A fold changes the size class, so state does not live in anything the size class replaces.** Selection, scroll position, text, unsaved edits and the page the person is on are held by the model that is above the layout, and the layout is a pure function of the size classes (and the space). A hand-written `if compact { A } else { B }` (or two view controllers swapped by a size-class check) is a different view with a different identity on each side of the fold, and whatever `A` held in its own `@State` is gone when `B` appears. Prefer the system's adaptive containers, which keep one structure across the change: a split view (`NavigationSplitView`, `UISplitViewController`) shows two panes on the inner display and collapses to one on the outer, and tab views and navigation stacks adapt in every pose. Where a swap is unavoidable, the state is written to the model before it and read back after, and a test proves it (How to test it). Keep the information hierarchy the same on both displays and add a level on the inner one if it suits the content (the Mail example of the Human Interface Guidelines: a list or a message when closed, both side by side when open). *Basis: documented for "keep functionality and the state of elements the same between displays", "maintain the same functionality across device poses", the split view behavior, and the Mail example (HIG: Best practices; Split views); design for the rest (what a size-class swap does to a view's own state is a property of SwiftUI and UIKit, not a statement of the page).*
|
|
6. **Move only what has to move when the device folds.** "Avoid extreme layout changes as people fold the device. Move only what's necessary to keep elements visible and easy to tap." Small adjustments, not a different arrangement. Keep controls in the same relative positions in every pose, so that nobody has to learn where actions went. Content that scrolls does not have to avoid the fold; interactive elements do, as far as they can. *Basis: documented (HIG: Reserved regions; Vertical controls; Tech Talk 111466).*
|
|
7. **Reserved regions: ask for them, do not guess them (iOS 27.1).** A reserved region is "an area of your view that an entity reserves, such as the device hardware". There are two kinds. `occlusion`: something covers your content (the Dynamic Island, a camera, window controls): place content outside it. `division`: content splits into separate regions, as at the fold of the hinge: arrange content on both sides. A region has a frame, margins, an active state and an identifier. In SwiftUI call `GeometryProxy.reservedRegions(kind:options:layoutDirectionBehavior:)` on the proxy of a `GeometryReader`; in UIKit call `UIView.reservedRegions(kind:options:)`. By default only the active regions come back; pass `includeInactive` for the others (an inactive fold has zero width, and is still useful for deciding, for instance, on an even number of grid columns). Frames are in the view's coordinate space, measured from the top-leading corner, and are mirrored for right-to-left languages unless `layoutDirectionBehavior` is `fixed`: compute from (0, 0) and add `bounds.origin` only when placing a view. The fold's division region is active only while the device is folded. The inner camera is an occlusion region that is active only while the camera is. Framework views, alerts, menus, sheets and split views already adjust; custom layouts do it themselves, for their highest-priority controls first. The API is **iOS 27.1**: gate it with `#available(iOS 27.1, *)` and lay out without it before. *Basis: documented (GeometryProxy.reservedRegions; Tech Talk 111463; HIG: Reserved regions).*
|
|
8. **An arrangement view for two related views, in its place (iOS 27.1).** `ArrangementView` (SwiftUI) and `UIArrangementViewController` (UIKit) lay out a primary and a secondary view by size class, orientation and reserved regions. **Split**: next to each other when wider than tall, one above the other when taller than wide; closed in landscape it shows only the primary view. **Overlay**: stacked on a flat display, and next to each other on a folded one (secondary leading and primary trailing in landscape; secondary on top and primary below in portrait). `arrangementViewStyle(.split.axes(...))` or `.overlay` chooses, and `overlayArrangementZIndex` (SwiftUI) or `state(for:)` (UIKit) lets a view follow the change between stacked and side by side. A layout already built with `HStack` or `VStack` maps to split, one built with `ZStack` to overlay. An arrangement view is not put inside a scroll view, a list, a grid, a navigation split view or another container that lays out views, and it does not hold a navigation split view or a tab view: it lays out content and has no navigation, so navigation is placed around it. *Basis: documented (ArrangementView documentation; Tech Talk 111463; HIG: Arrangement views).*
|
|
9. **The hinge is for effects, regions are for layout.** `onHingeChange` (SwiftUI) and `UIHingeInteraction` (UIKit) report the status (closed, partly open, fully open) and the angle continuously, and the hinge is optional (`context.hinge` is nil on a device without one). Use them for live interactions and effects; use the arrangement and reserved-region APIs for layout. A state driven by the hinge is reset when the hinge is not in the state that drives it. *Basis: documented (Tech Talk 111464).*
|
|
10. **Bars: use the system's bars, say what each item is, and let the system place it.** Toolbars, tab bars and navigation controls move to the side on the outer display and in landscape on the inner display, and stay horizontal on the inner display in portrait. Only bars the navigation containers provide are considered: a toolbar on a `NavigationStack` or `NavigationSplitView` (SwiftUI), or items on a view controller in a `UINavigationController` or `UITabBarController` (UIKit); a hand-built `UIToolbar`, `UINavigationBar` or `UITabBar` is left horizontal. Every item has a symbol and a title (the symbol is used vertically, the title in the overflow menu); an item with only a title or a custom view stays horizontal. Primary navigation (Back, Close) is first and prominent actions (Done) next: `ToolbarItemPlacement.cancellationAction` and `.topBarPinnedTrailing`, or `leadingItemGroups` and `pinnedTrailingGroup`. `axisBehavior` (27.1; its `.horizontalOnly` and `.verticalPreferred` are iOS only), `visibilityPriority` (iOS 27.0, macOS 26.1), `ToolbarOverflowMenu` (iOS 27.0, not macOS) or `additionalOverflowItems`, and `toolbarVerticalCompressionBehavior` (27.1) decide the rest; a custom view reads `toolbarVerticalEdge` (SwiftUI) or `verticalBarEdge` (UIKit) to know that its bar is vertical, and fits a fixed width. In general the default placement is not overridden (`toolbarVerticalBehavior(.disabled)` or `preferredVerticalBarBehavior` is for a single-page layout that needs the width or a sheet with one button), and no spacing is added by hand. In a split view only the detail column's bars go vertical; on the outer display a sheet's bar is vertical, and on the inner a centered sheet's stays horizontal. *Basis: documented (Preparing your app; Tech Talk 111462; HIG: Vertical controls).*
|
|
11. **Safe areas are not symmetric.** Controls sit on one edge, and in Split View each app's controls are on its outer edge, so the left inset and the right inset differ. Never compute `bounds.width - insets.left * 2`; use `bounds.inset(by: safeAreaInsets)`. Foreground content that is interactive stays inside the safe area; a full-bleed background may extend past it. Layout margins are asymmetric too. Use `ConcentricRectangle` (SwiftUI) or `UICornerConfiguration` (UIKit) to match the corners of the screen. *Basis: documented (Tech Talk 111461; HIG: Dynamic layouts).*
|
|
12. **Scenes: a second window is a request that can fail.** iPhone Duo is the first iPhone to support more than one scene of an app. New windows cannot be created from the outer display, only the inner, so a request for one is handled as something that can fail, and `UIWindowSceneActivationAction` hides itself when it is not available. Scene accessories (content on another display) are enabled by default, can be toggled, and their availability changes at run time: observe it (`onAvailabilityChange`) and disable what depends on it. The camera capture accessory (`CameraCaptureAccessory`, registered with `sceneAccessory` on the camera's view) puts content on the outer display while the app is full screen on the inner display with an active camera session. *Basis: documented (Tech Talk 111464).*
|
|
13. **A camera app follows the camera by the direction it faces.** When the device is opened, closed or rotated, the display and the camera in use may change and a camera may face the opposite way. Choose a camera by the direction it faces, not by its place, and handle the change. *Basis: documented (Preparing your app: Improve your app's camera handling; Choosing a camera by the direction it faces).*
|
|
14. **Games fill the screen in every pose.** A game may lock to portrait or landscape (`UIRequiresFullScreen` is honored on iPhone in resizable environments from iOS 27) and still fills the screen as the pose changes; it keeps the text and control sizes as steady as it can, prefers changing the aspect ratio to letterboxing, and puts artwork in the padding where letterboxing cannot be avoided. *Basis: documented (HIG: Best practices; WWDC26 278).*
|
|
15. **Test every pose, and the changes between them.** Use the iPhone Duo simulator in Device Hub (Xcode 27.1), whose controls open, close, rotate and fold the device; the resizable iOS simulator in Device Hub and iPhone Mirroring on macOS 27 for the extremes of size in both directions; and the App Resizability skill of Xcode 27.1 (its output is reviewed: Apple says it misses some issues). Then do what the pages do not: a UI test that changes the pose while the app is in a state (a selection, a scrolled list, a typed field, an unsaved edit, a sheet open, a page chosen) and requires that the state is the same after the change and back, that no control is lost, and that every control is still reachable; run in each pose and with the largest text size (`accessibility.md`). *Basis: documented for the tools (Preparing your app; Tech Talk 111461); design for the transition test.*
|
|
16. **The newer APIs are reached through one layer that detects the SDK and the OS, and gives each a fallback.** A call site does not carry an `if #available`: it calls a forward (`view.forwards.glassEffect(...)`, `proxy.forwards.reservedRegions(kind:)`), which holds the availability check, the fallback and the mapping in one tested function, and is removed from a call site when the deployment target reaches the OS. Two checks are needed and are not the same: whether the **SDK** being built against has the symbols (`#if canImport(SwiftUICore, _version: ...)`, because a macOS SDK that lacks them fails to compile an `#available`-guarded call) and whether the **OS** being run on has them (`#available`). The threshold is kept honest by a script that, for each installed SDK, checks that the symbols are present exactly when the version is at least the threshold. A fallback is the closest honest behavior (an empty list of regions, the flat-display layout of an arrangement, a bordered button for a glass one), never a pretence of the new API. The shared implementation is `Forwards` (`codeberg.org/Mihaela/Forwards`): `ForwardsCore` (pure values and the arrangement layout), `ForwardsSwiftUI` (iOS and macOS), `ForwardsUIKit` (iOS); the cross-framework AppKit and UIKit layer (`ui/appuikit.md`) delegates to it and answers with nothing on AppKit. Mac Catalyst is not a target. *Basis: observed (the macOS 27.0 SDK without the symbols, the module versions of each SDK, the members marked unavailable on macOS; the fallbacks tested on macOS, on an iOS 27.0 simulator and on the iPhone Duo simulator of iOS 27.1); design for the layering.*
|
|
17. **Do not rely on what Apple has not documented.** See the next section. An app that needs one of those behaviors observes it on the simulator and on a device, records it as observed with the OS build, and does not state it as a rule until Apple does. *Basis: design.*
|
|
|
|
## What Apple has not documented
|
|
|
|
On 2026-10-28, in the pages named above, read in full:
|
|
|
|
- **The transition itself.** The pages say content "transitions between displays" and that the app is resized, and describe the system's own effects (the wallpaper reacts to the hinge angle and zooms as the device opens; sheets, alerts, menus and toolbar buttons move away from the fold; a picture-in-picture video pins to the top and the app resizes with it). They give no duration, no curve, no intermediate frames, and no way to take part in the animation other than the layout APIs.
|
|
- **State across a fold.** No page says whether views, view controllers, scenes or `@State` survive, are recreated or are suspended when the device folds or unfolds, or what the person's state should be written to. The Human Interface Guidelines say to keep "the state of elements the same between displays"; they do not say how the system helps.
|
|
- **Scene lifecycle on a fold.** The pages name scene geometry updates and the scene lifecycle requirement, but not what a fold does to a scene (a new scene, the same scene, a new screen for it), nor `windowScene.screen` changes.
|
|
- **Detecting a pose change.** There is no named event for "the device was folded" other than a change of size class and trait, a change of scene geometry, a change of a reserved region, and the hinge API.
|
|
- **Sizes.** No page gives the points, the pixels or the scale of either display, or the exact aspect ratio (only "both displays share the same aspect ratio"), or the safe-area values.
|
|
- **State restoration**, **Split View on an iPhone versus an iPad**, and **the keys to opt in to multiple scenes** are not described.
|
|
|
|
These are the questions the transition test of rule 15 exists to answer, for the app, on the build in hand.
|
|
|
|
## What was observed on the simulator
|
|
|
|
On 2026-10-28, on the iPhone Duo simulator (iOS 27.1, Xcode 27.1), with a real app (a YAML and JSON editor with an outline, a source view and an inspector), poses driven from Device Hub (closed, open, half-folded, closed again, open again). These are observations of that build, not Apple's documentation (rule 17):
|
|
|
|
- **A vertical toolbar and a tab bar overlapped.** On the closed (outer) display, which is compact width, the system put a navigation toolbar down the side as a vertical bar; a `TabView` inside the same `NavigationStack` also drew its tab bar vertically; the two sat on the same coordinates, so the tab buttons were covered by the toolbar buttons. Setting `toolbarVerticalBehavior(.disabled)` on that screen (through the layer of rule 16) gave a horizontal tab bar at the bottom and a horizontal toolbar at the top with a More overflow. Found by dumping the accessibility tree, which gave the frames; a screenshot of a closed device does not show it.
|
|
- **A fold rebuilt the navigation and lost the screen.** Folding open to closed made the app swap its columns for pages, and the pages (a `NavigationStack`) were made again with an empty path: the person landed on the list of files with the document still open in the model. The stack now starts on the document when one is open. State above the swap (the open document, the search text, the chosen node) survived every change of pose in the run; state held in the swapped views (the chosen tab) did not.
|
|
- **Tools.** The pose can be set only from Device Hub, by clicking its pose buttons; `simctl`, XCUITest and AppleScript cannot. `simctl io screenshot` and the screenshot of a UI test are black unless the device is closed, in any other pose; a capture of the Device Hub window works in every pose. The first tap on a tab right after a pose change did not always switch the page; the test taps again and records how many taps it took.
|
|
|
|
## How to test it
|
|
|
|
- **Pure functions first.** The decision between arrangements (pages or columns, one pane or two) is a function of the size classes and the space, not of the device; test it over every combination of the two size classes and over widths from the narrowest to the widest, with equal results for equal inputs, and with no state in the function.
|
|
- **The state across the change.** In the simulator, put the app in a state, change the pose with the Device Hub controls, and read the state back: the selected row, the scroll offset, the text of a field and its caret, an unsaved edit, the open sheet, the chosen tab. Do it closed to open and open to closed, open to partly folded and back, rotated and back, and into and out of Split View. Record which of them survive on the build in hand (rule 17).
|
|
- **Every control, every pose.** Walk the accessibility tree in each pose and require that each control exists, is hittable, is not under the fold, and keeps its label (`accessibility.md`), and that the audit passes in each.
|
|
- **The sources of wrong size.** The acceptance check below finds the screen, idiom, orientation and symmetric-inset habits mechanically.
|
|
- **Availability.** Build for the oldest deployment target the app supports and require that the 27.1 APIs are behind `#available`.
|
|
- **The text size.** Run each pose at the largest accessibility text size, where the arrangement changes (`accessibility.md`, rule 14).
|
|
|
|
## Acceptance check
|
|
|
|
Run from the repository root. `SOURCES` are the Swift sources of the app and its UI packages. Every command must print nothing, except where it says otherwise:
|
|
|
|
```sh
|
|
# 1. no main screen
|
|
grep -rnE 'UIScreen\.main' "$SOURCES" --include='*.swift'
|
|
|
|
# 2. no layout by idiom or by a device size
|
|
grep -rnE 'userInterfaceIdiom|UIDevice\.current\.userInterfaceIdiom|bounds\.(width|height) *== *[0-9]' "$SOURCES" --include='*.swift'
|
|
|
|
# 3. no layout by orientation
|
|
grep -rnE 'UIDevice\.current\.orientation|statusBarOrientation|\.interfaceOrientation|isLandscape|isPortrait' "$SOURCES" --include='*.swift'
|
|
|
|
# 4. no window made from the main screen
|
|
grep -rnE 'UIWindow\(frame:' "$SOURCES" --include='*.swift'
|
|
|
|
# 5. no symmetric inset arithmetic
|
|
grep -rnE 'safeAreaInsets\.(left|right|leading|trailing) *\* *2' "$SOURCES" --include='*.swift'
|
|
|
|
# 6. a regular width is not taken for an iPad
|
|
grep -rnE 'horizontalSizeClass *== *\.regular.*(iPad|pad)|(iPad|pad).*horizontalSizeClass *== *\.regular' "$SOURCES" --include='*.swift'
|
|
|
|
# 7. a hand-built bar is not a bar the system places
|
|
grep -rnE '\bUIToolbar\(|\bUINavigationBar\(|\bUITabBar\(' "$SOURCES" --include='*.swift'
|
|
|
|
# 8. the 27.1 APIs are used only in a file that gates them (this must print nothing)
|
|
for f in $(grep -rlE 'ArrangementView|UIArrangementViewController|reservedRegions|onHingeChange|UIHingeInteraction' "$SOURCES" --include='*.swift'); do grep -q 'iOS 27\.1' "$f" || echo "$f: not gated"; done
|
|
|
|
# 9. the scene lifecycle is adopted (this must print the delegate)
|
|
grep -rnE 'UIWindowSceneDelegate|UISceneDelegate|WindowGroup|@main' "$SOURCES" --include='*.swift' | head -3
|
|
```
|
|
|
|
Promote commands 1 to 8 to a build-failing script with a self-test (a fixture that breaks each must fail it; a fixture that obeys all must pass it), and the tests above to the project's test suite. The check passes when the commands print as stated, the tests pass in each pose, and the transition test has been run on the simulator and its result recorded as observed with the build. Until that run, say "built with the 27.1 SDK, laid out by size classes, not yet tested across a fold", never "supports iPhone Duo". Rule 17 stands: the section "What Apple has not documented" is the open frontier, and each item leaves it only when Apple documents it or the app has observed it.
|
|
|
|
Cross-references: `accessibility.md` (the text size and the audit in each pose; the arrangement at accessibility sizes), `ui/swiftui-views.md` and `ui/uikit-views.md` (the views the rules apply to), `ui/three-renderers.md` (the same layout decision on every surface), `cross-platform.md` (the platform conditions around 27.1 APIs), `testing.md` and `core/testing-discipline.md` (the tests), `core/oracle-first.md` (the decision function is the answer key the surfaces are held to), `core/proof-discipline.md` (documented, observed and not established are kept apart), `core/no-shortcuts-first-principles.md` (a behavior that is undocumented is observed, not assumed).
|