My journaling app, Lanternly, has two shells. On iPhone there is a tab bar at the bottom; on iPad, a sidebar built on NavigationSplitView. One line chose between them: if UIDevice.current.userInterfaceIdiom == .pad. On iPhone Duo that line is the first thing to break. Duo is an iPhone, its idiom is .phone on both displays, and the 7.6-inch inner display would get a stretched phone tab bar.
This article shows how I moved the app to iPhone Duo with SwiftUI: the shell is chosen by window width, and the open section survives folding and rotation. Everything was checked in Xcode 27.1 RC on the Duo simulator running iOS 27.1. I covered the big picture in the iPhone Duo developer guide; this one is the practice of a single app.
Why the idiom no longer answers "phone or tablet"#
Duo has two displays. The outer one behaves like a regular iPhone: compact × regular in portrait. The inner one is regular × regular in any orientation. The idiom stays the same on both. So the question "which shell do we show" cannot be asked of the device. It has to be asked of the window: how much horizontal space is there right now.
That is exactly what horizontal size class in SwiftUI gives you, through @Environment(\.horizontalSizeClass). It changes when the device folds, rotates or enters Split View, and SwiftUI redraws whatever depends on it.
The shell rule for iPhone Duo in SwiftUI#
I put the decision into one type so it would not spread across screens:
import SwiftUI
enum ShellLayout {
static func usesSplit(horizontalSizeClass: UserInterfaceSizeClass?) -> Bool {
UIDevice.current.userInterfaceIdiom == .pad || horizontalSizeClass == .regular
}
}A wide window (the Duo inner display, an iPhone Pro Max in landscape) gets the sidebar. A narrow one (the Duo outer display, the narrow half of Split View) gets the tab bar.
The idiom survives in exactly one place, as an exception for iPad. Before Duo, iPad always lived in the sidebar, including narrow Split View, where NavigationSplitView collapses into a stack by itself. Switching iPad to a tab bar whenever its window shrinks would change behaviour that already works, for the sake of a device it has nothing to do with. So the rule reads: "iPad, or a wide window".
On the Mac the question never comes up. The app has its own shell there, MacRootView, with a sidebar and a navigation stack per section, so the whole shell choice sits behind #if !os(macOS). You can make a Mac window as narrow as you like and it will never get a tab bar. That is deliberate: a tab bar at the bottom of a Mac window would look foreign.
The app root now reads the size class from the environment:
struct RootView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var shellSelection = ShellSelection()
var body: some View {
Group {
if ShellLayout.usesSplit(horizontalSizeClass: horizontalSizeClass) {
PadRootView() // NavigationSplitView with a sidebar
} else {
MainTabView() // tab bar
}
}
.environment(shellSelection)
}
}The main side effect: when the device folds, one shell is removed and the other is built from scratch. Everything that lived in the first shell's @State is gone. Someone who was in Calendar lands on Today after unfolding.
Keeping the open section through a fold#
Each shell keeps its own selection, and their types differ: the tab bar has four tabs, the sidebar has nine sections. The only shared thing is "what is open right now", stored as a string:
import Observation
@Observable
final class ShellSelection {
var section: String?
}nil means "no shell has been shown yet". That matters: on first launch the new shell starts from its own default and does not override debug launch flags or deep links from widgets.
Each shell writes to this object when the section changes and reads from it when it appears:
struct MainTabView: View {
@Environment(ShellSelection.self) private var shell: ShellSelection?
@State private var selection: AppTab = .today
var body: some View {
content
.onAppear(perform: restoreSection)
.onChange(of: selection) { _, new in shell?.section = new.rawValue }
}
private func restoreSection() {
guard let raw = shell?.section else { return }
// Sidebar sections such as Journals do not exist in the tab bar: fall back to Today.
selection = AppTab(rawValue: raw) ?? .today
}
}AppTab and the sidebar sections share raw values (today, entries, calendar, luna), so the string carries over without a mapping table. The environment value is optional (ShellSelection?), which keeps screen previews working without extra setup.
Why not @SceneStorage? It also keeps the section between launches. The app would then open on the last section instead of Today, which changes how the product behaves. I only needed memory for the lifetime of the scene.
The navigation bar follows the shell, not the device#
A second idiom check was hiding inside screens. Today, Calendar and Chat hide the system navigation bar on iPhone because they draw their own header. On iPad the bar is needed: it holds the button that collapses the sidebar. It used to be decided like this:
.toolbar(UIDevice.current.userInterfaceIdiom == .pad ? .automatic : .hidden, for: .navigationBar)On the Duo inner display such a screen ends up inside the sidebar shell but thinks it is on a phone and hides the button. The right question is "which shell am I shown in". A dedicated environment key answers it, and the sidebar shell sets it:
private struct SplitShellKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var isSplitShell: Bool {
get { self[SplitShellKey.self] }
set { self[SplitShellKey.self] = newValue }
}
}
// In PadRootView:
NavigationSplitView { sidebar } detail: {
detail(for: selection)
.environment(\.isSplitShell, true)
}
// In a screen:
@Environment(\.isSplitShell) private var isSplitShell
// ...
.toolbar(isSplitShell ? .automatic : .hidden, for: .navigationBar)After that the code has a single idiom check left, inside ShellLayout. Every other decision depends on the window or the shell.
Testing the shell switch hands-free#
An iPhone Pro Max is a handy way to test switching on the fly: in landscape its width is regular too. You cannot rotate a simulator from the command line, so I added a debug flag that asks the scene to rotate through requestGeometryUpdate:
#if DEBUG
static func applyOrientationFlag() {
let args = CommandLine.arguments
guard let i = args.firstIndex(of: "-orientation"), i + 1 < args.count else { return }
for (step, name) in args[i + 1].split(separator: ",").enumerated() {
let mask: UIInterfaceOrientationMask = name == "portrait" ? .portrait : .landscapeRight
DispatchQueue.main.asyncAfter(deadline: .now() + Double(step + 1) * 4) {
for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
}
}
}
}
#endifLaunching with -orientation landscape,portrait rotates the screen after 4 and after 8 seconds. In my run, Entries went through "tab bar → sidebar → tab bar" and stayed selected. On iPad in iPadOS 27 windowed mode the system ignores this request, but the shell is not supposed to change there anyway.
Folding and unfolding Duo cannot be triggered from the command line; those are DeviceHub buttons. How I dealt with that is in the article on testing iPhone Duo in the simulator.
What works and what is left#
On the Duo simulator with iOS 27.1 I checked folding and unfolding on the fly in both directions, rotating both displays, and the book posture. The open section is kept. On iPhone 18 Pro Max the shell switches on rotation; on iPad Pro 13 nothing changed.
What I did not test live is iPhone Duo split view with two apps. Half of the inner display counts as a narrow window, so by the rule it gets the tab bar, the same path as the outer display. But that is a conclusion, not a test: I could not perform the drag-from-Dock gesture in the simulator with a mouse.
The shell was only half the work. The other half showed up on the screens themselves: the tab bar under the vertical system strip, hints that landed on the fold, a chat field across the hinge. Those are covered in a separate article on iPhone Duo adaptation problems.



