<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>heynavid: iPhone Duo</title>
    <link>https://heynavid.com/duo/</link>
    <atom:link href="https://heynavid.com/duo/rss.xml" rel="self" type="application/rss+xml" />
    <description>Building apps for iPhone Duo: the hinge, the fold, the outer screen and the simulator, from an indie iOS developer.</description>
    <language>en</language>
    <lastBuildDate>Mon, 28 Sep 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Designing Fold Cue around an outer screen I can't test yet</title>
      <link>https://heynavid.com/blog/designing-for-an-outer-screen-i-cant-test/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/designing-for-an-outer-screen-i-cant-test/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>foldcue</category>
      <category>iphone-duo</category>
      <category>swiftui</category>
      <description>Fold Cue's best feature on iPhone Duo is the script on the outer screen, beside the camera. The system decides when that screen is mine, and I won't hold a Duo until it goes on sale. So the app is designed to be fine without it.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/designing-for-an-outer-screen-i-cant-test-hero.jpg" alt="" /></p><p>The reason Fold Cue exists is one pose. Stand iPhone Duo up like a book with the backs towards you, and the outer screen sits right beside the main camera. Put the script there and you read straight into the lens, while the best camera on the phone films you and whoever is holding it sees a director’s view on the inside.</p>
<p>It’s also the one part of the app I can’t test. Apps only get the outer screen through a camera capture accessory, the system decides whether to show it, and the simulator has no camera. I wrote about <a href="https://heynavid.com/blog/iphone-duo-outer-screen-camera-only/">that rule</a> when I first read the SDK. This post is about what it did to the design once I was building on it, with a device I won’t hold until 23 October.</p>
<p>Everything below is implemented and seen working in Apple’s iPhone Duo simulator, in the flat and closed poses. None of it has run on a real Duo yet.</p>
<h2 id="the-outer-screen-is-an-enhancement">The outer screen is an enhancement</h2>
<p>The accessory is only a few lines of SwiftUI, attached to the capture screen and nowhere else:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#24292E">content.</span><span style="color:#005CC5">sceneAccessory</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#005CC5">    CameraCaptureAccessory</span><span style="color:#24292E">(</span><span style="color:#005CC5">isEnabled</span><span style="color:#24292E">: $isEnabled) {</span></span>
<span class="line"><span style="color:#005CC5">        OuterPrompterView</span><span style="color:#24292E">(</span><span style="color:#005CC5">model</span><span style="color:#24292E">: model)</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">    .</span><span style="color:#005CC5">onAvailabilityChange</span><span style="color:#24292E"> { available </span><span style="color:#D73A49">in</span></span>
<span class="line"><span style="color:#005CC5">        Task</span><span style="color:#24292E"> { </span><span style="color:#D73A49">@MainActor</span><span style="color:#D73A49"> in</span><span style="color:#24292E"> model.accessoryAvailable </span><span style="color:#D73A49">=</span><span style="color:#24292E"> available }</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>The interesting line is the last one. The system can decline to show the accessory, or withdraw it mid-take, and the app finds out through that callback. So the design question was never “how does the outer screen look”. It was “what happens to a take when the outer screen goes away”.</p>
<p>The answer is that nothing happens to the take. Recording never depends on the accessory. The inner screen always carries a copy of the script, and when the outer one isn’t showing it, that copy grows to fill its side and becomes the prompter:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">/// The outer screen isn't showing the script, so the mirror grows.</span></span>
<span class="line"><span style="color:#D73A49">private</span><span style="color:#D73A49"> var</span><span style="color:#24292E"> fallbackActive: </span><span style="color:#005CC5">Bool</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#24292E">    model.isLive </span><span style="color:#D73A49">&#x26;&#x26;</span><span style="color:#24292E"> (</span><span style="color:#D73A49">!</span><span style="color:#24292E">model.accessoryAvailable </span><span style="color:#D73A49">||</span><span style="color:#D73A49"> !</span><span style="color:#24292E">settings.outerScriptOn)</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Everything you need mid-take is on the inner screen too, for the same reason. Apple’s guidance for accessory content is minimal interaction, and the person reading is standing a little way back anyway, so the outer screen has the script, a status line and at most two big buttons. You can’t drag the text with a finger out there, but a Bluetooth keyboard or a page-turner still moves it.</p>
<h2 id="one-model-for-every-screen">One model for every screen</h2>
<p>The outer prompter, the inner director’s view and the plain prompter on every other iPhone all read the same <code>@Observable</code> capture model. Apple’s article on the accessory recommends sharing the model rather than passing messages between scenes, and it made the fallback almost free: when the accessory disappears there’s no state to hand over, because there was only ever one copy of it.</p>
<p>That model is a state machine that every screen draws from: ready, checking the microphone, counting down, rolling, paused, saved, interrupted. “Hold on” pauses the script, never the camera.</p>
<h2 id="two-layouts-chosen-by-who-is-looking">Two layouts, chosen by who is looking</h2>
<p>The inner screen has two jobs, and which one depends on which camera is recording.</p>
<p>With the main camera, someone else is usually holding the phone, or it’s standing on a table facing you. You read the outer screen, so the inner screen belongs to the operator: the framing on one side, and the script, the sound level, the takes and the record button on the other. That’s the pattern Apple shows in its own camera talk.</p>
<p>With the inner front camera, you’re filming yourself on the big screen. Now the inner screen is for you, so its right-hand page becomes one large page of script with the reading line just under the lens.</p>
<p>The layout is chosen by the camera, not by the device. On any other iPhone the same views collapse into a front-camera prompter with the script just under the camera.</p>
<h2 id="size-classes-nearly-identify-the-duo">Size classes nearly identify the Duo</h2>
<p>My first rule was “regular width means the Duo’s inner screen”. It isn’t true. A large iPhone in landscape is regular width too, so on my own phone every rotation switched to the director layout and the rear camera, and each switch reconfigured the camera. The screen froze while it did.</p>
<p>The fix was to require regular width and regular height, which among iPhones only the Duo’s inner screen has. The Duo-only APIs sit behind <code>if #available(iOS 27.1, *)</code>, and there’s no device-model check anywhere. It’s a small thing, but it’s the difference between an app that adapts and one that guesses.</p>
<h2 id="the-fold-is-a-region-and-sometimes-it-isnt-there">The fold is a region, and sometimes it isn’t there</h2>
<p>The fold comes from the runtime, as a reserved region of kind <code>.division</code>. I expected to keep content off it all the time. Then the simulator reported it as inactive whenever the Duo was flat, and that turns out to be the point: flat, the inner screen is one continuous display, and there’s nothing to avoid. The fold only matters while the device is folded.</p>
<p>So content steps off the hinge only while that region is active. <code>reservedRegions(kind:)</code> returns only active regions unless you ask for inactive ones too, so the loop doesn’t need its own check:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#6A737D">// Active divisions only: the fold matters while the Duo is folded.</span></span>
<span class="line"><span style="color:#6A737D">// Flat, it's one continuous screen and reports the division inactive.</span></span>
<span class="line"><span style="color:#D73A49">for</span><span style="color:#24292E"> d </span><span style="color:#D73A49">in</span><span style="color:#24292E"> geo.</span><span style="color:#005CC5">reservedRegions</span><span style="color:#24292E">(</span><span style="color:#005CC5">kind</span><span style="color:#24292E">: .division).</span><span style="color:#005CC5">map</span><span style="color:#24292E">(\.frame)</span></span>
<span class="line"><span style="color:#D73A49">where</span><span style="color:#24292E"> d.maxX </span><span style="color:#D73A49">></span><span style="color:#005CC5"> 0</span><span style="color:#D73A49"> &#x26;&#x26;</span><span style="color:#24292E"> d.minX </span><span style="color:#D73A49">&#x3C;</span><span style="color:#24292E"> width {</span></span>
<span class="line"><span style="color:#D73A49">    if</span><span style="color:#24292E"> d.midX </span><span style="color:#D73A49">&#x3C;</span><span style="color:#24292E"> width </span><span style="color:#D73A49">/</span><span style="color:#005CC5"> 2</span><span style="color:#24292E"> { insets.leading </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> max</span><span style="color:#24292E">(insets.leading, d.maxX </span><span style="color:#D73A49">+</span><span style="color:#24292E"> spacing) }</span></span>
<span class="line"><span style="color:#D73A49">    else</span><span style="color:#24292E"> { insets.trailing </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> max</span><span style="color:#24292E">(insets.trailing, width </span><span style="color:#D73A49">-</span><span style="color:#24292E"> d.minX </span><span style="color:#D73A49">+</span><span style="color:#24292E"> spacing) }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>Two more surprises from the same place. <code>ArrangementView</code> splits the safe width, not the screen at the fold, so its two panes don’t necessarily meet at the hinge. I stopped trying to force a split ratio to make them. And the inner camera sits under the display, so it only occludes the screen while it’s recording, and the simulator always reports it inactive. Only the reading layout clears the lens, because only that layout is used while the inner camera films, and it clears it even when the region says it’s inactive.</p>
<p>A debug launch argument outlines every reserved region on screen, in one colour for the fold and another for the cameras, with whether each is active. It was the quickest way to stop guessing where the hardware was.</p>
<h2 id="system-containers-everywhere-else">System containers everywhere else</h2>
<p>Outside the capture screen, Fold Cue uses the containers Apple already adapted to the fold. Scripts, takes and settings are ordinary split views that collapse to stacks on a phone. Onboarding is an <code>ArrangementView</code> spread. The take review uses plain stacks, because an <code>ArrangementView</code> inside a list or a scroll view is exactly what the documentation tells you not to do. Sheets stay system sheets.</p>
<p>I’d planned custom layouts for several of these screens. Each time, the system container already did the right thing on the fold, and my version would have been one more thing to verify on hardware I don’t have.</p>
<h2 id="a-stand-guide-that-says-its-a-guess">A stand guide that says it’s a guess</h2>
<p>Standing the Duo up only works at some angles. Too closed and it tips; too open and the camera and the outer screen aren’t square to you. So Fold Cue reads the hinge with <code>onHingeChange</code> and suggests a range of angles to stand it at, with “open it a little wider” or “close it a little” until you’re there.</p>
<p>That range is a starting point, and the code says so. I’ll only know the real one when I can stand a real Duo on a real desk. It’s also set-up help and nothing more: no part of recording depends on the hinge.</p>
<h2 id="what-the-simulator-can-and-cant-tell-you">What the simulator can and can’t tell you</h2>
<p>The iPhone Duo simulator is good for layout and useless for the camera. The pose can only be set by hand in Device Hub, and only the awake display renders. It has no camera and no speech model, so the capture screen falls back to steady scroll with a message saying why. That fallback earned its keep in testing long before any user sees it.</p>
<p>To get useful screenshots anyway, debug builds take launch arguments that put the app straight into a state: the capture screen mid-take, paused, counting down, the reading layout, seeded takes, the reserved-region outlines. Combined with the simulator’s screenshot command for each display, that was enough to review every screen on both displays without being able to tap either. Several real bugs turned up that way, like text running under the inner camera and controls overlapping the script.</p>
<p>The list of things that need a real Duo is short and specific: whether the accessory shows reliably when the phone is standing, the director’s view with an actual camera image in it, which rear camera really faces you, and the stand angle.</p>
<p>Designing the fallback first felt like pessimism at the time. It’s the reason I’m not worried about the list.</p>]]></content:encoded>
    </item>
    <item>
      <title>What Fold Cue would never cut, and what it isn't trying to be</title>
      <link>https://heynavid.com/blog/what-fold-cue-would-never-cut/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/what-fold-cue-would-never-cut/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>foldcue</category>
      <category>iphone-duo</category>
      <category>indie</category>
      <category>behind-the-scenes</category>
      <description>Fold Cue was planned in a hurry, for a phone that isn't out yet. The most useful part of the plan wasn't the feature list. It was deciding in advance what would never be traded away when time ran short, and what the app wasn't going to try to be.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/what-fold-cue-would-never-cut-hero.jpg" alt="" /></p><p>Fold Cue went from an idea to a working build very quickly, for a phone that goes on sale on 23 October. Plans written in a hurry tend to be feature lists. This one worked better than most of mine, and looking back, the reason is that the most important sections weren’t about features at all.</p>
<h2 id="research-before-code">Research before code</h2>
<p>Before any app code there was a clickable design prototype, a pile of notes from reading reviews of teleprompter apps, and a small spike to find out whether on-device speech could keep up with a reader at all.</p>
<p>The reviews were the most useful of those. People didn’t ask for more features. They asked for the features they had to stop failing quietly: text that stalls with no explanation, scrolling that drifts over a long session, a take lost to a phone call, captions that need fixing word by word, and a subscription for something they use now and then. That list became the product.</p>
<p>The spike came next, because if speech couldn’t follow a messy reading on the device, there was no app. It could, well enough to build on, and its logs became the engine’s first tests. I wrote about that part in <a href="https://heynavid.com/blog/following-a-voice-through-a-script/">Following a voice through a script, on the device</a>.</p>
<h2 id="the-engine-first-in-its-own-package">The engine first, in its own package</h2>
<p>The first real code was a small Swift package for the engine, with no UIKit and no AVFoundation. The follower, the command detector, the captions and the scoring are the part that makes Fold Cue good or bad, and they’re also the part that’s easiest to test away from a phone.</p>
<p>Doing it in that order meant the hardest logic had tests before there was an app to put it in, and every later change to the app sat on something that had already been checked.</p>
<h2 id="what-would-never-be-cut">What would never be cut</h2>
<p>Every plan like this has a list of what to drop when you’re behind. The more useful list is the opposite one: what doesn’t get dropped, however late it is. For Fold Cue it’s short.</p>
<p><strong>Voice-follow quality.</strong> It’s the reason to choose this app over a free one. A teleprompter that follows badly is worse than one that scrolls at a fixed pace, because at least the fixed one is predictable. If time ran short, features would go before the follower got less careful.</p>
<p><strong>Takes that survive.</strong> A take is someone’s effort, and often their nerve. Crash-safe recording, recovery on the next launch and saving cleanly on a phone call aren’t polish. Losing a take is the one failure a user never forgives.</p>
<p><strong>The fallback for the outer screen.</strong> The outer-screen prompter is the headline feature on iPhone Duo, and it’s the one the system is allowed to withdraw. The inner screen always carries the script, so a take never depends on it. I wrote about designing around that in <a href="https://heynavid.com/blog/designing-for-an-outer-screen-i-cant-test/">Designing Fold Cue around an outer screen I can’t test yet</a>.</p>
<p><strong>The mic check.</strong> Before each take, the countdown waits until it can actually hear you. It’s a small feature and a boring one, and it exists because a take recorded with a muted microphone looks fine until you play it back.</p>
<p>Writing these down early made later decisions easy. Whenever something had to give, the question was only whether it touched something on that list.</p>
<h2 id="free-is-the-whole-app">Free is the whole app</h2>
<p>The line between free and Pro was decided before the paywall existed, and it’s a simple one. Free is everything you need to record a script: voice follow, steady scroll, recording, spoken commands, rehearsing, the full iPhone Duo setup, and export to Photos with no watermark and no time limit. It isn’t a trial.</p>
<p>Pro is the studio tools on top: captions from your script, spoken commands trimmed out, take scoring to pick the best one, higher-resolution recording, script sync, and Final Cut Pro projects. It’s one purchase that’s yours to keep. There’s no subscription and no ads.</p>
<p>The rule that matters most is written into the project’s own notes: never move a free feature behind the paywall later. Plenty of apps start generous and tighten, and the reviews show how people feel about it.</p>
<h2 id="what-it-isnt-trying-to-be">What it isn’t trying to be</h2>
<p>The plan also had a list of non-goals for the first version, and I’ve found that list as valuable as the goals. For now, Fold Cue doesn’t write your script for you. It doesn’t fake eye contact, generate an avatar, float over other apps, stream live or replace your background. None of those are bad ideas, and some might suit it one day. For a first version they’d have been a different app, and every one of them would have taken time from the things above.</p>
<p>Saying no in writing, before anyone asks, is much easier than saying no to a feature that’s half built.</p>
<h2 id="designing-for-a-device-you-cant-hold">Designing for a device you can’t hold</h2>
<p>The last part of the plan was the humblest: a list of what can’t be known without a real iPhone Duo. Whether the system shows the outer screen reliably while the phone is standing. The angle it stands steadily at. How the script reads from a little way back, at a slight angle. How well its microphones hear you from across a room.</p>
<p>Most of them have a place in the app where the answer plugs in, and a sensible default until then. The stand guide says its range is a starting point, and the reading line and the text size can move once I’ve seen them from across a room. The microphones are the exception: if speech struggles at a distance, that will mean new code, not a new setting. The fallback doesn’t care whether the outer screen appears. The first day with a Duo is already written down as a test plan, so it can be spent measuring rather than wondering what to measure.</p>
<p>There’s a version of this app that waited for the hardware before designing anything. It would have been more certain, and it would have missed the launch. Planning around what I didn’t know turned out to be most of the plan.</p>]]></content:encoded>
    </item>
    <item>
      <title>I'm building apps for a phone I haven't held yet</title>
      <link>https://heynavid.com/blog/building-for-a-phone-i-havent-held/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/building-for-a-phone-i-havent-held/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>indie</category>
      <category>behind-the-scenes</category>
      <description>iPhone Duo isn't in anyone's hands yet, and I'm already building for it. Why now, what makes an app a Duo app rather than a big phone app, and the kinds of app I've decided not to build.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/building-for-a-phone-i-havent-held-hero.jpg" alt="" /></p><p>Apple announced iPhone Duo in September, and the simulator arrived in the Xcode 27.1 beta a week or so later. Almost nobody outside Apple has used one yet. I’ve decided to spend the next few months building for it anyway.</p>
<p>This is the reasoning, written down before the device exists, so I can check it against reality later.</p>
<h2 id="why-now">Why now</h2>
<p>A new kind of iPhone is the rare moment when the App Store has more room in it than apps. People who buy a folding phone go looking for things that show off the fold, and on day one there aren’t many. Apple’s editors are likely to look for the same thing. The window is short, because the big apps catch up, and after that a new app is just another app.</p>
<p>There’s also a quieter reason. A new platform is where an indie developer’s lack of a marketing budget matters least. Nobody has an audience for iPhone Duo apps yet, including me.</p>
<h2 id="a-bigger-screen-isnt-the-point">A bigger screen isn’t the point</h2>
<p>The first thing I learned in the simulator is how much an app gets for free if it uses the standard system components. Rebuild with the current SDK and the app fills the inner display. System toolbars and tab bars move to the side edge in most poses. Sheets and alerts step around the fold on their own.</p>
<p>That’s good for users and bad for anyone hoping “it works on the Duo” is a selling point. If an app only looks good on the bigger screen, the competitor that rebuilt last Tuesday looks just as good.</p>
<p>So I started asking a narrower question: what can an app do on this phone that it can’t do on any other iPhone? The answer is shorter than I expected.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/free-vs-duo.svg" alt="After a rebuild every app fills the inner screen, gets bars on the side edge and has sheets that avoid the fold. Only the Duo has the hinge as input, the book and tabletop poses, and the outer screen while filming." loading="lazy" width="600" height="420"><figcaption>The left side comes free with a rebuild. The right side is where a Duo app has to earn its place.</figcaption></figure>
<h2 id="the-parts-only-this-phone-has">The parts only this phone has</h2>
<p><strong>The hinge.</strong> Apps can read whether the phone is closed, partly open or fully open, and a live angle in between. Apple suggests using it for interactions and effects, not layout.</p>
<p><strong>The poses.</strong> Partly folded, the fold becomes a region the system reserves. That gives you a book with a spine, or a tabletop with something to look at above the fold and something to touch below it.</p>
<p><strong>The outer screen, while the camera runs.</strong> This one has a rule attached. An app can offer interactive content for the outer screen, facing whoever is being filmed, and the system shows it only while the app is using the camera. That single rule decides a lot about which ideas are possible.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/poses.svg" alt="Closed: the outer screen in one hand. Flat: one big screen or a shared board. Book: two pages with the fold as a spine, seen from above. Tabletop: a view above the fold and controls below. Tent: standing on its edges." loading="lazy" width="600" height="500"><figcaption>The poses, seen from the side. The dot is the hinge.</figcaption></figure>
<p>Everything I’m building starts from one of those three. If an idea would be just as good on a flat phone, I drop it, however much I like it.</p>
<h2 id="what-im-not-building">What I’m not building</h2>
<p>Some ideas die because Apple got there first. Apple’s own camera already shows the person being photographed a live preview on the outer screen. FaceTime already lets a second person join from the outer screen. StandBy already runs on either screen. Building a smaller version of a system feature is a way to lose slowly.</p>
<p>Other ideas die because of the outer screen rule. Face-to-face translation sounds perfect for a phone with a screen on each side, and I’d love to build it. But it isn’t a camera app, so the outer screen isn’t available to it. The same goes for a customer-facing total at a market stall, or a second player’s private screen in a game that doesn’t use the camera.</p>
<p>And some die because the only thing they add is size. A recipe app on the inner screen is a nicer recipe app. It isn’t a Duo app.</p>
<h2 id="what-that-leaves">What that leaves</h2>
<p>What’s left is small and specific: apps where opening, closing or standing the phone is part of how you use it. ComicFlow is first, because a comic already is a spread with a gutter down the middle, and the fold lands exactly where the gutter goes. The others I’ll name when they’re close to shipping.</p>
<p>I’m also keeping a <a href="https://heynavid.com/duo/developers/">developer reference</a> with everything I check about the device, and a <a href="https://heynavid.com/duo/apps/">directory</a> of apps for the Duo. Both exist because I needed them myself and couldn’t find them.</p>
<p>The strange part is designing physical interactions for a device I can’t touch. The simulator has a slider for the hinge, and a slider is not a hinge. Some of this will be wrong the first time I hold one, and I’d rather find out which parts on launch day than a year later.</p>]]></content:encoded>
    </item>
    <item>
      <title>The lines that made ComicFlow assume every iPhone was the same shape</title>
      <link>https://heynavid.com/blog/comicflow-assumed-every-iphone-was-the-same-shape/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/comicflow-assumed-every-iphone-was-the-same-shape/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>comicflow</category>
      <category>ios</category>
      <description>Running ComicFlow in the iPhone Duo simulator showed stretched layouts, phone-sized covers and a reader that decided on spreads by orientation. The Duo exposed them, but several of the bugs were already shipping on iPad.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/comicflow-assumed-every-iphone-was-the-same-shape-hero.jpg" alt="" /></p><p>The first time I ran ComicFlow in the iPhone Duo simulator, it mostly worked. The tab bar moved to the side of the screen on its own, because the app uses a standard tab view. The What’s New sheet presented as a card instead of stretching. Nothing crashed.</p>
<p>It also looked like a phone app that had been pulled wider. The empty library’s import button stretched from one edge of the inner display to the other, with everything crowded into the top of the screen. Once I loaded some comics, the Continue Reading card did the same, and the library grid kept the covers and columns I’d picked for a phone, with the extra width spent on gaps. When I went looking for why, I found the same mistake in several places, and it wasn’t a Duo mistake.</p>
<h2 id="deciding-by-device-instead-of-by-space">Deciding by device instead of by space</h2>
<p>Apple’s guidance for the Duo says to stop making layout decisions from three things: the screen, the device idiom and the orientation. ComicFlow used all three.</p>
<p><strong>The idiom.</strong> The Continue Reading card had a width cap, but only on iPad. In shape, it was this:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#24292E">.</span><span style="color:#005CC5">frame</span><span style="color:#24292E">(</span><span style="color:#005CC5">maxWidth</span><span style="color:#24292E">: UIDevice.current.userInterfaceIdiom </span><span style="color:#D73A49">==</span><span style="color:#24292E"> .pad </span><span style="color:#D73A49">?</span><span style="color:#24292E"> cardCap </span><span style="color:#D73A49">:</span><span style="color:#24292E"> .</span><span style="color:#005CC5">infinity</span><span style="color:#24292E">)</span></span></code></pre>
<p>iPhone Duo reports the phone idiom, so on its inner display the card got no cap at all. The library grid had the same shape of bug in a different place. The grid picked its cover size and its column count from the idiom, so the Duo’s inner display got the phone’s covers and the phone’s columns.</p>
<p><strong>The orientation.</strong> The reader’s automatic page layout showed a two-page spread when the device was in landscape and a setting was on:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">case</span><span style="color:#24292E"> .auto</span><span style="color:#D73A49">:</span><span style="color:#D73A49"> return</span><span style="color:#24292E"> isLandscape </span><span style="color:#D73A49">&#x26;&#x26;</span><span style="color:#24292E"> settings.doublePagerInLandscape</span></span></code></pre>
<p>That reads fine until you think about windows. An iPad in portrait has plenty of room for two pages and got one. An iPad in landscape running ComicFlow in a narrow Split View window had room for one and got two. Both were shipping on iPad.</p>
<p><strong>The screen.</strong> Tap zones, the page size in vertical mode, and the size hint for decoding pages all came from <code>UIScreen.main.bounds</code>. That’s the whole screen, not the window ComicFlow is drawn in, so tap zones and vertical page sizing were wrong in Split View and Slide Over, and pages were decoded larger than they needed to be.</p>
<h2 id="measuring-instead">Measuring instead</h2>
<p>The fixes are mostly small.</p>
<p>The card now has a plain width cap with no device check. The grid counts its columns from the width it actually has. The reader shows a spread when there’s room for two readable pages, whatever the orientation, so half an iPad gets one page and a wide window gets two. The setting that tied spreads to landscape is gone, because nothing in the interface could change it anyway. Tap zones are now a fraction of the reader’s own width.</p>
<p>I also added a unit test that fails if <code>UIScreen.main</code>, the device idiom or an orientation check appears anywhere in the app’s sources. It’s a blunt rule, and it’s the one I kept breaking.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/comicflow-before-after.svg" alt="Before: the Continue Reading card stretches across the whole inner display and the covers stay at phone size. After: the card is capped and the grid counts its columns from the width it has, so the covers fill the screen." loading="lazy" width="600" height="659"><figcaption>The library on the inner display. Before, the card stretched and the covers stayed phone-sized. After, the card is capped and the grid fills the width it has.</figcaption></figure>
<h2 id="then-the-fold">Then the fold</h2>
<p>With the inputs fixed, the Duo-specific work turned out to be small, because the reader already knew how to show spreads.</p>
<p>In book pose, the reader shows a two-page spread with the gutter on the crease, so no page straddles the fold. A comic is already laid out as facing pages with a gutter between them, and the fold lands exactly where the gutter should be. PDFs get an approximation: a gap the width of the fold in the middle of the pair, which lands on the crease when the fold is centred in the window.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/comicflow-spread.svg" alt="In book pose ComicFlow shows two facing pages, one on each half of the inner display, with the gutter between them lying exactly on the fold." loading="lazy" width="600" height="454"><figcaption>Book pose: one page per half, with the gutter on the fold.</figcaption></figure>
<p>Tabletop pose is designed but not yet seen working. The plan is the page above the fold and the reading controls in the half below it: the scrubber, the page indicator and a strip of thumbnails, with no forced spread, because the crease runs across the page rather than down the middle. The code is there. What I don’t have yet is a look at it on screen, because rotating the simulator into that pose has to be done by hand in Device Hub, and I haven’t done it yet. I’d rather not describe something as working until I’ve watched it work.</p>
<p>The reader finds the fold from the region the system reserves for it, not from the hinge angle. I left the hinge out entirely. A page that reflows while you adjust your grip is worse than one that stays put, and zoom now only resets when the fold appears or disappears, not every time the angle moves.</p>
<p>The book pose and layout work is in ComicFlow 3.5, coming around the iPhone Duo launch. It has been checked in the simulator only, since nobody has the hardware yet.</p>
<h2 id="what-the-duo-was-really-testing">What the Duo was really testing</h2>
<p>The three inputs looked right on every device I tested on. Every iPhone was one shape per orientation, and every iPad was bigger than every iPhone. But iPad windows could already be any size when I wrote that code, and I had never tried ComicFlow in one. A phone that can be as wide as a small tablet only made the mistake impossible to miss.</p>
<p>The Duo didn’t introduce new bugs so much as remove the last device that was hiding them. The iPad bugs had been there for a while. It took a phone that unfolds for me to go and look.</p>]]></content:encoded>
    </item>
    <item>
      <title>The iPhone Duo's inner screen is drawn larger than its panel</title>
      <link>https://heynavid.com/blog/iphone-duo-inner-screen-drawn-larger/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/iphone-duo-inner-screen-drawn-larger/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>ios</category>
      <category>swiftui</category>
      <description>The simulator, App Store Connect and Apple's spec sheet give different pixel sizes for the iPhone Duo's inner display. None of them is wrong. The simplest explanation is that the system renders larger than the panel and scales down, which is one more reason to lay out in points.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/iphone-duo-inner-screen-drawn-larger-hero.jpg" alt="" /></p><p>When I first booted the iPhone Duo simulator, I read the display sizes straight out of the runtime and wrote them in my notes as settled. The inner display’s pixel size didn’t match what the press had been reporting, so I wrote down that the press figure was wrong.</p>
<p>It wasn’t. The press figure came from Apple’s own spec sheet. I had found two correct numbers and assumed one of them had to be a mistake.</p>
<h2 id="three-sources-two-answers">Three sources, two answers</h2>
<p>There are three places a developer would look for the size of the inner display:</p>
<ul>
<li><strong>The simulator</strong>, which reports the size in points and a scale factor.</li>
<li><strong>App Store Connect’s screenshot specifications</strong>, which list the pixel size for Duo screenshots.</li>
<li><strong>Apple’s technical specifications page</strong>, which lists the panel’s resolution and pixel density.</li>
</ul>
<p>The first two agree with each other. The spec sheet lists a smaller resolution than both of them, and a lower pixel density than the outer display.</p>
<p>The outer display has no such gap. Its size in points times its scale factor lands exactly on the panel resolution Apple lists. Only the inner one differs. The exact figures are in the <a href="https://heynavid.com/duo/developers/#displays">developer reference</a>.</p>
<h2 id="what-is-probably-going-on">What is probably going on</h2>
<p>The inner display appears to render at its size in points times its scale factor, then get scaled down onto a panel with fewer physical pixels. iPhone 6 Plus did the same thing years ago: the system drew a frame larger than the screen and downsampled it, so apps could work at a clean scale factor while the hardware used a panel that didn’t divide evenly.</p>
<p>I should be clear that this is my reading of the numbers. Apple doesn’t say it anywhere I’ve found. But it’s the simplest explanation that makes all three sources right at once, and it fits how the outer display behaves.</p>
<p>The simulator’s own display profile points the same way. It describes the inner screen at the same density as the outer one, and scaling that down to the density on Apple’s spec sheet lands almost exactly on the panel’s resolution.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/render-then-scale.svg" alt="On the inner display the system appears to draw a frame larger than the physical panel and scale it down to fit. On the outer display the drawn frame and the panel are the same size." loading="lazy" width="600" height="330"><figcaption>The inner display appears to draw a frame larger than its panel and scale it down. The outer display draws exactly its panel.</figcaption></figure>
<h2 id="why-it-doesnt-change-anything-in-your-code">Why it doesn’t change anything in your code</h2>
<p>If your app lays out in points, none of this reaches you. SwiftUI and Auto Layout work in points, the simulator reports points, and the scale-down happens after your frame is finished. A layout that looks right in the simulator will look the same size on the panel, just drawn with slightly fewer physical pixels.</p>
<p>Where it can reach you:</p>
<p><strong>Pixel-exact artwork.</strong> A hairline one rendered pixel wide lands on slightly less than one physical pixel, and it may shimmer. Prefer strokes that survive scaling.</p>
<p><strong>Anything that reads the screen.</strong> Code that asks <code>UIScreen.main</code> for its size is building on the wrong foundation anyway. It has been deprecated since iOS 26, and on a device with two displays it’s ambiguous which screen you’d even get. If you render pixel-exact content with Metal, read the native scale from your view’s own screen, the way Apple advised for iPhone 6 Plus, rather than from <code>UIScreen.main</code>. Read sizes from the scene or the view you’re drawing in.</p>
<p><strong>Screenshots.</strong> App Store Connect wants the rendered size, not the panel size. The simulator’s screenshots already come out at the rendered size, so capture from there.</p>
<h2 id="points-were-always-the-contract">Points were always the contract</h2>
<p>The iPhone has had enough screen sizes that most of us stopped thinking in pixels years ago. The Duo is the first device in a while where that habit is doing real work, and where the spec sheet and the developer tools can disagree without either of them being wrong.</p>
<p>I’d rather have found this by reading more carefully than by correcting my own note. Two numbers that don’t match are usually two numbers measuring different things, and this time they were.</p>]]></content:encoded>
    </item>
    <item>
      <title>On iPhone Duo, the outer screen belongs to apps using the camera</title>
      <link>https://heynavid.com/blog/iphone-duo-outer-screen-camera-only/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/iphone-duo-outer-screen-camera-only/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>ios</category>
      <category>swiftui</category>
      <description>Third-party apps can show interactive content on the iPhone Duo's outer screen while they run on the inner one, but only through a camera capture accessory. That one rule decides which Duo ideas are possible, and it closed off a few I wanted to build.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/iphone-duo-outer-screen-camera-only-hero.jpg" alt="" /></p><p>A phone with a screen on each side invites a particular kind of idea. You look at the inner screen, someone across the table looks at the outer one, and each of you sees something different. My list of iPhone Duo ideas was full of them before I read the documentation properly.</p>
<p>Then I read it properly.</p>
<h2 id="the-one-way-in">The one way in</h2>
<p>When an app is open on the inner display, there is today one supported way for it to put anything on the outer display at the same time: a scene accessory registered for camera capture. In SwiftUI it’s a view modifier:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#005CC5">CameraScreen</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#24292E">    .</span><span style="color:#005CC5">sceneAccessory</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#005CC5">        CameraCaptureAccessory</span><span style="color:#24292E">(</span><span style="color:#005CC5">isEnabled</span><span style="color:#24292E">: $showsScript) {</span></span>
<span class="line"><span style="color:#005CC5">            ScriptView</span><span style="color:#24292E">(</span><span style="color:#005CC5">script</span><span style="color:#24292E">: script)</span></span>
<span class="line"><span style="color:#24292E">        }</span></span>
<span class="line"><span style="color:#24292E">        .</span><span style="color:#005CC5">onAvailabilityChange</span><span style="color:#24292E"> { available </span><span style="color:#D73A49">in</span></span>
<span class="line"><span style="color:#24292E">            accessoryAvailable </span><span style="color:#D73A49">=</span><span style="color:#24292E"> available</span></span>
<span class="line"><span style="color:#24292E">        }</span></span>
<span class="line"><span style="color:#24292E">    }</span></span></code></pre>
<p>In UIKit, a view controller registers <code>UISceneAccessory.cameraCapture(sceneConfiguration:)</code> with <code>registerSceneAccessory(_:)</code> and gets a registration back that reports whether the accessory is available and enabled. The system gives the resulting scene a dedicated session role, so the app can tell that scene apart from its main one.</p>
<p>The content can be interactive. Someone in front of the lens can tap it.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/outer-rule.svg" alt="You see the app on the inner screen. The person in front of the camera sees the accessory on the outer screen. It can only appear when the app is in the foreground, a capture session with a visible preview is running, the device is open and the app is full screen on the inner display, and even then the system decides." loading="lazy" width="600" height="560"><figcaption>You see the app on the inner screen. The person being filmed sees the accessory on the outer screen, and only when every condition holds.</figcaption></figure>
<h2 id="the-conditions">The conditions</h2>
<p>The accessory can only appear when all of these are true: the app is in the foreground, it has an active camera capture session, the device is open, and the app’s interface is full screen on the inner display, with the view that registered the accessory on screen. That full screen condition means Split View is out. Apple’s overview describes the case as the device fully open and capturing with the rear camera.</p>
<p>Even then, the system decides whether and where to show it. Apple’s own framing is that an accessory enhances the app when it’s available and the app must work fully without it. I’m treating it the way I’d treat a feature that might be switched off by the user: build the whole app first, then let the accessory make it better.</p>
<h2 id="why-a-camera-specifically">Why a camera, specifically</h2>
<p>I wondered whether this was a first version that would open up later, so I looked for anything Apple had said beyond the documentation. On the developer forums, an Apple engineer answered it directly. The camera capture accessory is the only way to use both displays at the same time, and camera cases are “the only supported and intended use cases for dual display at present”. A capture session with only an input isn’t supported either, because it doesn’t actually run the camera: you have to show a camera preview on one or both displays. That “at present” leaves the question open, which is the most I can say about the future.</p>
<p>That last part closes the obvious workaround. You can’t start a camera session just to unlock the outer screen for something else. The preview has to be real and on screen, and the camera has to be the point.</p>
<h2 id="what-it-rules-out">What it rules out</h2>
<p>Face-to-face translation was the idea I most wanted. Each person reads the other’s words on the screen facing them. It needs the outer screen and has no reason to use a camera, so it can’t be built this way.</p>
<p>The same goes for a customer-facing total on a phone used as a till, a private hand for the second player in a card game, and a presenter’s notes on one side with slides on the other. All of them are natural on a two-screen phone, and none of them are camera apps.</p>
<p>Apple’s own dual-screen features follow the same shape. Duo Preview shows the person being photographed a live preview on the outer screen, Kid Cue plays animations there to catch a child’s attention, and Duo FaceTime lets a second person join from the outer screen. All of them are camera experiences.</p>
<h2 id="what-it-makes-possible">What it makes possible</h2>
<p>Flip the rule around and it describes a very specific opportunity: apps where the person in front of the camera needs to see or touch something.</p>
<p>A creator filming themselves with the rear camera can read a script from the screen right next to the lens. Someone being interviewed can see the current question. A person doing a workout can see their count and form cues while the camera watches them. A party game can film one player’s reaction while showing them the challenge.</p>
<p>Those are all camera apps first, and the outer screen makes each of them better. That’s the shape Apple is asking for, and it’s narrower than the space of ideas a two-screen phone suggests.</p>
<h2 id="testing-it">Testing it</h2>
<p>The simulator has no camera, so presenting the accessory can’t be tried there, although its layout can be checked in previews and in the simulator. I’ve been building the accessory content as ordinary SwiftUI views that I can preview and test on their own, and keeping the camera side separate. The part where the system decides when to show the accessory will only be answerable on a real device.</p>
<p>I lost a few ideas to this rule and gained a clearer sense of what the outer screen is for. It isn’t a second display. It’s the screen that faces whoever the camera is looking at, and the apps that fit it are the ones where that person matters.</p>]]></content:encoded>
    </item>
    <item>
      <title>Reading the hinge on iPhone Duo, and designing around what Apple won't promise</title>
      <link>https://heynavid.com/blog/reading-the-iphone-duo-hinge/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/reading-the-iphone-duo-hinge/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>ios</category>
      <category>swiftui</category>
      <description>iOS 27.1 gives every app the iPhone Duo's hinge status and a live angle, with no entitlement. The API is small. The interesting part is the one promise Apple leaves out, and how to design an interaction that doesn't need it.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/reading-the-iphone-duo-hinge-hero.jpg" alt="" /></p><p>The hinge was the first thing I looked for in the iOS 27.1 SDK, because it’s the one part of iPhone Duo no other iPhone has. I half expected it to be private, or behind an entitlement, or only exposed as a layout change.</p>
<p>It’s none of those. Any app can read it, in SwiftUI or UIKit, and the headers are short enough to read in one sitting.</p>
<h2 id="swiftui">SwiftUI</h2>
<p>The modifier is <code>onHingeChange</code>. It hands you the old and the new context, and the context holds an optional hinge:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">struct</span><span style="color:#6F42C1"> PageView</span><span style="color:#24292E">: </span><span style="color:#6F42C1">View </span><span style="color:#24292E">{</span></span>
<span class="line"><span style="color:#D73A49">    @State</span><span style="color:#D73A49"> private</span><span style="color:#D73A49"> var</span><span style="color:#24292E"> isPartlyOpen </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> false</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> body: </span><span style="color:#D73A49">some</span><span style="color:#24292E"> View {</span></span>
<span class="line"><span style="color:#005CC5">        Content</span><span style="color:#24292E">(</span><span style="color:#005CC5">isPartlyOpen</span><span style="color:#24292E">: isPartlyOpen)</span></span>
<span class="line"><span style="color:#24292E">            .</span><span style="color:#005CC5">onHingeChange</span><span style="color:#24292E"> { </span><span style="color:#005CC5">_</span><span style="color:#24292E">, new </span><span style="color:#D73A49">in</span></span>
<span class="line"><span style="color:#D73A49">                guard</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> hinge </span><span style="color:#D73A49">=</span><span style="color:#24292E"> new.hinge </span><span style="color:#D73A49">else</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#6A737D">                    // No hinge on this device.</span></span>
<span class="line"><span style="color:#24292E">                    isPartlyOpen </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> false</span></span>
<span class="line"><span style="color:#D73A49">                    return</span></span>
<span class="line"><span style="color:#24292E">                }</span></span>
<span class="line"><span style="color:#24292E">                isPartlyOpen </span><span style="color:#D73A49">=</span><span style="color:#24292E"> hinge.status </span><span style="color:#D73A49">==</span><span style="color:#24292E"> .partiallyOpen</span></span>
<span class="line"><span style="color:#24292E">            }</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p><code>DeviceHinge</code> has a <code>status</code> and an <code>angle</code>, and the angle is a SwiftUI <code>Angle</code>. A <code>nil</code> hinge means the device doesn’t have one, which is what every other iPhone reports.</p>
<p>One small surprise: <code>DeviceHinge.Status</code> is a struct with static members, not an enum. You compare against <code>.closed</code>, <code>.partiallyOpen</code> and <code>.fullyOpen</code>, but a <code>switch</code> over it needs a <code>default</code> case.</p>
<h2 id="uikit">UIKit</h2>
<p>UIKit uses an interaction, the same pattern as pointer and drag interactions:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">let</span><span style="color:#24292E"> interaction </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> UIHingeInteraction</span><span style="color:#24292E"> { [</span><span style="color:#D73A49">weak</span><span style="color:#005CC5"> self</span><span style="color:#24292E">] </span><span style="color:#005CC5">_</span><span style="color:#24292E">, update </span><span style="color:#D73A49">in</span></span>
<span class="line"><span style="color:#D73A49">    guard</span><span style="color:#D73A49"> let</span><span style="color:#005CC5"> self</span><span style="color:#D73A49"> else</span><span style="color:#24292E"> { </span><span style="color:#D73A49">return</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">    guard</span><span style="color:#D73A49"> let</span><span style="color:#24292E"> hinge </span><span style="color:#D73A49">=</span><span style="color:#24292E"> update.hinge </span><span style="color:#D73A49">else</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#005CC5">        self</span><span style="color:#24292E">.</span><span style="color:#005CC5">hingeBecameUnavailable</span><span style="color:#24292E">()</span></span>
<span class="line"><span style="color:#D73A49">        return</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#005CC5">    self</span><span style="color:#24292E">.</span><span style="color:#005CC5">apply</span><span style="color:#24292E">(</span><span style="color:#005CC5">status</span><span style="color:#24292E">: hinge.status, </span><span style="color:#005CC5">angle</span><span style="color:#24292E">: hinge.angle)</span></span>
<span class="line"><span style="color:#24292E">}</span></span>
<span class="line"><span style="color:#24292E">view.</span><span style="color:#005CC5">addInteraction</span><span style="color:#24292E">(interaction)</span></span></code></pre>
<p>The handler fires once with the current state and again on each update. It also fires when the view moves between hierarchies, and the hinge is <code>nil</code> when the view has left a hierarchy that provides hinge updates. The UIKit status has an extra <code>.unknown</code> case the SwiftUI one doesn’t.</p>
<p>Two details that bit me on first read. The UIKit angle is a <code>CGFloat</code> in radians, while the SwiftUI angle is an <code>Angle</code>, so shared code needs to pick one. And a disabled interaction doesn’t queue updates: turn it back on and you get the current state once, not the history you missed.</p>
<p>There’s nothing in Core Motion, Game Controller or Metal. If a game wants the hinge, it reads it through UIKit or SwiftUI like everyone else.</p>
<h2 id="the-promise-apple-leaves-out">The promise Apple leaves out</h2>
<p>This is the line in the header I keep coming back to. On UIKit’s <code>UIHinge.angle</code>, Apple writes that the rate and granularity of updates are system policy and can change with system state, so you shouldn’t depend on a particular update frequency or precision. The SwiftUI documentation doesn’t repeat it, but I’d assume the same policy applies. If you only need to know whether the hinge is closed, partly open or fully open, use <code>status</code>.</p>
<p>That’s an unusually direct warning. It means an interaction that needs a smooth, fine-grained stream of angles might work on one day and feel rough on another, depending on things the app can’t see. I can’t test how rough in the simulator, because there I’m driving the hinge myself, so it can’t tell me what update rate real hardware delivers.</p>
<p>So I’ve started designing hinge interactions in three tiers.</p>
<p><strong>Status first.</strong> Anything that can be expressed as closed, partly open or fully open should be. The system decides the status from the angle and the device’s orientation, so you inherit Apple’s judgement about where the boundaries are.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/hinge-status.svg" alt="One half of the device lies flat while the other rotates from closed to fully open. Near closed the status is closed, across the middle it is partially open, and at the end it is fully open. The continuous angle is measured between the halves. The system decides the boundaries." loading="lazy" width="600" height="420"><figcaption>Status is coarse and dependable. The angle underneath it is continuous, and its update rate is up to the system.</figcaption></figure>
<p><strong>Thresholds second.</strong> Some interactions need a boundary Apple doesn’t define, like “this half has been lifted far enough”. Those use two thresholds rather than one, so a hinge hovering near the boundary doesn’t flicker:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="swift"><code><span class="line"><span style="color:#D73A49">struct</span><span style="color:#6F42C1"> LiftDetector</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> showBelow: Angle</span></span>
<span class="line"><span style="color:#D73A49">    var</span><span style="color:#24292E"> hideAbove: Angle</span></span>
<span class="line"><span style="color:#D73A49">    private</span><span style="color:#24292E">(</span><span style="color:#D73A49">set</span><span style="color:#24292E">) </span><span style="color:#D73A49">var</span><span style="color:#24292E"> isLifted </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> false</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D73A49">    mutating</span><span style="color:#D73A49"> func</span><span style="color:#6F42C1"> update</span><span style="color:#24292E">(</span><span style="color:#6F42C1">with</span><span style="color:#24292E"> hinge: DeviceHinge) </span><span style="color:#D73A49">-></span><span style="color:#005CC5"> Bool</span><span style="color:#24292E"> {</span></span>
<span class="line"><span style="color:#D73A49">        if</span><span style="color:#D73A49"> !</span><span style="color:#24292E">isLifted, hinge.angle </span><span style="color:#D73A49">&#x3C;</span><span style="color:#24292E"> showBelow { isLifted </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> true</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">        if</span><span style="color:#24292E"> isLifted, hinge.angle </span><span style="color:#D73A49">></span><span style="color:#24292E"> hideAbove { isLifted </span><span style="color:#D73A49">=</span><span style="color:#005CC5"> false</span><span style="color:#24292E"> }</span></span>
<span class="line"><span style="color:#D73A49">        return</span><span style="color:#24292E"> isLifted</span></span>
<span class="line"><span style="color:#24292E">    }</span></span>
<span class="line"><span style="color:#24292E">}</span></span></code></pre>
<p>A threshold only needs the angle to cross a line eventually. It doesn’t care how many updates arrived on the way.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/hysteresis.svg" alt="As the hinge angle falls past the show threshold the content appears, and it only hides again once the angle rises back past the higher hide threshold, so small wobbles near either line change nothing." loading="lazy" width="600" height="360"><figcaption>Two thresholds: content appears once the angle falls past one line, and hides only after it rises back past the other.</figcaption></figure>
<p><strong>Continuous last.</strong> Interactions that follow the angle directly, like a bellows or a lever, are the most fun and the most exposed to that warning. I’m building them, but with smoothing between updates, and with the expectation that they’ll need tuning the day I hold real hardware.</p>
<h2 id="dont-lay-out-with-it">Don’t lay out with it</h2>
<p>The other thing Apple is clear about is that the hinge isn’t a layout tool. When the device is partly open, the fold becomes a reserved region of the view, and that’s what layout should read. ComicFlow’s reader finds the fold that way and never looks at the angle at all.</p>
<p>It took me a while to see why that split matters. Layout that follows the angle reflows every time someone adjusts their grip. Layout that follows the fold region changes once, when the fold appears, and then leaves the content alone.</p>
<p>The hinge ended up being less of a sensor than I imagined and more of a switch with a dial attached. The switch is dependable. The dial is a bonus, and I’m designing as if it might not always be there.</p>]]></content:encoded>
    </item>
    <item>
      <title>Setting up the iPhone Duo simulator, and the things that tripped me up</title>
      <link>https://heynavid.com/blog/setting-up-the-iphone-duo-simulator/</link>
      <guid isPermaLink="true">https://heynavid.com/blog/setting-up-the-iphone-duo-simulator/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>Navid</dc:creator>
      <category>iphone-duo</category>
      <category>ios</category>
      <category>tooling</category>
      <description>The iPhone Duo simulator ships in the Xcode 27.1 beta. Getting it running took one surprise from xip, a renamed Simulator app, a permission error on my external drive, a noisy first boot, and screenshots that came out black.</description>
      <content:encoded><![CDATA[<p><img src="https://heynavid.com/blog/images/setting-up-the-iphone-duo-simulator-hero.jpg" alt="" /></p><p>The iPhone Duo simulator arrived in the Xcode 27.1 beta, and setting it up should have been a short afternoon. It was, eventually, but most of the time went on things that weren’t mentioned anywhere I looked. These are those things, in the order they happened.</p>
<h2 id="xip-replaced-my-xcode">xip replaced my Xcode</h2>
<p>I downloaded the beta as a <code>.xip</code> and expanded it next to my existing Xcode. I assumed it would appear as <code>Xcode-beta.app</code>, because the file was called <code>Xcode_27.1_beta.xip</code>.</p>
<p>It didn’t. With an <code>Xcode.app</code> already in Applications, <code>xip</code> installed the beta as <code>Xcode.app</code> and quietly renamed my release Xcode to <code>Xcode 1.app</code>. For a few minutes my default toolchain was the beta, and the Mac App Store copy of Xcode was no longer where the App Store expected it.</p>
<p>The fix is just renaming them back: the beta to <code>Xcode-beta.app</code>, the release copy to <code>Xcode.app</code>. After that I keep the release Xcode as the default and opt into the beta per command:</p>
<pre class="astro-code github-light" style="background-color:#fff;color:#24292e; overflow-x: auto;" tabindex="0" data-language="sh"><code><span class="line"><span style="color:#24292E">DEVELOPER_DIR</span><span style="color:#D73A49">=</span><span style="color:#032F62">/Applications/Xcode-beta.app/Contents/Developer</span><span style="color:#6F42C1"> xcodebuild</span><span style="color:#005CC5"> -version</span></span></code></pre>
<p>That way nothing I ship by accident gets built with a beta SDK.</p>
<h2 id="the-simulator-app-is-gone">The Simulator app is gone</h2>
<p>In Xcode 27, the app you open to see simulators is Device Hub. It lives at <code>Contents/Applications/DeviceHub.app</code> inside the Xcode bundle, and the old <code>Simulator.app</code> path doesn’t exist at all, in the release Xcode as well as the beta.</p>
<p>If you have a script or an alias that opens <code>Simulator.app</code> by path, it’s already broken. I found out when my own <code>open -a</code> on that path failed.</p>
<h2 id="keep-simulators-on-the-internal-drive">Keep simulators on the internal drive</h2>
<p>The Duo device type comes with the Xcode beta, but it needs the iOS 27.1 simulator runtime, which is a separate and sizeable download. I keep most of my development files on an external drive, and I’d like simulators to live there too.</p>
<p>On my setup, CoreSimulator gets a permission error writing to the external volume, which comes from macOS privacy controls rather than from Xcode. So the runtime and the simulators stay on the internal drive. Granting CoreSimulator Full Disk Access might get around it, but I haven’t tried. If internal storage is tight, clear space first.</p>
<h2 id="the-first-boot-looks-broken">The first boot looks broken</h2>
<p>The release notes warn that the first Simulator launch can take several minutes. What they don’t say is that the Duo runtime logs a data migration failure on boot, and that right after booting, <code>simctl spawn</code> failed to run a simple command even though the device reported as booted.</p>
<p>I erased the device and booted it again from clean, and got exactly the same messages. SpringBoard came up, apps launched, screenshots worked. As far as I can tell, in this beta the messages are cosmetic. Later in the same session, <code>simctl spawn</code> worked fine for listing processes, reading logs and writing defaults, so the early failure looks like a device that hadn’t finished starting.</p>
<h2 id="two-displays-one-awake">Two displays, one awake</h2>
<p>This is the one that cost me the most time. The Duo simulator has two displays, and <code>simctl io</code> screenshots take a <code>--display</code> argument. It accepts a screen number, a name or a UUID, and the numbers aren’t what you’d guess, because a TV-out screen sits between the two real ones. The device names are the safe choice: <code>--display=primary</code> is the outer display and <code>--display=primary-1</code> is the inner one.</p>
<p>Then I took a batch of screenshots and every one came back as the same black frame. Only the display that’s awake renders. With the device open, the outer display is off. With it closed, the inner one is. I noticed because the files were identical to the byte, and after that I captured both displays every time and kept whichever wasn’t black.</p>
<figure class="duo-fig"><img src="https://heynavid.com/duo/figures/simulator-displays.svg" alt="The inner display is named primary-1 and the outer display is named primary. With the device open only the inner display renders, and with it closed only the outer one does. Capturing the sleeping display returns black." loading="lazy" width="600" height="300"><figcaption>The display names the screenshot command expects, and which one is awake in each state.</figcaption></figure>
<h2 id="poses-are-manual">Poses are manual</h2>
<p>Device Hub has controls to open, close, rotate and fold the device. I found no <code>simctl</code> command for any of it.</p>
<p>Apple gives you no command-line way to set a pose. Third-party tools can set the fold angle through a private simulator interface, and my own test script uses one, but I wouldn’t build anything important on a private interface. Rotating the Duo still needs a hand in Device Hub, so any screenshot or test that depends on the device’s orientation needs a person.</p>
<h2 id="where-that-leaves-the-simulator">Where that leaves the simulator</h2>
<p>Even with all of this, the simulator is good. It has real display geometry, the system behaviour for bars and sheets and the fold region, and a hinge I can drive. What it can’t do is anything with the camera, since there isn’t one, and it can’t tell me how the hinge feels in a hand.</p>
<p>I’ve kept a running list of these details in my <a href="https://heynavid.com/duo/developers/#simulator">iPhone Duo developer reference</a>, so the next time I set up a machine I can skip the afternoon.</p>
<p>The pattern across all of them was the same: each one failed quietly rather than loudly. An installer that renames instead of asking, logs that look fatal and aren’t, a command that fails only in the first moments after boot, screenshots that succeed and contain nothing. The fixes were all easy once I stopped trusting that “no error” meant “worked”.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
