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 tha...
Installs into .claude/skills of the current project.
Are you the author of Iphone Duo Dual Pane Patterns?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/mirzaaghazadeh-iphone-duo-dual-pane-patterns)
---
name: iphone-duo-dual-pane-patterns
description: 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.
---
# Dual-pane patterns, dimensions and hinge callbacks
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.
## The numbers
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:
1. **The inner display is the outer display turned sideways**, near enough. Both
are ≈1.4–1.45 : 1, which is why Apple describes them as sharing one aspect
ratio.
2. **Each half of the inner display is roughly one outer display.** A phone-shaped
UI fits one half; the other half is *additional*. That is the whole design
thesis: one familiar pane plus one extra pane, not one stretched phone UI.
3. **The fold runs down the middle of the long edge** when open, so default
two-pane layouts are side by side, and tabletop layouts are top/bottom.
### Points
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):
- Outer: **466 × 678 pt** — exactly the 1398 × 2034 panel at 3×.
- Inner: **951 × 669 pt** (669 × 951 in portrait). That is **not** an exact 3×
of the 2670 × 1878 panel: the system renders at 2853 × 2007 (the App Store
Connect screenshot size) and downsamples, like iPhone 6 Plus did. Export
inner-display assets at 3× of the point size, not from the panel pixels.
- The fold sits at **475.5 pt** from the edge — the centre of 951 — in both
orientations.
Still confirm in the Xcode 27.1 simulator; read `GeometryProxy.size` or scene
bounds rather than hardcoding any of this.
## Layout tokens (pt)
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.
### Physical
| | 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.
## Hinge callbacks — exact shapes
All iOS 27.1 (beta at time of writing), verified against Apple's symbol docs.
### SwiftUI
```swift
nonisolated func onHingeChange(
isEnabled: Bool = true,
_ action: @escaping (DeviceHingeContext, DeviceHingeContext) -> Void
) -> some View
```
| 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.
### UIKit
```swift
init(updateHandler: (UIHingeInteraction, UIHingeInteraction.Update) -> Void)
var isEnabled: Bool
```
| 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.
### Rules Apple states in the reference
- **Status is decided by the system from angle *and* device orientation.** Don't
re-derive it from angle thresholds — there is no documented "closed below N°".
- **Update rate and precision are system policy** and can change with system
state. Don't assume a frequency; interpolate or animate towards the latest
value rather than treating each callback as a frame.
- **If you only need closed / partial / open, use `status`, not `angle`.**
- The **angle range isn't documented.** Don't assume flat is exactly 180° — treat
`.fullyOpen` as your upper bound and normalise against it.
### A reusable normaliser
```swift
/// 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) }
```
The UIKit equivalent reads `update.hinge?.angle` (already radians) and must add a
`case .unknown:` that leaves the last value untouched.
## Pose → pattern
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:
```swift
GeometryReader { proxy in
let fold = proxy.reservedRegions(kind: .division).first { $0.isActive }
let isTabletop = fold.map { $0.frame.width > $0.frame.height } ?? false
// …
}
```
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.
### The five patterns that work
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.
### Six rules that come from all four vendors
1. **Mask media, split controls.** Photos and video may run across the fold.
Buttons, text and inputs never sit on it. The only exception is a drag in
progress — a dragged card may cross the fold while the finger is down.
2. **Don't stretch.** Each half is phone-shaped. Doubling font size or button
width across 2670 px is the canonical failure. Use an even column count so
nothing straddles the centre.
3. **Tabletop: content up, controls down.** The raised half is for looking, the
flat half is for touching. Nothing interactive near the fold.
4. **Tools go right** (or bottom) of the canvas — thumb side for most users, and
where iOS 27.1 already puts the vertical bar.
5. **Two screens = compare.** If the app has any before/after or variant choice,
the fold is the natural divider; a single-screen slider is no longer needed.
6. **State survives the fold.** Opening and closing resizes the scene; it isn't
torn down. Scroll position, in-progress text and the current step live in
the model, not in view `@State` that a layout swap discards. Dialogs stay
within one half.
## Hinge-driven effects
The hinge is for **effects and interactions**, never layout. Effects that earn
their place:
- **Hinge as a handle.** Map normalised progress to a reveal — a door swinging
open onto a scene, a cover lifting, a camera pushing in. The user's hand *is*
the animation. Keep it an overlay under a second long, never blocking input,
and on `.fullyOpen` finish the transition and remove the overlay.
- **Close to save.** On `.closed`, persist and show a short confirmation on the
outer display. Pairs naturally with the reveal running in reverse.
- **Parallax pitch.** Combine CoreMotion tilt (yaw/roll) with hinge progress
(pitch) to move 2.5D layers — a depth map split into 3–4 layers is enough.
Clamp amplitude to avoid motion sickness.
- **Continuous controls.** Anything a lever or whammy bar could drive.
Always:
- Guard `nil` hinge (most devices) and give those users the end state.
- Reset in the `else` branch when leaving `.partiallyOpen`, so effects don't
stick at a stale angle.
- Honour **Reduce Motion** — swap the reveal for a crossfade.
- Pass `isEnabled: false` when the effect isn't on screen.
## Purchase and paywall screens
- **Sheets avoid the fold** on their own, so a sheet paywall lands in one half.
To span both panes, present a **full-screen cover** with your own two-column
layout: value proposition (ideally the user's *own* result, animated) in the
left pane, plans and the purchase button entirely in the right pane.
- **Price, button and legal copy stay together in one pane.** Never split the
price from the button across the fold, and never run the button through it.
- On the **outer display**, fall back to one column; `SubscriptionStoreView`
handles trial badges and renewal wording for you there.
- App Review 3.1.2 still governs: the billed amount is the most prominent
element, trial wording is secondary, the renewal period is explicit, restore
purchases is reachable, and the close button is visible immediately.
- **Folding is not a dismissal.** Don't treat a close/open during a paywall as
"user declined" for exit-offer logic; the scene was only resized.
- Tabletop-triggered paywalls should keep the tabletop shape: preview up top,
plans on the flat half.
## Fold-aware onboarding
1. **Never require opening the device.** Invite it — "open to step inside" —
and continue on the outer display after a few seconds if the user doesn't.
People hold Duo closed a lot.
2. **Opening at any step expands that step** into two panes. It never restarts
the flow. This is the state-survives-the-fold rule applied to onboarding.
3. Reach the "aha" before asking for permissions or money. Permission priming
screens go directly before the system prompt, at the moment of need, with a
"not now".
4. Ask for notifications *after* the paywall, attached to a concrete promise
(for example, "we'll remind you before your trial ends").
## Test matrix
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.