npx skills add ...
npx skills add mirzaaghazadeh/iphone-duo-skills --skill iphone-duo-dual-pane-patterns
Design and build two-pane experiences for iPhone Duo — the real pixel and point dimensions of each display and each half, the exact hinge callback types (onHingeChange / UIHingeInteraction), hinge-driven effects, pose-to-pattern mapping (companion pane, dual view, list-detail, tabletop controls), two-pane paywalls and fold-aware onboarding. Use when sizing a layout or asset for iPhone Duo, wiring a hinge callback, choosing what goes in each pane, or designing onboarding and purchase flows that span the fold.
npx skills add mirzaaghazadeh/iphone-duo-skills --skill iphone-duo-dual-pane-patterns
The sibling skills explain the platform: reserved regions and arrangements in
../iphone-duo-adaptive-layout/SKILL.md, the hinge and scenes in
../iphone-duo-hinge-and-scenes/SKILL.md, design principles in
../iphone-duo-design-review/SKILL.md. This one is the applied layer: the
numbers you'll want when mocking up or sizing assets, the exact shapes of the
hinge callbacks, and the proven patterns for what to put in each pane — drawn
from Apple's material plus a decade of dual-screen guidance from Microsoft
(Surface Duo), Google (Android foldables) and Samsung (Flex Mode), which all
converge on the same answers.
From Apple's published specs. Use them for mockups, asset budgets and sanity checks — never as layout constants. Layout reads size class, safe area and reserved regions at runtime.
| Outer display | Inner display (open) | Each half of the inner display | |
|---|---|---|---|
| Diagonal | 5.4 in | 7.6 in | — |
| Points (w × h, as held) | 466 × 678 portrait | 951 × 669 landscape | 475.5 × 669 portrait |
| Pixels (w × h, as held) | 1398 × 2034 portrait | 2670 × 1878 landscape | ≈1335 × 1878 portrait |
| Density | 460 ppi | 430 ppi | 430 ppi |
| Aspect ratio | ≈1.45 : 1 (tall) | ≈1.42 : 1 (wide) | ≈1.41 : 1 (tall) |
| Size class | compact × regular (portrait), compact × compact (landscape) | regular × regular, any orientation | — |
Apple lists the inner panel as "1878-by-2670"; open, the device is wider than tall (164.6 × 117.8 mm), so the natural open posture is landscape.
Three consequences worth internalising:
Apple's Tech Specs list pixels only. The point sizes come from the iPhone Duo templates in Apple's iOS and iPadOS 27 design resource (as reproduced in Noah Elhadedy's community iPhone Duo Adaptive Design Starter Kit, aligned to Apple's templates on 23 September 2026):
Still confirm in the Xcode 27.1 simulator; read GeometryProxy.size or scene
bounds rather than hardcoding any of this.
For mockups and for sanity-checking what the system gives you at runtime. The Source column matters: only Apple values come from Apple's design resource; kit values are design choices from the community starter kit that you're free to change.
Safe areas — asymmetric by design; never mirror one side onto the other.
| State | Top | Bottom | Leading | Trailing | Safe content size | Source |
|---|---|---|---|---|---|---|
| Outer portrait | 0 | 0 | 0 | 84 (vertical bar) | 382 × 678 | Apple |
| Outer landscape | 0 | 0 | 0 | 84, on the camera edge | 594 × 466 | Apple |
| Inner landscape (flat or book) | 0 | 0 | 0 | 84 (vertical bar) | 867 × 669 | Apple |
| Inner portrait (flat or tabletop) | 84 (status + top bar) | 95 (tab bar) | 0 | 0 | 669 × 772 | Apple |
Horizontal bars return only on the inner display in portrait. Everywhere else, navigation, toolbar and tab bar share one 84 pt vertical bar in the order back → prominent action → toolbar groups → tab bar, top to bottom.
Bars and controls
| Element | Size | Source |
|---|---|---|
| Vertical bar width | 84 pt | Apple |
| Horizontal navigation bar / toolbar (inner portrait) | 48 pt tall | Apple |
| Horizontal tab bar (inner portrait) | 95 pt tall inset | Apple |
| Bar button / prominent bar button | 36 pt / 48 pt | Apple |
| Touch target | 44 × 44 pt default, 28 × 28 pt minimum | Apple HIG |
Margins, gaps, fold
| Token | Value | Source |
|---|---|---|
| Layout margin, both displays | 20 pt | Apple |
| Division region width when flat | 0 pt | Apple |
| Division region width when folded | not published — read ReservedRegion.frame | — |
| Fold avoidance band (mockups) | 27 pt, centred on 475.5 pt | kit |
| Gap between panes | 16 pt outer, 24 pt inner | kit |
| Max width for running text | 600 pt | kit |
Columns — Apple asks for an even count; the counts themselves are kit values.
| State | Columns | Gutter |
|---|---|---|
| Outer portrait | 4 | 16 |
| Outer landscape | 6 | 16 |
| Inner portrait | 6 | 24 |
| Inner landscape | 8 | 24 |
| Inner, book-folded | 4 + 4, meeting at the fold, none on it | 24 |
Typical pane widths inside the 867 pt inner-landscape safe area (kit): list 300–320 + detail 546–566; sidebar 280; player/primary ≈462 (one half) + context ≈404. Keep a pane's content close to its outer-display size rather than stretching it.
| Open | Closed | |
|---|---|---|
| Width × height | 164.6 × 117.8 mm | 84.1 × 117.8 mm |
| Thickness | 5.2 mm | 11.3 mm |
| Weight | 254 g | 254 g |
Biometrics are Touch ID in the side button — no Face ID. Any flow that says
"Face ID" or uses a face glyph needs to branch on LAContext.biometryType.
No LiDAR is listed: features built on RoomPlan or scene depth need a
fallback on this device.
All iOS 27.1 (beta at time of writing), verified against Apple's symbol docs.
| Type | Member | Type of member | Notes |
|---|---|---|---|
DeviceHingeContext | hinge | DeviceHinge? | nil → the device has no hinge |
DeviceHinge | angle | Angle | use .degrees / .radians |
DeviceHinge | status | DeviceHinge.Status | |
DeviceHinge.Status | .closed, .partiallyOpen, .fullyOpen | struct with static members | Equatable, so switch works, but you need a default |
The closure receives (old, new). Use isEnabled: to stop updates when an
effect is off-screen instead of ignoring them in the closure.
| Type | Member | Type of member | Notes |
|---|---|---|---|
UIHingeInteraction.Update | hinge | UIHinge? | nil when the view leaves a hierarchy that provides hinge updates |
UIHinge | angle | CGFloat | radians, not degrees |
UIHinge | status | UIHinge.Status | |
UIHinge.Status | .closed, .partiallyOpen, .fullyOpen, .unknown | enum (Int raw value) | UIKit has a fourth case SwiftUI doesn't |
The UIKit handler is not an (old, new) pair. It fires when the hinge
changes and when the interaction moves between hierarchies. Keep your own
previous value if you need a delta.
status, not angle..fullyOpen as your upper bound and normalise against it.The UIKit equivalent reads update.hinge?.angle (already radians) and must add a
case .unknown: that leaves the last value untouched.
There is no "laptop" or "tent" status. Poses are what the user does; your code sees size class, reserved regions and hinge status. Map them like this:
| Pose | What your code observes | Pattern |
|---|---|---|
| Closed | outer display, compact width | The phone app you already have. Secondary pane collapses to a sheet or a push. |
| Open flat | regular × regular, division region inactive (zero width) | Two panes side by side, media may cross the centre. |
| Book (partly folded, inner landscape) | division region active and taller than wide | Two panes, left/right. Nothing interactive in the curve. |
| Tabletop / laptop (partly folded, inner portrait) | division region active and wider than tall | Content in the raised half, controls in the flat half. |
| Tent | partially open, often flipped | Hands-free viewing; don't rely on touch. |
Detect orientation of the fold from the region, not the hinge:
Better still, let ArrangementView with .split choose the axis for you — it
splits horizontally when wider than tall and vertically when taller than wide,
which is the book-vs-tabletop decision.
Microsoft's dual-screen taxonomy, reconciled with Apple's containers:
| Pattern | Left / top pane | Right / bottom pane | Apple container | Good for |
|---|---|---|---|---|
| Companion pane | Canvas (photo, room, document) | Tools, library, properties, prompt | ArrangementView(.split) | Editors, creative tools |
| Dual view | Version A (original) | Version B (result) | Two columns on regular width | Before/after, diffs, compare variants |
| List-detail | List | Detail, updates on tap | NavigationSplitView | Browsing catalogs, styles, messages |
| Extended canvas | One image spanning both panes | — | full-bleed + reserved regions for overlays | Maps, panoramas, video |
| Tabletop controls | Media, no buttons | Joystick, sliders, actions | ArrangementView(.split) / .overlay | Hands-free viewing, showroom, playback |
Pick by content relationship, not by pose: companion = one thing + its tools; dual view = two versions of one thing; list-detail = many things + one.
@State that a layout swap discards. Dialogs stay
within one half.The hinge is for effects and interactions, never layout. Effects that earn their place:
.fullyOpen finish the transition and remove the overlay..closed, persist and show a short confirmation on the
outer display. Pairs naturally with the reveal running in reverse.Always:
nil hinge (most devices) and give those users the end state.else branch when leaving .partiallyOpen, so effects don't
stick at a stale angle.isEnabled: false when the effect isn't on screen.SubscriptionStoreView
handles trial badges and renewal wording for you there.Run every two-pane screen through this in the Xcode 27.1 Device Hub simulator, dragging the hinge slider slowly through each transition:
| Closed | Book (slider mid-way) | Open flat | Tabletop | Split View, left | Split View, right | |
|---|---|---|---|---|---|---|
| No letterboxing | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ |
| Nothing tappable in the fold | — | ☐ | — | ☐ | — | — |
| State kept across the transition | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ |
| Hinge effect resets / no-hinge path works | ☐ | ☐ | ☐ | ☐ | — | — |
| Paywall price + button in one pane | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ |
Also run once on a non-foldable simulator to exercise the nil-hinge path.
init(updateHandler: (UIHingeInteraction, UIHingeInteraction.Update) -> Void)
var isEnabled: Bool/// 0 = closed, 1 = fully open. Effects read this; nothing lays out from it.
@Observable final class HingeProgress {
var value: Double = 1 // no hinge → behave as if flat
var isPartial = false
private var maxSeen: Double = .pi
func update(_ hinge: DeviceHinge?) {
guard let hinge else { value = 1; isPartial = false; return }
let radians = hinge.angle.radians
switch hinge.status {
case .closed:
value = 0; isPartial = false
case .fullyOpen:
maxSeen = max(maxSeen, radians)
value = 1; isPartial = false
default: // .partiallyOpen
value = min(max(radians / maxSeen, 0), 1); isPartial = true
}
}
}
// usage
.onHingeChange(isEnabled: effectVisible) { _, new in progress.update(new.hinge) }GeometryReader { proxy in
let fold = proxy.reservedRegions(kind: .division).first { $0.isActive }
let isTabletop = fold.map { $0.frame.width > $0.frame.height } ?? false
// …
}