Adapting an app for iPhone Duo
iPhone Duo is Apple's folding iPhone: a compact outer display, a large inner display, a hinge between the halves, and a front camera on each side. Three consequences break real apps:
- The screen got wide and short. A layout that centres a phone-width column — or stretches one — ends up with dead paper or 600-point-wide controls.
- The bars moved to the side. Status bar, Dynamic Island, toolbar and tab bar share one vertical bar on the outer display and on the inner display in landscape. Safe areas become asymmetric.
- The display folds while the app is running. A 40-point hinge band becomes a reserved region and the layout must re-flow live, with no scene-phase change to hang it on.
Apple's position is the spine of this skill: adapt to size classes and container size — never to the device, the pose, or the orientation. An app that already resizes well gets most of this for free. The skill is about the part that isn't free.
Ground truth first
These APIs were days old when this was written, so there is little to check a spelling against except the SDK itself. The traps survive a plausibility check: the reserved-region kinds are singular (.division, .occlusion) where prose pluralises them naturally, and a name that compiles can still be the wrong one. A wrong name costs a build cycle; a wrong mental model costs an afternoon. So before writing code:
scripts/check-sdk.sh # which Duo APIs does the installed SDK really have, and in which module?
One result surprises everyone once: SwiftUI's layout APIs (ArrangementView, reservedRegions, onHingeChange …) are declared in SwiftUICore, not SwiftUI. import SwiftUI re-exports them, so code compiles — but grepping SwiftUI.swiftinterface alone "proves" they don't exist. Search both. When a symbol is reported missing, don't code against it; at the time of writing that was AVCaptureDeviceDirectionCoordinator, described in Tech Talk 111465 and absent from the headers.
If you can't run scripts, trust references/api-reference.md (every entry was read from the SDK and compile-checked) over anything remembered or searched.
How to work
- Verify the SDK —
scripts/check-sdk.sh. Note the deployment target: everything new needs #available(iOS 27.1, *) and a fallback that still works.
- Audit —
scripts/audit.sh <project-dir> gives a ranked reading list of likely breakage. Grep can't see the biggest class of problem (centred and stretched layouts), so also walk references/audit-checklist.md.
- Fix cheapest-first. Roughly: stretched controls → safe-area assumptions → toolbar items → layout restructuring with arrangements → reserved regions for custom-drawn content → camera → multiple scenes. The early items are small diffs with large visible effect; they also remove noise before the structural work.
- Verify in every pose, by measurement. Attach
assets/DuoLayoutProbe.swift, capture with scripts/capture-displays.sh, and compare numbers — see Verifying below. Layout bugs on this device routinely look like design problems until you log the values.
Match the effort to the question. "Does this code use the API correctly?" is answered by the compiler in seconds: swiftc -typecheck -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" -target arm64-apple-ios26.0-simulator File.swift. Booting a simulator is for questions about behaviour — where a pane lands, what an inset is. Use the simulator that is already booted; never create extra ones (each costs gigabytes of disk and RAM on the user's machine), don't drive GUI apps unasked, and delete anything you did create.
Keep the phone and tablet paths working. Most fixes here are neutral or positive on iPad; say so explicitly when a change alters iPad behaviour (a two-pane layout triggered by "regular width and wider than tall" also triggers on an iPad in landscape).
The device in one table
Measured on the iPhone Duo simulator (@3x); "content" is what a root view gets inside the safe area.
|
Outer display (closed) |
Inner display, portrait |
Inner display, landscape |
| Points |
466 × 678 |
669 × 951 |
951 × 669 |
| Size classes (h / v) |
compact / regular |
regular / regular |
regular / regular |
| Bars |
vertical, trailing edge |
horizontal, top and bottom |
vertical, trailing edge |
| Safe-area insets (t / l / b / tr) |
bar on the trailing side |
82 / 0 / 83 / 0 |
24 / 0 / 34 / 84 |
| Content |
≈ 382 wide |
669 × 786 |
867 × 611 |
| Hinge (division region) |
— |
horizontal band |
vertical band, x 455.5…495.5 of 867 |
The outer display is wider and shorter than any other iPhone (an iPhone 17 Pro is 402 × 874). The inner display ignores UISupportedInterfaceOrientations entirely — a portrait-only app gets landscape there. More numbers, and how they were taken: references/device-and-metrics.md.
Rules that matter, and why
- Decide layout from
horizontalSizeClass and the container's size. Not userInterfaceIdiom (the inner display is regular width on a phone), not orientation (it isn't size, and the inner display doesn't honour yours), not UIScreen.main (there are two screens and it is deprecated).
- Wide is not the same as stretched. Text has a length at which it reads well and a card has a size at which it looks like a card. Cap columns of content at a readable measure (≈ 560 pt) and let the paper get wider. Grids of things — covers, photos — are the exception: they want every point.
- Treat safe areas as asymmetric. With a vertical bar only one side is inset. Inset the rect (
bounds.inset(by: safeAreaInsets)); never subtract one side twice. Never hard-code a status-bar height as a fallback, and don't discard a measured inset because it is small — on this device the top inset ranges from 24 to 82 depending on pose.
- Backgrounds may run under the bar; foregrounds may not. Full-bleed imagery behind the vertical bar is good; anything tappable or readable stays in the safe area.
backgroundExtensionEffect() does it for you by mirroring the view's edge pixels outward — right for a photograph or a colour field, wrong for anything already blurred, where the mirror axis shows as a seam. Inside an arrangement it goes on the pane, not on the image within it; a background that has to reach past the pane's own edge can't be drawn in a pane at all (trap 6 below).
- Only system containers get vertical bars. Items declared with
.toolbar { } in a NavigationStack / NavigationSplitView, or on a view controller inside UINavigationController / UITabBarController, move to the side. Hand-made UIToolbars and floating button stacks don't, and may end up under the camera.
- Give every toolbar item a title and a symbol. A vertical bar has fixed width and flexible height: it shows symbols. Title-only and custom-view items won't present vertically; the title is still needed for the overflow menu.
- Same functionality in every pose. People open and close the device constantly. Rearranging is fine; features that exist only in one pose are not.
- Small moves. When the device folds, move only what must move to stay visible and tappable. Controls that jump across the screen are controls people lose.
Choosing a layout container
| The screen has… |
Use |
Because |
| List → detail (selection drives the other pane) |
NavigationSplitView / UISplitViewController |
It's navigation. Collapses on the outer display, expands on the inner, adapts to the fold by itself. |
| Two peers that are both always relevant (player + queue, hero + shelf, map + results) |
ArrangementView with .split |
Side by side when wide, stacked when tall, and snaps the panes to the two halves when folded. |
| A foreground layered over a background (controls over a page, shutter over a viewfinder) |
ArrangementView with .overlay |
Layered when flat; when partially folded the two move to opposite sides of the hinge. |
| One scrolling column |
Nothing new — cap its width |
A feed or article scrolls through the fold; scrolling content is exempt from fold avoidance. |
| A grid |
Nothing new — compute columns from measured width |
Prefer an even column count when a hinge exists so the fold falls in a gutter. |
Mapping from existing code: an HStack/VStack of two real panes becomes a split arrangement; a ZStack of content and controls becomes an overlay arrangement.
ArrangementView essentials
NavigationStack { // navigation goes AROUND it
ArrangementView {
PlayerView()
.splitArrangementLayoutSize(minWidth: 280, idealWidth: 330, maxWidth: 440)
} secondary: {
QueueView() // a ScrollView/List INSIDE a pane is fine
.splitArrangementLayoutSize(minWidth: 360)
}
.arrangementViewStyle(.split.axes(.horizontal))
}
Six traps, each of which cost a debugging session to find:
- Size preferences belong on the panes, not on the
ArrangementView. splitArrangementLayoutSize, splitArrangementLayoutRatio and splitArrangementFixedLayoutSize are View modifiers that a child uses to describe itself (like navigationSplitViewColumnWidth). On the container they are silently ignored and the split stays 50/50.
- Once a pane names an
idealWidth, its partner needs a minWidth. A primary carrying min 280 / ideal 330 / max 440 opposite a floorless secondary took its ideal 330 and left the secondary 126 — and the two together fitted inside the leading half, leaving the 371.5-point trailing half blank. Text wrapped one character per line. A bare minWidth on the primary alone was measured as harmless, so the missing floor is only half the cause; the ideal width is the trigger. Giving the secondary a floor restored one pane per half.
- A
minWidth that cannot fit a half deletes the whole arrangement. Half-folded, a primary floor of 500 against a 455.5-point half rendered nothing: no panes, no primary-only fallback, no diagnostic. 400 was fine. The container is 867 points wide, so the layout looks correct flat and goes blank the moment someone folds the device — budget every floor against the half, not the display.
.axes(.horizontal) shows only the primary when the container is taller than wide. The secondary doesn't stack underneath; it disappears. Either allow both axes (.split), or enter the arrangement only when the container is wider than tall and wide enough for both floors — and keep a single-column fallback. Require regular vertical size class too: a Plus/Max iPhone in landscape is regular width but compact height, and without that check it silently switches to your new two-pane layout on a 440-point-tall screen you never designed for.
- Don't put an
ArrangementView inside a ScrollView, List or NavigationSplitView, and don't put navigation containers inside it. Arrangements do layout, not navigation.
- A full-bleed background can't be drawn inside a pane. A pane's trailing edge is the arrangement's edge, not the screen's, and a pane sitting inside the safe area has no trailing inset to tell it the bar is there. Ignoring the safe area horizontally doesn't reach towards the screen edge — it grows the scene over the neighbouring pane, and everything laid out inside moves with it (measured: a 330-point hero pane ending at x = 867 with the bar at 867…951, and the first ~20 points of every line of text sheared off at a hard vertical line).
.offset(x:) to push it back takes the same width off the other side. Draw the background as a plain sibling behind the whole ArrangementView and let the pane report where it begins — nothing clips a ZStack sibling.
Two things that are not problems, so don't engineer around them: the fold overrides maxWidth (a pane capped at 440 took the full 455.5-point half), and the arrangement re-lays out live as the hinge moves — no observer needed.
Prefer point sizes to ratios when a pane holds something of fixed physical size; as a ratio, a pane built around one book cover took half of a 1366-point tablet. UIKit equivalent, overlay details, reading the arrangement from inside a pane (overlayArrangementZIndex, splitArrangementAxis), and a worked migration: references/arrangement-views.md.
Vertical bars essentials
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Close", systemImage: "xmark") { } } // top
ToolbarItem(placement: .topBarPinnedTrailing) { Button("Done", systemImage: "checkmark") { } } // then
ToolbarItemGroup(placement: .topBarTrailing) {
Button("Compose", systemImage: "square.and.pencil") { }
}
.visibilityPriority(.high) // last into the overflow menu
ToolbarItem(placement: .principal) { Text("INBOX") }
.axisBehavior(.horizontalOnly) // meaningful text stays horizontal
ToolbarOverflowMenu { Button("Settings", systemImage: "gearshape") { } } // one overflow: the system's
}
- Order on the vertical axis: primary navigation (Back, Close) at the top, then prominent actions (Done), then the rest in their groups. Use the semantic placements above rather than manual spacing; group related items with
ToolbarItemGroup.
- Items overflow bottom-to-top by default. Use
visibilityPriority to keep the frequently used action (Compose, New Note) and anything carrying status (badges) visible longest.
- When space runs out, choose what survives:
toolbarVerticalCompressionBehavior(.prefersTabBar) keeps the tab bar and overflows toolbar items (the iOS default — right for navigation-focused apps); .prefersToolbarItems keeps the actions and minimises the tab bar (right for task-focused screens).
- Opt out with
toolbarVerticalBehavior(.disabled) only for full-width, bottom-heavy, non-scrolling UI (a calculator) or a sheet whose only control is Close.
- Custom toolbar views read
@Environment(\.toolbarVerticalEdge) (.leading, .trailing, or nil for horizontal bars) and switch to a fixed-width, symbol-only form. It can be .leading: in Split View multitasking each app's bar sits on its outer edge.
Where bars are and aren't vertical (sheets, inspectors, split-view columns), UIKit spellings, tab-bar sidebar placement, and one measured nuance about .principal items: references/vertical-bars.md.
Reserved regions and the hinge
GeometryReader { proxy in
let hinge = proxy.reservedRegions(kind: .division).first // active regions only
let cameras = proxy.reservedRegions(kind: .occlusion)
// `.includeInactive` also returns the hinge while the device is flat (isActive == false)
}
Two kinds: .division (the fold — active only while partially folded, zero effect when flat) and .occlusion (cameras; the inner one only while in use). A region's frame already includes the margins recommended for interactive content. System components — alerts, menus, sheets, toolbar buttons, split views, arrangements — avoid the fold on their own; query regions only for content you position by hand. Keep interactive elements out of the fold; let scrolling content pass through it.
onHingeChange / UIHingeInteraction give the hinge status and a continuous angle. That is for live effects (a page that tilts with the fold), not for layout — layout belongs to size classes, arrangements and regions. Details and UIKit forms: references/reserved-regions-and-hinge.md.
Anti-patterns
| Pattern |
Why it breaks here |
Instead |
UIScreen.main.bounds / .scale |
Two screens; deprecated |
Container bounds; view.window?.windowScene?.screen; traitCollection.displayScale |
if UIDevice.current.userInterfaceIdiom == .pad |
Inner display is regular width on a phone |
horizontalSizeClass, measured width |
| A model check — "is this device foldable?" — to choose a container |
There is no such API, so it is a hard-coded model list, and it is constant: still true while the outer display is showing, where an arrangement then stacks two panes into a phone-sized screen |
Ask the display, not the device: reservedRegions(kind: .division, options: .includeInactive) is empty on the outer display. Better still, ask neither — size classes already distinguish them |
| Orientation checks for layout |
Not size; inner display ignores supported orientations |
Size class or container aspect ratio |
safeAreaInsets.top ?? 59 |
One phone's status bar, frozen into layout |
Fall back to 0; measure |
if inset > 0 { use(inset) } |
Bakes in "there is always a top inset"; keeps stale values |
Accept the measurement; bound it some other way |
width - safeAreaInsets.left * 2 |
Insets are asymmetric with a vertical bar |
bounds.inset(by: safeAreaInsets) |
connectedScenes.first |
First iPhone with multiple scenes |
The view's own window scene, else foregroundActive |
.frame(maxWidth: .infinity) on controls and cards |
600-point chips and search fields |
Cap at a readable measure, align with the content below |
| Hero height as a % of viewport height |
64 % of a 669-point-tall screen leaves one clipped row |
Bound by width too, or move the hero into a pane |
Int(width / itemWidth) for grid columns |
Floors 2.96 to 2: two fat columns |
Round; prefer even counts when a hinge exists |
AVCaptureDevice.default(.builtInWideAngleCamera, …) and a session tied to scenePhase |
Cameras differ per display; folding isn't a phase change |
Discover devices; reconfigure on display change; RotationCoordinator |
settings.flashMode = .auto |
Uncatchable exception if unsupported; no front camera has a flash |
Check supportedFlashModes first |
Verifying
Test matrix — all six, because each has failed independently in practice: outer display portrait · outer landscape · inner portrait · inner landscape flat · inner landscape half-folded · inner portrait half-folded. Then Split View multitasking on the inner display (bar on the leading edge for the left-hand app).
- Poses are changed in Device Hub (it replaces Simulator.app in Xcode 27.1). There is no
simctl command and no XCTest API for folding; XCUIDevice can rotate only. If you are an agent without screen control, ask the user to set the pose, then measure.
scripts/capture-displays.sh screenshots both displays by id. A plain simctl io … screenshot often grabs the display that is switched off — a black image that looks like a crash.
- Measure, don't eyeball. Add
.duoLayoutProbe("name") from assets/DuoLayoutProbe.swift and read real sizes, insets, region frames and hinge state from the log. Estimating from screenshots goes wrong quietly: they are @3x, often downscaled again by the viewer, and the two displays differ. Several "layout bugs" in the work behind this skill were measurement errors, and several real bugs were invisible until logged.
- Mind default actor isolation. New Xcode project templates set
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. A plain struct you hand to onGeometryChange(for:) then gets a main-actor-isolated Equatable conformance and the build fails with "cannot satisfy conformance requirement for a 'Sendable' type parameter". Mark such value types nonisolated and Sendable. The bundled probe already is — it failed in a real app before it was.
- Re-run
tests/typecheck.sh from the repository after every Xcode update; it checks every sample under both isolation defaults.
- Layout behaviour claims in this skill were measured with DuoProbe, a fixture app that renders the same two panes ten ways and prints what each one got. When a claim here looks wrong on your Xcode, re-run it there rather than arguing from a screenshot.
Simulator tooling gaps, log commands and a per-pose checklist: references/simulator-and-verification.md.
Reference files
Read only what the task needs.
| File |
Read it when |
references/api-reference.md |
You need an exact spelling, module, availability or enum case — SwiftUI, UIKit, AVFoundation |
references/arrangement-views.md |
Building or debugging a two-pane or layered layout |
references/vertical-bars.md |
Toolbar, tab bar, sheet or navigation-bar work |
references/reserved-regions-and-hinge.md |
Positioning custom content around the fold or cameras; hinge-driven effects |
references/camera-and-scenes.md |
Capture sessions, the second display as a camera accessory, multiple windows |
references/device-and-metrics.md |
You need numbers: sizes, insets, regions per pose |
references/audit-checklist.md |
Auditing an existing app, or deciding what to fix first |
references/simulator-and-verification.md |
Running, capturing, logging and testing on the simulator |
Apple's own material — the HIG page Designing for iPhone Duo, the overview Preparing your app for iPhone Duo, and Tech Talks 111461–111466 — is linked from references/api-reference.md. When this skill and the installed SDK disagree, the SDK wins: fix the code, then open an issue against the skill.
1---2name: iphone-duo-adaptation3description: Adapt iOS apps (SwiftUI and UIKit) for iPhone Duo, Apple's folding iPhone: two displays, a hinge, toolbars and tab bars that move to the side, reserved regions, and the iOS 27.1 layout APIs (ArrangementView, UIArrangementViewController, reservedRegions, onHingeChange, axisBehavior, toolbarVerticalBehavior). Use this skill whenever the user mentions iPhone Duo, a foldable or folding iPhone, the hinge or fold, book or laptop pose, inner or outer display, Device Hub, vertical bars, or ArrangementView. Use it too when an iOS layout looks stretched, centred with dead space, squeezed or clipped on a wide or short screen, when toolbar items overflow or jump to the side, or when auditing an app for iOS 27 / Xcode 27 readiness, even if nobody says "Duo". Provides API spellings verified against the shipping SDK, measured device metrics, the traps that cost real debugging time, compile-checked samples, and scripts that audit a project and verify the SDK.4license: MIT5---67# Adapting an app for iPhone Duo89iPhone Duo is Apple's folding iPhone: a compact outer display, a large inner display, a hinge between the halves, and a front camera on each side. Three consequences break real apps:10111. **The screen got wide and short.** A layout that centres a phone-width column — or stretches one — ends up with dead paper or 600-point-wide controls.122. **The bars moved to the side.** Status bar, Dynamic Island, toolbar and tab bar share one *vertical bar* on the outer display and on the inner display in landscape. Safe areas become asymmetric.133. **The display folds while the app is running.** A 40-point hinge band becomes a reserved region and the layout must re-flow live, with no scene-phase change to hang it on.1415Apple's position is the spine of this skill: **adapt to size classes and container size — never to the device, the pose, or the orientation.** An app that already resizes well gets most of this for free. The skill is about the part that isn't free.1617## Ground truth first1819These APIs were days old when this was written, so there is little to check a spelling against except the SDK itself. The traps survive a plausibility check: the reserved-region kinds are singular (`.division`, `.occlusion`) where prose pluralises them naturally, and a name that compiles can still be the wrong one. A wrong name costs a build cycle; a wrong mental model costs an afternoon. So before writing code:2021```bash22scripts/check-sdk.sh # which Duo APIs does the installed SDK really have, and in which module?23```2425One result surprises everyone once: SwiftUI's layout APIs (`ArrangementView`, `reservedRegions`, `onHingeChange` …) are declared in **SwiftUICore**, not SwiftUI. `import SwiftUI` re-exports them, so code compiles — but grepping `SwiftUI.swiftinterface` alone "proves" they don't exist. Search both. When a symbol is reported missing, don't code against it; at the time of writing that was `AVCaptureDeviceDirectionCoordinator`, described in Tech Talk 111465 and absent from the headers.2627If you can't run scripts, trust `references/api-reference.md` (every entry was read from the SDK and compile-checked) over anything remembered or searched.2829## How to work30311. **Verify the SDK** — `scripts/check-sdk.sh`. Note the deployment target: everything new needs `#available(iOS 27.1, *)` and a fallback that still works.322. **Audit** — `scripts/audit.sh <project-dir>` gives a ranked reading list of likely breakage. Grep can't see the biggest class of problem (centred and stretched layouts), so also walk `references/audit-checklist.md`.333. **Fix cheapest-first.** Roughly: stretched controls → safe-area assumptions → toolbar items → layout restructuring with arrangements → reserved regions for custom-drawn content → camera → multiple scenes. The early items are small diffs with large visible effect; they also remove noise before the structural work.344. **Verify in every pose, by measurement.** Attach `assets/DuoLayoutProbe.swift`, capture with `scripts/capture-displays.sh`, and compare numbers — see *Verifying* below. Layout bugs on this device routinely look like design problems until you log the values.3536**Match the effort to the question.** "Does this code use the API correctly?" is answered by the compiler in seconds: `swiftc -typecheck -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" -target arm64-apple-ios26.0-simulator File.swift`. Booting a simulator is for questions about *behaviour* — where a pane lands, what an inset is. Use the simulator that is already booted; never create extra ones (each costs gigabytes of disk and RAM on the user's machine), don't drive GUI apps unasked, and delete anything you did create.3738Keep the phone and tablet paths working. Most fixes here are neutral or positive on iPad; say so explicitly when a change alters iPad behaviour (a two-pane layout triggered by "regular width and wider than tall" also triggers on an iPad in landscape).3940## The device in one table4142Measured on the iPhone Duo simulator (`@3x`); "content" is what a root view gets inside the safe area.4344| | Outer display (closed) | Inner display, portrait | Inner display, landscape |45|---|---|---|---|46| Points | 466 × 678 | 669 × 951 | 951 × 669 |47| Size classes (h / v) | compact / regular | regular / regular | regular / regular |48| Bars | **vertical**, trailing edge | horizontal, top and bottom | **vertical**, trailing edge |49| Safe-area insets (t / l / b / tr) | bar on the trailing side | 82 / 0 / 83 / 0 | 24 / 0 / 34 / **84** |50| Content | ≈ 382 wide | 669 × 786 | 867 × 611 |51| Hinge (division region) | — | horizontal band | vertical band, x 455.5…495.5 of 867 |5253The outer display is *wider and shorter* than any other iPhone (an iPhone 17 Pro is 402 × 874). The inner display ignores `UISupportedInterfaceOrientations` entirely — a portrait-only app gets landscape there. More numbers, and how they were taken: `references/device-and-metrics.md`.5455## Rules that matter, and why5657- **Decide layout from `horizontalSizeClass` and the container's size.** Not `userInterfaceIdiom` (the inner display is regular width *on a phone*), not orientation (it isn't size, and the inner display doesn't honour yours), not `UIScreen.main` (there are two screens and it is deprecated).58- **Wide is not the same as stretched.** Text has a length at which it reads well and a card has a size at which it looks like a card. Cap columns of content at a readable measure (≈ 560 pt) and let the *paper* get wider. Grids of things — covers, photos — are the exception: they want every point.59- **Treat safe areas as asymmetric.** With a vertical bar only one side is inset. Inset the rect (`bounds.inset(by: safeAreaInsets)`); never subtract one side twice. Never hard-code a status-bar height as a fallback, and don't discard a measured inset because it is small — on this device the top inset ranges from 24 to 82 depending on pose.60- **Backgrounds may run under the bar; foregrounds may not.** Full-bleed imagery behind the vertical bar is good; anything tappable or readable stays in the safe area. `backgroundExtensionEffect()` does it for you by mirroring the view's edge pixels outward — right for a photograph or a colour field, wrong for anything already blurred, where the mirror axis shows as a seam. Inside an arrangement it goes on the *pane*, not on the image within it; a background that has to reach past the pane's own edge can't be drawn in a pane at all (trap 6 below).61- **Only system containers get vertical bars.** Items declared with `.toolbar { }` in a `NavigationStack` / `NavigationSplitView`, or on a view controller inside `UINavigationController` / `UITabBarController`, move to the side. Hand-made `UIToolbar`s and floating button stacks don't, and may end up under the camera.62- **Give every toolbar item a title *and* a symbol.** A vertical bar has fixed width and flexible height: it shows symbols. Title-only and custom-view items won't present vertically; the title is still needed for the overflow menu.63- **Same functionality in every pose.** People open and close the device constantly. Rearranging is fine; features that exist only in one pose are not.64- **Small moves.** When the device folds, move only what must move to stay visible and tappable. Controls that jump across the screen are controls people lose.6566## Choosing a layout container6768| The screen has… | Use | Because |69|---|---|---|70| List → detail (selection drives the other pane) | `NavigationSplitView` / `UISplitViewController` | It's navigation. Collapses on the outer display, expands on the inner, adapts to the fold by itself. |71| Two peers that are both always relevant (player + queue, hero + shelf, map + results) | `ArrangementView` with `.split` | Side by side when wide, stacked when tall, and snaps the panes to the two halves when folded. |72| A foreground layered over a background (controls over a page, shutter over a viewfinder) | `ArrangementView` with `.overlay` | Layered when flat; when partially folded the two move to opposite sides of the hinge. |73| One scrolling column | Nothing new — cap its width | A feed or article scrolls through the fold; scrolling content is exempt from fold avoidance. |74| A grid | Nothing new — compute columns from measured width | Prefer an **even** column count when a hinge exists so the fold falls in a gutter. |7576Mapping from existing code: an `HStack`/`VStack` of two real panes becomes a split arrangement; a `ZStack` of content and controls becomes an overlay arrangement.7778## ArrangementView essentials7980```swift81NavigationStack { // navigation goes AROUND it82 ArrangementView {83 PlayerView()84 .splitArrangementLayoutSize(minWidth: 280, idealWidth: 330, maxWidth: 440)85 } secondary: {86 QueueView() // a ScrollView/List INSIDE a pane is fine87 .splitArrangementLayoutSize(minWidth: 360)88 }89 .arrangementViewStyle(.split.axes(.horizontal))90}91```9293Six traps, each of which cost a debugging session to find:94951. **Size preferences belong on the panes, not on the `ArrangementView`.** `splitArrangementLayoutSize`, `splitArrangementLayoutRatio` and `splitArrangementFixedLayoutSize` are `View` modifiers that a *child* uses to describe itself (like `navigationSplitViewColumnWidth`). On the container they are silently ignored and the split stays 50/50.962. **Once a pane names an `idealWidth`, its partner needs a `minWidth`.** A primary carrying `min 280 / ideal 330 / max 440` opposite a floorless secondary took its ideal 330 and left the secondary 126 — and the two together fitted inside the *leading* half, leaving the 371.5-point trailing half blank. Text wrapped one character per line. A bare `minWidth` on the primary alone was measured as harmless, so the missing floor is only half the cause; the ideal width is the trigger. Giving the secondary a floor restored one pane per half.973. **A `minWidth` that cannot fit a half deletes the whole arrangement.** Half-folded, a primary floor of 500 against a 455.5-point half rendered *nothing*: no panes, no primary-only fallback, no diagnostic. 400 was fine. The container is 867 points wide, so the layout looks correct flat and goes blank the moment someone folds the device — budget every floor against the **half**, not the display.984. **`.axes(.horizontal)` shows *only the primary* when the container is taller than wide.** The secondary doesn't stack underneath; it disappears. Either allow both axes (`.split`), or enter the arrangement only when the container is wider than tall and wide enough for both floors — and keep a single-column fallback. Require **regular *vertical* size class too**: a Plus/Max iPhone in landscape is regular width but compact height, and without that check it silently switches to your new two-pane layout on a 440-point-tall screen you never designed for.995. **Don't put an `ArrangementView` inside a `ScrollView`, `List` or `NavigationSplitView`, and don't put navigation containers inside it.** Arrangements do layout, not navigation.1006. **A full-bleed background can't be drawn inside a pane.** A pane's trailing edge is the *arrangement's* edge, not the screen's, and a pane sitting inside the safe area has no trailing inset to tell it the bar is there. Ignoring the safe area horizontally doesn't reach towards the screen edge — it grows the scene over the *neighbouring* pane, and everything laid out inside moves with it (measured: a 330-point hero pane ending at x = 867 with the bar at 867…951, and the first ~20 points of every line of text sheared off at a hard vertical line). `.offset(x:)` to push it back takes the same width off the other side. Draw the background as a plain sibling *behind* the whole `ArrangementView` and let the pane report where it begins — nothing clips a `ZStack` sibling.101102Two things that are *not* problems, so don't engineer around them: the fold overrides `maxWidth` (a pane capped at 440 took the full 455.5-point half), and the arrangement re-lays out live as the hinge moves — no observer needed.103104Prefer point sizes to ratios when a pane holds something of fixed physical size; as a ratio, a pane built around one book cover took half of a 1366-point tablet. UIKit equivalent, overlay details, reading the arrangement from inside a pane (`overlayArrangementZIndex`, `splitArrangementAxis`), and a worked migration: `references/arrangement-views.md`.105106## Vertical bars essentials107108```swift109.toolbar {110 ToolbarItem(placement: .cancellationAction) { Button("Close", systemImage: "xmark") { } } // top111 ToolbarItem(placement: .topBarPinnedTrailing) { Button("Done", systemImage: "checkmark") { } } // then112 ToolbarItemGroup(placement: .topBarTrailing) {113 Button("Compose", systemImage: "square.and.pencil") { }114 }115 .visibilityPriority(.high) // last into the overflow menu116 ToolbarItem(placement: .principal) { Text("INBOX") }117 .axisBehavior(.horizontalOnly) // meaningful text stays horizontal118 ToolbarOverflowMenu { Button("Settings", systemImage: "gearshape") { } } // one overflow: the system's119}120```121122- Order on the vertical axis: primary navigation (Back, Close) at the top, then prominent actions (Done), then the rest in their groups. Use the semantic placements above rather than manual spacing; group related items with `ToolbarItemGroup`.123- Items overflow bottom-to-top by default. Use `visibilityPriority` to keep the frequently used action (Compose, New Note) and anything carrying status (badges) visible longest.124- When space runs out, choose what survives: `toolbarVerticalCompressionBehavior(.prefersTabBar)` keeps the tab bar and overflows toolbar items (the iOS default — right for navigation-focused apps); `.prefersToolbarItems` keeps the actions and minimises the tab bar (right for task-focused screens).125- Opt out with `toolbarVerticalBehavior(.disabled)` only for full-width, bottom-heavy, non-scrolling UI (a calculator) or a sheet whose only control is Close.126- Custom toolbar views read `@Environment(\.toolbarVerticalEdge)` (`.leading`, `.trailing`, or `nil` for horizontal bars) and switch to a fixed-width, symbol-only form. It can be `.leading`: in Split View multitasking each app's bar sits on its *outer* edge.127128Where bars are and aren't vertical (sheets, inspectors, split-view columns), UIKit spellings, tab-bar sidebar placement, and one measured nuance about `.principal` items: `references/vertical-bars.md`.129130## Reserved regions and the hinge131132```swift133GeometryReader { proxy in134 let hinge = proxy.reservedRegions(kind: .division).first // active regions only135 let cameras = proxy.reservedRegions(kind: .occlusion)136 // `.includeInactive` also returns the hinge while the device is flat (isActive == false)137}138```139140Two kinds: `.division` (the fold — *active only while partially folded*, zero effect when flat) and `.occlusion` (cameras; the inner one only while in use). A region's `frame` already includes the margins recommended for interactive content. System components — alerts, menus, sheets, toolbar buttons, split views, arrangements — avoid the fold on their own; query regions only for content you position by hand. Keep interactive elements out of the fold; let scrolling content pass through it.141142`onHingeChange` / `UIHingeInteraction` give the hinge status and a continuous angle. That is for *live effects* (a page that tilts with the fold), not for layout — layout belongs to size classes, arrangements and regions. Details and UIKit forms: `references/reserved-regions-and-hinge.md`.143144## Anti-patterns145146| Pattern | Why it breaks here | Instead |147|---|---|---|148| `UIScreen.main.bounds` / `.scale` | Two screens; deprecated | Container bounds; `view.window?.windowScene?.screen`; `traitCollection.displayScale` |149| `if UIDevice.current.userInterfaceIdiom == .pad` | Inner display is regular width on a phone | `horizontalSizeClass`, measured width |150| A model check — "is this device foldable?" — to choose a container | There is no such API, so it is a hard-coded model list, and it is **constant**: still true while the *outer* display is showing, where an arrangement then stacks two panes into a phone-sized screen | Ask the display, not the device: `reservedRegions(kind: .division, options: .includeInactive)` is empty on the outer display. Better still, ask neither — size classes already distinguish them |151| Orientation checks for layout | Not size; inner display ignores supported orientations | Size class or container aspect ratio |152| `safeAreaInsets.top ?? 59` | One phone's status bar, frozen into layout | Fall back to `0`; measure |153| `if inset > 0 { use(inset) }` | Bakes in "there is always a top inset"; keeps stale values | Accept the measurement; bound it some other way |154| `width - safeAreaInsets.left * 2` | Insets are asymmetric with a vertical bar | `bounds.inset(by: safeAreaInsets)` |155| `connectedScenes.first` | First iPhone with multiple scenes | The view's own window scene, else `foregroundActive` |156| `.frame(maxWidth: .infinity)` on controls and cards | 600-point chips and search fields | Cap at a readable measure, align with the content below |157| Hero height as a % of viewport height | 64 % of a 669-point-tall screen leaves one clipped row | Bound by width too, or move the hero into a pane |158| `Int(width / itemWidth)` for grid columns | Floors 2.96 to 2: two fat columns | Round; prefer even counts when a hinge exists |159| `AVCaptureDevice.default(.builtInWideAngleCamera, …)` and a session tied to `scenePhase` | Cameras differ per display; folding isn't a phase change | Discover devices; reconfigure on display change; `RotationCoordinator` |160| `settings.flashMode = .auto` | Uncatchable exception if unsupported; no front camera has a flash | Check `supportedFlashModes` first |161162## Verifying163164Test matrix — all six, because each has failed independently in practice: outer display portrait · outer landscape · inner portrait · inner landscape flat · inner landscape **half-folded** · inner portrait half-folded. Then Split View multitasking on the inner display (bar on the leading edge for the left-hand app).165166- Poses are changed in **Device Hub** (it replaces Simulator.app in Xcode 27.1). There is no `simctl` command and no XCTest API for folding; `XCUIDevice` can rotate only. If you are an agent without screen control, ask the user to set the pose, then measure.167- `scripts/capture-displays.sh` screenshots both displays by id. A plain `simctl io … screenshot` often grabs the display that is switched off — a black image that looks like a crash.168- **Measure, don't eyeball.** Add `.duoLayoutProbe("name")` from `assets/DuoLayoutProbe.swift` and read real sizes, insets, region frames and hinge state from the log. Estimating from screenshots goes wrong quietly: they are `@3x`, often downscaled again by the viewer, and the two displays differ. Several "layout bugs" in the work behind this skill were measurement errors, and several real bugs were invisible until logged.169- **Mind default actor isolation.** New Xcode project templates set `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. A plain `struct` you hand to `onGeometryChange(for:)` then gets a main-actor-isolated `Equatable` conformance and the build fails with *"cannot satisfy conformance requirement for a 'Sendable' type parameter"*. Mark such value types `nonisolated` and `Sendable`. The bundled probe already is — it failed in a real app before it was.170- Re-run `tests/typecheck.sh` from the repository after every Xcode update; it checks every sample under both isolation defaults.171- Layout *behaviour* claims in this skill were measured with [DuoProbe](https://github.com/sven-ericmolzahn/iphone-duo-probe), a fixture app that renders the same two panes ten ways and prints what each one got. When a claim here looks wrong on your Xcode, re-run it there rather than arguing from a screenshot.172173Simulator tooling gaps, log commands and a per-pose checklist: `references/simulator-and-verification.md`.174175## Reference files176177Read only what the task needs.178179| File | Read it when |180|---|---|181| `references/api-reference.md` | You need an exact spelling, module, availability or enum case — SwiftUI, UIKit, AVFoundation |182| `references/arrangement-views.md` | Building or debugging a two-pane or layered layout |183| `references/vertical-bars.md` | Toolbar, tab bar, sheet or navigation-bar work |184| `references/reserved-regions-and-hinge.md` | Positioning custom content around the fold or cameras; hinge-driven effects |185| `references/camera-and-scenes.md` | Capture sessions, the second display as a camera accessory, multiple windows |186| `references/device-and-metrics.md` | You need numbers: sizes, insets, regions per pose |187| `references/audit-checklist.md` | Auditing an existing app, or deciding what to fix first |188| `references/simulator-and-verification.md` | Running, capturing, logging and testing on the simulator |189190Apple's own material — the HIG page *Designing for iPhone Duo*, the overview *Preparing your app for iPhone Duo*, and Tech Talks 111461–111466 — is linked from `references/api-reference.md`. When this skill and the installed SDK disagree, the SDK wins: fix the code, then open an issue against the skill.