私の日記アプリLanternlyには、2つの構成があります。iPhoneでは画面下にタブバー、iPadではNavigationSplitViewのサイドバーです。どちらを使うかは、if UIDevice.current.userInterfaceIdiom == .padという1行で決めていました。iPhone Duoで最初に壊れるのがこの1行です。DuoはiPhoneなので、2つの画面どちらでもidiomは.phoneになり、7.6インチの内側の画面に引き伸ばされたタブバーが表示されてしまいます。
この記事では、SwiftUIで書いたアプリをiPhone Duoに対応させた実際の手順を紹介します。構成はウィンドウの幅で選び、折りたたんだり回転したりしても、開いていた画面は失われません。すべてXcode 27.1 RCとiOS 27.1のDuoシミュレータで確認しました。全体像はiPhone Duo 開発者向けの対応ガイドで解説しています。ここでは1つのアプリでの実践に絞ります。
idiomでは「電話かタブレットか」を判断できなくなった#
Duoには画面が2つあります。外側の画面は普通のiPhoneと同じで、縦向きではcompact × regularです。内側の画面は、向きに関係なくregular × regularです。それでもidiomはどちらも同じです。つまり「どの構成を表示するか」はデバイスではなく、ウィンドウに問うべきです。いま横方向にどれだけのスペースがあるか、です。
それを教えてくれるのが、SwiftUIのhorizontal size class(@Environment(\.horizontalSizeClass))です。折りたたみ、回転、画面分割で値が変わり、SwiftUIはそれに依存するビューを自動で描き直します。
iPhone Duo アプリ開発での構成ルール#
判断が画面ごとに散らばらないよう、1つの型にまとめました。
import SwiftUI
enum ShellLayout {
static func usesSplit(horizontalSizeClass: UserInterfaceSizeClass?) -> Bool {
UIDevice.current.userInterfaceIdiom == .pad || horizontalSizeClass == .regular
}
}幅の広いウィンドウ(Duoの内側の画面、横向きのiPhone Pro Max)にはサイドバー、狭いウィンドウ(Duoの外側の画面、画面分割の狭い側)にはタブバーを使います。
idiomが残っているのは1か所だけで、iPadの例外です。Duo以前から、iPadは常にサイドバー構成でした。狭い画面分割でもNavigationSplitViewが自動でスタックに畳まれます。ウィンドウが狭くなるたびにiPadをタブバーへ切り替えると、すでに問題なく動いている挙動を、関係のないデバイスのために変えることになります。そこでルールは「iPad、または幅の広いウィンドウ」にしました。
Macではこの問題そのものが起きません。Macには専用の構成MacRootViewがあり、サイドバーとセクションごとのナビゲーションスタックで動いているので、構成の選択全体を#if !os(macOS)で除外しています。Macのウィンドウをどれだけ狭くしてもタブバーにはなりません。これは意図的な判断で、Macのウィンドウ下部にタブバーがあると違和感があるからです。
アプリのルートは、size classを環境から読みます。
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
} else {
MainTabView() // タブバー
}
}
.environment(shellSelection)
}
}大きな副作用があります。折りたたむと一方の構成が破棄され、もう一方がゼロから作られます。前の構成の@Stateにあったものはすべて消えます。カレンダーを開いていた人が、開き直すと「今日」に戻ってしまいます。
開いている画面を折りたたみ後も保つ#
構成ごとにselectionを持っていて、型も違います。タブバーは4タブ、サイドバーは9セクションです。共有するのは「いま何が開いているか」だけにして、文字列で持たせました。
import Observation
@Observable
final class ShellSelection {
var section: String?
}nilは「まだどの構成も表示していない」という意味です。これが大事で、初回起動時には新しい構成が自分のデフォルトで始まり、デバッグ用の起動フラグやウィジェットからのディープリンクを上書きしません。
各構成は、セクションが変わったらこのオブジェクトに書き込み、表示されたときに読み出します。
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 }
// 「ジャーナル」などタブバーにないセクションなら「今日」に戻す
selection = AppTab(rawValue: raw) ?? .today
}
}AppTabとサイドバーのセクションは生の値(today、entries、calendar、luna)が共通なので、対応表なしで文字列をそのまま渡せます。環境の値はオプショナル(ShellSelection?)にしてあるので、プレビューも追加設定なしで動きます。
なぜ@SceneStorageを使わないのか。これは起動をまたいでセクションを保存します。するとアプリは「今日」ではなく最後に開いた画面で起動し、製品の振る舞いが変わってしまいます。必要だったのは、シーンが生きている間だけの記憶でした。
ナビゲーションバーはデバイスではなく構成で決める#
画面の中にも、もう1つidiomの判定が隠れていました。「今日」「カレンダー」「チャット」はiPhoneでは独自の見出しを描くので、システムのナビゲーションバーを隠します。iPadではバーが必要で、そこにサイドバーを畳むボタンがあります。以前はこう判定していました。
.toolbar(UIDevice.current.userInterfaceIdiom == .pad ? .automatic : .hidden, for: .navigationBar)Duoの内側の画面では、この画面はサイドバー構成の中に表示されるのに、自分は電話だと思ってボタンを隠してしまいます。正しい問いは「自分はどの構成の中に表示されているか」です。それに答えるのが専用の環境キーで、サイドバー構成が値をセットします。
private struct SplitShellKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var isSplitShell: Bool {
get { self[SplitShellKey.self] }
set { self[SplitShellKey.self] = newValue }
}
}
// PadRootView側
NavigationSplitView { sidebar } detail: {
detail(for: selection)
.environment(\.isSplitShell, true)
}
// 画面側
@Environment(\.isSplitShell) private var isSplitShell
// ...
.toolbar(isSplitShell ? .automatic : .hidden, for: .navigationBar)これで、コードに残るidiomの判定はShellLayoutの1か所だけになりました。ほかの判断はすべて、ウィンドウか構成に基づいています。
手を使わずに構成の切り替えを確認する#
その場での切り替えを確かめるには、iPhone Pro Maxが便利です。横向きでは幅がregularになるからです。コマンドラインからシミュレータを回転させることはできないので、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))
}
}
}
}
#endif-orientation landscape,portraitを付けて起動すると、4秒後と8秒後に画面が回転します。私の確認では、「記録」画面が「タブバー → サイドバー → タブバー」と切り替わっても選択されたままでした。iPadOS 27のウィンドウモードのiPadではシステムがこの要求を無視しますが、そこでは構成が変わらないのが正しい挙動です。
Duoの折りたたみと展開はコマンドラインからは行えず、DeviceHubのボタンで操作します。その方法はシミュレータでiPhone Duoをテストする記事にまとめました。
できたことと、残っていること#
iOS 27.1のDuoシミュレータで、その場での折りたたみと展開(両方向)、両画面の回転、ブック型の姿勢を確認しました。開いている画面は保たれます。iPhone 18 Pro Maxでは回転で構成が切り替わり、iPad Pro 13では何も変わりませんでした。
実機の操作で確認できていないのは、2つのアプリを並べるiPhone Duoの画面分割です。内側の画面の半分は狭いウィンドウとして扱われるので、ルール上はタブバーになり、外側の画面と同じ経路をたどります。ただしこれは推論で、確認ではありません。シミュレータでDockからドラッグするジェスチャーを、マウスではうまく再現できませんでした。
構成は作業の半分にすぎませんでした。残りの半分は画面そのものに現れました。縦型のシステムバーの下のタブバー、折り目に重なるヒント、ヒンジをまたぐチャットの入力欄です。これらはiPhone Duo対応で直面した問題をまとめた別の記事で扱います。



