В моём дневнике Lanternly две оболочки. На iPhone внизу таб-бар, на iPad слева сайдбар на NavigationSplitView. Выбирала между ними одна строка: if UIDevice.current.userInterfaceIdiom == .pad. На iPhone Duo эта строка ломается первой. Duo — это iPhone, идиома у него .phone на обоих экранах, и внутренний экран на 7,6 дюйма получил бы растянутый телефонный таб-бар.
В этой статье расскажу, как я перевёл приложение на SwiftUI под iPhone Duo: каркас выбирается по ширине окна, а открытый раздел не теряется, когда устройство складывают или поворачивают. Всё проверено в Xcode 27.1 RC на симуляторе Duo с iOS 27.1. Общую картину изменений я разбирал в гайде по адаптации под iPhone Duo, здесь только практика одного приложения.
Почему идиома больше не отвечает на вопрос «телефон или планшет»#
У Duo два экрана. Внешний ведёт себя как обычный iPhone: в портрете compact × regular. Внутренний — regular × regular в любой ориентации. Идиома при этом одна и та же. Значит, вопрос «какая у нас оболочка» нельзя задавать устройству. Его надо задавать окну: сколько места сейчас есть по горизонтали.
Это ровно то, что даёт horizontal size class в SwiftUI, @Environment(\.horizontalSizeClass). Он меняется при складывании, повороте и в Split View, и SwiftUI сам перерисовывает всё, что от него зависит.
Правило выбора каркаса для iPhone Duo в SwiftUI#
Я вынес решение в один тип, чтобы оно не расползлось по экранам:
import SwiftUI
enum ShellLayout {
static func usesSplit(horizontalSizeClass: UserInterfaceSizeClass?) -> Bool {
UIDevice.current.userInterfaceIdiom == .pad || horizontalSizeClass == .regular
}
}Широкое окно (внутренний экран Duo, iPhone Pro Max в альбоме) получает сайдбар. Узкое (внешний экран Duo, узкая половина Split View) получает таб-бар.
Идиома в правиле осталась ровно в одном месте: это исключение для iPad. До Duo iPad всегда жил в сайдбаре, в том числе в узком Split View: NavigationSplitView сам сворачивается в стек. Переводить iPad в таб-бар при сужении окна значило бы поменять поведение, которое уже работает, ради устройства, к которому оно отношения не имеет. Поэтому правило звучит так: «iPad или широкое окно».
На 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, и типы у них разные: у таб-бара четыре вкладки, у сайдбара девять разделов. Общим я сделал только «что сейчас открыто», в виде строки:
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? Он сохраняет раздел и между запусками. Тогда приложение открывалось бы на последнем разделе, а не на «Сегодня», то есть поменялось бы поведение продукта. Мне нужна была память только на время жизни сцены.
Навбар по каркасу, а не по устройству#
Вторая идиома пряталась в экранах. «Сегодня», «Календарь» и «Чат» на 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)После этого в коде осталась одна проверка идиомы, в ShellLayout. Все остальные решения зависят от окна или от каркаса.
Как проверить смену каркаса без рук#
Смену каркаса на лету удобно проверять поворотом 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 секунд. В моём прогоне «Записи» прошли круг «таб-бар → сайдбар → таб-бар», и вкладка осталась выбранной. На iPad в оконном режиме iPadOS 27 такой запрос система игнорирует, но там каркас и не должен меняться.
Складывание и раскладывание Duo из командной строки не вызвать, это кнопки DeviceHub. Как я с ними справился, рассказано в статье про тестирование iPhone Duo в симуляторе.
Что получилось и что осталось#
На симуляторе Duo с iOS 27.1 проверены складывание и раскладывание на лету в обе стороны, повороты внешнего и внутреннего экрана и книжная поза. Открытый раздел сохраняется. На iPhone 18 Pro Max каркас меняется при повороте, на iPad Pro 13 всё как было.
Вживую я не проверил iPhone Duo split view с двумя приложениями. Половина внутреннего экрана считается узким окном, и по правилу там будет таб-бар, тот же путь, что на внешнем экране. Но это вывод, а не проверка: жест перетаскивания из Dock в симуляторе мне мышью не дался.
Каркас был только половиной работы. Вторая половина всплыла уже на экранах: таб-бар под вертикальной системной полосой, подсказки, которые ложились на сгиб, поле чата через шарнир. Об этих граблях — в отдельной статье о проблемах адаптации под iPhone Duo.



