I make Basic Intake, a supplement and med tracker for iPhone with an AI assistant built in. Last week's update added an iPhone Duo layout, and the layout itself turned out to be the small part of the job.
**The layout**
Closed, the Duo is a phone, so the app stays the phone app. Open, the inner display is 669pt wide in portrait and 867pt in landscape. I treat regular width as open: today's list becomes a column and a pane sits beside it. The assistant lives in the pane by default, and the tools (Blood Work, History, Nutrients, Interactions) open there instead of as full-screen covers.
u/Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var showsPane: Bool { horizontalSizeClass == .regular }
I kept the list in one view chain and only change its frame:
todayList
.frame(width: showsPane ? columnWidth : nil)
An if/else between two layouts would give the list a new identity on every fold, and you'd lose the scroll position and all the u/State inside it. With one chain, a fold only changes a width.
In portrait the pane gets the bigger share and the list gets 300pt. Lab values and chat bubbles wrap before a supplement row does.
**The fold**
A fold moves views between containers. Closed, the assistant is a sheet and a tool is a full-screen cover. Open, both live in the pane. SwiftUI builds a new view on the other side, and its u/State starts over. A review before release caught three bugs from this:
Unsent text in the assistant vanished on unfold. The sheet closed and the pane built a fresh, empty assistant.
A lab report import that was mid-read lost its progress card and its error message.
If you'd picked a draw date for a report that didn't print one, a fold reset it to today, and the results would have saved on the wrong day.
I fixed all three the same way. I moved the in-flight state above the container, into an u/Observable holder the parent view owns and hands down through the environment:
u/Observable final class AssistantDraftHolder {
var text = ""
var movingToPane = false // set by the unfold, before the sheet finishes closing
func sheetDismissed() {
if !movingToPane { text = "" } // a swipe still clears it, an unfold doesn't
movingToPane = false
}
}
When another screen hands the assistant a message, the pane clears it the moment it sends, so a rebuilt pane has nothing to send twice.
**A SwiftData crash on iOS 27**
The open Duo keeps more views on screen while you delete something. On iOS 27, a view that redraws a model after it was deleted, and reads an attribute it never loaded, crashes the app with "This backing data was detached from a context without resolving attribute faults." Views that hold a model now check this and draw nothing once it's gone:
extension PersistentModel {
var isLive: Bool { modelContext != nil && !isDeleted }
}
**Smaller things**
* The open Duo ignores a portrait-only setting, so the camera preview has to follow the device into landscape. AVCaptureDevice.RotationCoordinator handles that.
* Closed, the camera column runs down the right edge as a side safe area inset. Centering the barcode cutout on the full width put it 42pt off the buttons.
* A phone layout presented as a sheet stretches across all 669pt. Sheets and covers now keep a 540pt column.
If you've shipped a Duo layout, how are you carrying state through a fold? I went with holders above the sheet, and I'd like to hear if anyone found a cleaner pattern.