SwiftUI · Part 2 of 2

iPhone Duo on an iPad - Part 2: The OS and Mail

Rebuilding the iPhone Duo’s inner display as a small OS inside an iPad app, then building Mail on it: native-size layouts scaled once, tokens from the Figma kit, apps that zoom out of their icons, a split view that changes posture on tilt, one compose sheet for four actions, and delete with Undo.

6 October 2026 · 26 min read

Part 1 made a flat iPad screen fold. On its own that’s a trick. To read as a device, everything around it has to read as iPhone Duo system UI and never as an iPad app in a window: the status bar, the way apps open, the controls.

So the project became a small OS. The iPad is only the host display. It boots into a Duo home screen and apps open from their icons. An iPad has one screen, so this is the Duo’s inner display only; the outer display needs a second screen to simulate properly, and it’s left out.

This part builds the frame everything hangs on, then the first real app on top of it: Mail.

The AI way

Then Mail, as the first app on that frame:

How the prompts came together

The OS was built the same way, in Claude Code. What moved it forward was handing over references, the HIG and the Figma kit, instead of describing them. Mail was the hardest stretch: matching a Figma frame by prompting alone is slow, and writing the interaction design down first is what turned it around. The prompts that changed direction:

  • “This is what we are trying to emulate: Apple’s Designing for iPhone Duo. We don’t have an official emulator yet and we have an iPad, so make the logic work for our use case.”
  • “Is there any way we can get the spec for iOS 27 from the Figma file?”
  • “We need to make the designs at iPhone Duo size and then scale them to the iPad.”
  • “We’re emulating a device that hasn’t launched, as a mini OS inside the iPad. Make a note that everything we build from now on follows the iPad Duo Mini OS.”
  • “Anywhere else we show the status bar, it should be a live clock.”
  • “There’s a click angle of the fold, from 180 to about 170, where it goes 50/50. Otherwise it stays the way we have it.”
  • “We need a boolean for the switch: tilt right to go 50/50 and tilt left to go back to the default screen.”
  • “Now I want more interaction. Write the design for the taps first: compose, reply, reply all, forward and delete.”
  • “The blur will happen, and then the layout shift will happen.”

The hard way

Or build it yourself. This is everything the prompt asks for, one piece at a time, and why each piece is there.

What we’re building

  • Layouts authored at the Duo’s native size and scaled onto the iPad once
  • A design-token enum taken straight from the Figma kit, so nothing is guessed off a screenshot
  • An iPad with every trace of iPadOS hidden
  • One live status bar shared by every screen
  • A shell that opens apps by zooming them out of their icons
  • A layout primitive shaped like Apple’s ArrangementView, so it can be swapped for the real thing later
  • Mail on that primitive: two postures switched by tilting, with the frost from part 1 hiding the change
  • A vertical rail of controls that press in and bounce
  • One compose sheet for Compose, Reply, Reply All and Forward, and a keyboard that’s an ordinary SwiftUI view
  • Delete with no confirmation, and an Undo

Build at native size, scale once

The Duo’s inner display, in landscape, has a live area of 951 × 669 points. The iPad Pro 11″ is 1194 × 834. The temptation is to lay everything out in iPad points. Don’t: every value from the Figma kit is in Duo points, and converting each one by hand is where drift creeps in.

Instead, every component is authored at native Duo size and scaled as a whole, by one uniform factor fitted to the iPad’s height: k = 834 / 669 ≈ 1.2466. The aspect ratio is preserved, never stretched. The scaled display comes out at 1185.5 wide, about 8 points short of the iPad, so it sits centred with a 4.25 point offset.

The view is laid out at its native frame first, then scaled, then given the screen’s frame. Because the scale is applied last, the component never knows about the iPad. You perfect it at native size, and the iPad is a detail of the parent.

Tokens from the kit

Early screens were matched against screenshots, and every one was slightly off. The fix was to pull values from the iOS 27 Figma kit directly and keep them in one enum, DS. Colours, type sizes, the vertical control bar’s module and the layout margins all live there, each with the Figma node it came from. The control bar is built from 48-point items in an 84-point band, with a 6-point inset and 2 points between items. Content sits 20 points from the edges, and the camera reserves an 84 × 119 region at the top of the trailing edge.

Type gets one extra guard. The kit only ever uses the standard iOS text sizes, so anything measured off a mockup is snapped to the nearest one. The app can’t carry a 16.5 point label by accident.

Hide the iPad

Every full-screen view carries the same three modifiers. ignoresSafeArea lets the canvas reach the edges, statusBarHidden removes the iPad’s clock and battery, and persistentSystemOverlays(.hidden) removes the home indicator and the window resize handle iPadOS 26 adds.

Orientation needs one more step. The app is landscape-only, but on iPadOS 27 that setting is ignored unless the app also declares UIRequiresFullScreen in its Info.plist. Without it the app rotates to portrait and crops the canvas. The key is deprecated and Xcode warns about it; it’s still the switch that works.

One status bar, everywhere

The Duo keeps its status bar in the reserved region at the top of the trailing edge, next to the camera: a clock over a Wi-Fi glyph. It’s one component, used by the home screen and every app, so the time is the same live clock everywhere instead of a real clock in one place and a frozen 9:41 in another. It’s a 48 × 86 cluster: the time, refreshed every second, over a 42-point Wi-Fi glyph.

The optional fixed time is what makes pixel comparison possible: freeze it at 9:41 and the component can be diffed against the Figma export.

Apps that open out of their icons

DuoRootView is the shell. It draws the folding home screen and, when an icon is tapped, mounts the app on top. The open mimics iOS: the app grows out of its icon’s exact frame as an opaque tile, its corner radius morphing from the icon’s squircle to the screen’s corner, while the home dims, blurs and scales back behind it.

Each app knows where its icon sits on the home grid, in design coordinates, so every launch starts from the right place.

The zoom

The zoom is a single ViewModifier driven by one progress value, 0 at the icon and 1 at full screen. The detail that makes it look right is the corner. The clip is applied before the scale, so the radius is divided by the scale to keep the visible corner at the icon’s 0.2237 squircle ratio as it grows.

Opening in two steps

Opening and animating are split into two steps. Tapping the icon only sets which app is open, which mounts it at progress 0, sitting exactly on the icon. Its onAppear then springs progress to 1, with a response of 0.4 and damping of 0.9, while the home behind scales back 5%, blurs by 4 points and dims by 22%. Closing runs the spring back to 0 and unmounts the app in the completion handler.

The tile is opaque the whole way. A cross-fade would let the home screen ghost through the app as it grows, which is the one thing that gives away that it isn’t the real OS.

The app template

On the Duo, an app’s controls live in a vertical bar down the trailing edge: the status cluster in the reserved region at the top, a navigation capsule, a tab capsule, and a detached search button. DuoAppTemplate is that frame, built at 951 × 669 from the kit, in Liquid Glass. Every Duo app starts from it.

To check it, the template has a pixel-compare harness: the template at exactly 1:1 on a magenta key, with the clock frozen at 9:41 and the glass swapped for Figma’s flat composited tones. A screenshot is cropped to the key and diffed against the Figma export. The last run was a mean difference of 0.21 out of 255, with 0.15% of pixels off by more than 24.

A layout primitive you can swap out

Apps on the Duo rearrange as it folds. Apple’s ArrangementView does this from the system’s fold region, which never activates on an iPad. DuoArrangement takes an explicit fold value instead, 0 flat to 1 folded, driven by the gyro or a control. Its API mirrors the real one on purpose, primary and secondary views and a style, so on real hardware it can be replaced without touching the code around it.

It only does layout. The crease, the frost and the scrim are layered on by the app, which keeps the primitive reusable. Mail is the first app built on it.

Mail

Mail is the app that shows why the Duo exists. Flat, it’s an inbox beside a message. Tilt it into a partial fold and the two panes even out on the crease, like an open book.

Before writing any of it, the interactions were written down as a short design doc: what each tap does, which sheet it opens, how long the Undo lasts. Most of the decisions below come from that doc, and it’s in the repo next to the code.

Two panes on the arrangement

Mail is the list as the primary view and the message as the secondary, in a split DuoArrangement. The flat ratio is measured from the Figma frame, where the divider sits at 399.74 of 1194 points. In a partial fold the arrangement eases it to 50/50.

Content that scrolls, the rows and the message body, reflows to its pane’s width instead of sliding sideways, as Apple’s guidance asks. The control rail stays pinned to the screen’s trailing edge whatever the panes do, and a soft dark band rides on the divider to mark the crease as it moves.

Tilt is a switch, not a dial

The first version followed the tilt continuously, and the inbox swam under your hands as you held the iPad. Reading was impossible. So tilt became a switch: tilting right past 10° sets the partial fold, tilting left past 10° sets the flat layout, and level keeps whichever was set last. The layout never follows the tilt.

Hiding the switch in the frost

A layout that jumps looks broken, though. The switch is hidden behind the frost from part 1, in three beats: the left half frosts over completely in 0.25 seconds, the layout shifts underneath on a spring (response 0.5, damping 0.85), then the frost clears over 0.45 seconds after a 0.3-second pause.

The frost still tracks the tilt continuously, it’s only the layout that snaps. That frost lives in its own small view, MailFoldFrost, which reads the motion manager itself. Only that view re-renders 60 times a second with the gyro, not the whole of Mail.

The frost shows whichever is heavier, the tilt’s or the switch’s, so the switch can push it to full even when the iPad is only slightly tilted.

A rail of real buttons

The trailing rail holds Compose as a detached glass circle, then a capsule of actions: Reply, Reply All, Forward, Delete and Move. Each one is a real button with two kinds of feedback. Pressing sinks it to 0.92 with a soft highlight behind the glyph. Releasing fires the action and bounces the glyph once.

The bounce needs a value that changes on every tap, so each symbol keeps a counter in a dictionary. The hit area is the whole cell, not just the glyph, which matters on a rail of 22 point icons.

One sheet for four actions

Compose, Reply, Reply All and Forward all open the same sheet. Only the prefill and the starting focus differ. The kind of compose decides both, and whether the keyboard comes up straight away: Compose and Forward start by typing a recipient, so it does; replies open for reading first.

The sheet follows the fold

The sheet also respects the fold. Flat, it’s a centred card, the kit’s large detent. In a partial fold it moves to the leading half and stops at the crease, which leaves the original message readable on the other half while you reply. Both frames come from the kit in native Duo points, scaled by the same k as everything else: 653 × 587 flat, and 468 × 587 against the crease when folded.

A keyboard that’s just a view

The iPad’s own keyboard would be the biggest giveaway of all. So the Duo keyboard is drawn: DuoKeyboard is a SwiftUI view built at its native 951 × 275 from the kit, and it doesn’t edit text itself. It reports keys, and the draft model decides what they mean.

Keeping the rules in the model is what lets the card and the keyboard be separate views, with their own frames and entrance timing. The keyboard rises with the card, 0.05 seconds behind it. Recipients become tokens on Return, and Delete on an empty To field removes the last token, as it does on iOS.

Closing and Undo Send

Closing checks the draft against how it opened. Unchanged, the sheet closes straight away. Edited, the close button offers Delete Draft or Save Draft. Send closes the sheet and shows an “Undo Send” capsule for 10 seconds, the iOS default; Undo reopens the sheet with the message as it was sent.

Delete without asking

Delete moves mail to the Trash, which can be recovered, so there’s no confirmation. It acts at once and offers Undo instead, in a capsule that stays for 4 seconds. Three things happen together: the message scales down into the trash button and fades over 0.35 seconds, the row collapses and the rows below slide up, and the next email is selected. Swapping to the next email happens while the reading pane is invisible, so you never see the old message replaced.

binningItem keeps the deleted message drawn after it has left the inbox, so it has something to animate into the bin. Undo is the same thing reversed: the row goes back at its old index, it’s selected, and the message zooms back out of the trash button.

Dev controls that never ship

The Simulator has no gyroscope and no hinge, so Mail has a tilt slider for it: drag it left or right and Mail reacts as it would to the gyro, and let go and it springs back to level. There’s also a button that lays the Figma frame over the screen at 50% for pixel alignment. Both are wrapped so they only exist in Simulator builds.

On a real iPad the mini OS has no trace of them. Tuning the feel still happens on the device, but laying things out against the kit happens at the desk.

Takeaways

  • Author at the target device’s native size and scale once. The host display is the parent’s problem.
  • Keep tokens in one place, sourced from the kit with the node they came from. Snap type to the standard scale.
  • Hide every trace of the host: status bar, home indicator, resize handle, and the wrong orientation.
  • Make shared chrome, like the status bar, one component with a frozen-time mode for comparison.
  • Keep transitions opaque. A cross-fade is what gives an emulation away.
  • Shape your stand-ins like the real APIs, so they can be swapped out on real hardware.
  • Write the interactions down before building them. Each tap, sheet and timing in one short doc saves rebuilding.
  • When a continuous input makes content unreadable, turn it into a switch, and hide the switch behind an effect.
  • Isolate whatever updates at 60 Hz in its own small view.
  • One sheet with different prefills beats four sheets, and keeping text rules in a model lets the keyboard and the card animate independently.
  • Recoverable actions don’t need a confirmation. Act, then offer Undo.

The full project, Mail, Notes and the rest of the mini OS, is open source under the MIT license, with the interaction doc in docs/Mail-Interactions.md. The icons, wallpaper and sample photo are placeholders that resemble Apple artwork; swap in your own before doing anything beyond experimenting.