Адаптация iOS-приложений под iPhone Duo


Привет! Я Валерия Кудрявцева, iOS-разработчик в KTS. Пора поговорить о новинке.
9 сентября 2026 года Apple представила iPhone Duo, свой первый складной смартфон. Пока пользователи обсуждали новый форм-фактор, у нас, iOS-разработчиков, вопрос был другой: как поддерживать приложение на устройстве с двумя экранами и сгибом. Для нас Apple подготовила раздел HIG о проектировании под Duo, шесть Tech Talks и хаб Get Ready for iPhone Duo.
Статья основана на опубликованных Tech Talks Apple. Xcode 27.1 beta на момент подготовки ещё не вышел, поэтому возможности 27.1 описаны по презентациям. Проверить их на практике нужно после выхода SDK.
Сразу оговорюсь. По информации Apple, существующие приложения запустятся на Duo и без пересборки. Но адаптацию интерфейса это не отменяет: то, как приложение займёт внутренний дисплей и как поведут себя стандартные панели, зависит от SDK, с которым оно собрано. Различия разберу ниже.
Отдельный интерфейс под каждую pose Duo делать не нужно. А вот допущения в текущей вёрстке проверить придётся. Если код берёт доступное пространство из модели устройства или ориентации и считает отступы симметричными, на Duo такие расчёты дадут неверный результат. Основа остаётся одна: адаптивный UI, который смотрит на размер текущего окна и на size class.
Дальше разберу изменения iOS 27, что система сделает сама и что нужно править в коде, новые API 27.1 и типичные ошибки при аудите. В конце сравню подходы с Android foldable и расскажу, что из этого уже есть в Compose Multiplatform.
Оглавление
iOS 27: что меняется для любого приложения
Начну с изменений, которые касаются всех iOS-приложений. В iOS 27 окно приложения может менять размер: при работе через iPhone Mirroring на Mac и при запуске iPhone-only приложения на iPad. Поэтому сначала посмотрим, чего iOS 27 требует уже сейчас.
Scene lifecycle становится обязательным
При сборке с iOS 27 SDK приложение на старом UIKit lifecycle без scene lifecycle не запустится. Apple предупреждала об этом с WWDC25, а подробности миграции в TN3187.
На практике отличие в том, что окно больше не создаётся в AppDelegate: нужен UIApplicationSceneManifest и configurationForConnecting.
Было (UIKit без scene lifecycle с iOS 27 SDK не запустится):
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = RootViewController()
window?.makeKeyAndVisible()
return true
}
}
Стало (scene-based lifecycle):
// Info.plist: UIApplicationSceneManifest
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
}
}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = RootViewController()
window?.makeKeyAndVisible()
}
}
Минимальный фрагмент Info.plist:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
Проекты на SwiftUI App уже scene-based. Миграция нужна в основном UIKit-приложениям без UIApplicationSceneManifest.
При этом scene lifecycle не обязывает поддерживать несколько окон. У приложения по-прежнему может быть одна scene. Смысл перехода в том, чтобы у каждого экземпляра UI был свой scene lifecycle и своё состояние.
Размер экрана больше не определяет размер интерфейса
UIScreen.main.bounds отдаёт размер основного экрана:
let screenWidth = UIScreen.main.bounds.width
Но окно приложения может занимать только его часть. К тому же main может относиться не к тому дисплею, где сейчас показано окно:
let screen = UIScreen.main // может быть не тот дисплей
let screen = windowScene.screen // экран текущей scene
Для вёрстки внутри view нужно выбирать размер контейнера:
let width = cardView.superview?.bounds.width ?? 0
На уровне scene нужно учитывать геометрию UIWindowScene:
let geometry = windowScene.effectiveGeometry
UIScreen нужно получать через windowScene.screen. И displayScale нужно брать у экрана этой scene, а не у глобального main:
let scale = windowScene.screen.scale
Например, карточка занимает половину ширины экрана.
// плохо при resize окна
let cardWidth = UIScreen.main.bounds.width / 2
Пока приложение полноэкранное, результат выглядит правильно. Но в Split View та же карточка может уже не поместиться в своей колонке. Ширину нужно считать относительно контейнера, в котором карточка лежит.
Ориентация больше не определяет layout
В iOS 27 система может игнорировать поддерживаемые ориентации в resizable-средах, а не при любом запуске приложения.
// preference, не опора для layout в resizable-среде
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.portrait
}
Например, в iPhone Mirroring ориентация интерфейса остаётся портретной, даже если пользователь растянул окно в ширину.
Поэтому проверка ориентации уже не подскажет, хватит ли места для двух колонок:
// ориентация не отражает ширину окна
if UIDevice.current.orientation.isLandscape { ... }
Это поведение разбирается в сессии Modernize your UIKit app (WWDC26-278).
Size class описывает доступное пространство категориями compact и regular, отдельно по горизонтали и по вертикали. compact означает, что места по этой оси мало. regular означает, что места достаточно для более широкой структуры интерфейса, например для двух колонок.
traitCollection.horizontalSizeClass // .compact или .regular
traitCollection.verticalSizeClass
Это ориентир для выбора структуры интерфейса.
Если нужно понять, поместятся ли конкретные элементы, нужно проверять размеры их контейнера:
if traitCollection.horizontalSizeClass == .regular { ... }
Чтобы понять, нужна одна колонка или две, нужно смотреть на size class. Для точных расчётов позиций и размеров элементов нужно использовать размеры их контейнера.
Проверка userInterfaceIdiom тоже не поможет:
if traitCollection.userInterfaceIdiom == .phone { ... }
iPhone-only приложение сохраняет .phone при запуске на iPad и через Mirroring, хотя доступного пространства может быть значительно больше. У universal-приложений idiom бывает другим, поэтому эту оговорку нельзя переносить на все сборки.
UIRequiresFullScreen не отключает resize
В iOS 27 этот ключ включает дискретное изменение размера с учётом поддерживаемых ориентаций.
<key>UIRequiresFullScreen</key>
<true/>
Дискретный resize означает переход между готовыми размерами, а не непрерывную перекладку интерфейса, пока пользователь тянет окно.
Это относится к resizable-средам iOS 27, а не к любому запуску приложения. Он полезен прежде всего для игр, но зафиксировать размер окна с его помощью уже не получится.
Документация по UIRequiresFullScreen пока описывает старые режимы совместимости и депрекацию с iOS 26. Новое поведение нужно сверять по Modernize your UIKit app (WWDC26-278) и TN3192, а позже по release notes SDK.
Для игр и canvas отдельно стоит проверить поведение при изменении пропорций окна. Даже дискретный resize не решает за разработчика, какую часть игрового мира показывать и как размещать управление. Если соотношение сторон остаётся фиксированным, возможны поля. Если область рендеринга расширяется, нужно проверить композицию и доступность кнопок.
Игры не всегда требуют самой сложной адаптации. Объём работы зависит от того, как устроены вёрстка и обработка ввода.
Если приложение уже корректно работает при изменении размера окна на iPad или в Mirroring, часть подготовки к Duo уже сделана.
iPhone Duo: экраны, pose и SDK
Два дисплея, разное пространство для контента

Источник: Apple Newsroom, анонс iPhone Duo
В сложенном состоянии пользователь будет работать с приложением на внешнем дисплее, как на привычном iPhone. После раскрытия приложение будет отображаться на внутреннем дисплее. При полноэкранном отображении size class может быть regular по обеим осям, но layout нужно строить по текущим traits, а не задавать regular по модели устройства.
traitCollection.horizontalSizeClass
traitCollection.verticalSizeClass
Здесь уже можно будет показать список и выбранный элемент рядом, добавить боковую навигацию или разместить больше карточек в строке. Особенности size class Apple разбирает в Prepare your app for iPhone Duo.
Например, в приложении заметок на внешнем экране пользователь сначала откроет список и только потом перейдёт к тексту заметки. На внутреннем экране список можно оставить слева, а текст показать справа.
Какие pose устройства нужно учитывать
Pose у Apple это состояние сгиба, orientation это portrait/landscape. Всего их 6: 4 основных и 2 дополнительных:
Pose | Portrait | Landscape |
|---|---|---|
Closed (сложен) | внешний дисплей | внешний дисплей |
Open (раскрыт) | внутренний, одна колонка | внутренний, две колонки |
Book (как книга) | сгиб по вертикали | нет |
Tabletop (на столе) | нижняя половина лежит | верх для контента, низ для управления |

Источник: кадр из Design for iPhone Duo.
Как это обрабатывается в коде
Отдельная вёрстка на каждую позу не нужна. Система меняет size class и геометрию окна, а layout подстраивается по traits.
Контент у сгиба и у камер учитывается через reserved regions в iOS 27.1. Если нужно разделить UI у сгиба, например в tabletop положить контент наверх, а управление вниз, это делается через ArrangementView / UIArrangementViewController. Сама система так не разложит.
// iOS 27.1: область сгиба
// SwiftUI: reserved regions в GeometryProxy
// UIKit: UIViewReservedRegion
Сценарии book и tabletop Apple разбирает в Strike a pose with adaptive layouts on iPhone Duo.
Что зависит от версии SDK
Поведение на внутреннем дисплее зависит от SDK, с которым собрано приложение. Ниже различия по материалам Prepare your app for iPhone Duo.
SDK сборки | Поведение на внутреннем дисплее |
|---|---|
Ниже iOS 27 | Приложение запустится, но не займёт всю площадь дисплея |
iOS 27 SDK | Область отображения расширится, но ещё не дойдёт до края экрана |
iOS 27.1 SDK | Область дойдёт до края экрана. У стандартных кнопок в navigation bar и toolbar появится вертикальное размещение панелей |
Версия SDK сборки не равна минимальной поддерживаемой версии ОС. Новые API нужно проверять на доступность, если приложение поддерживает старые системы. Само по себе наличие iOS 27.1 SDK не значит, что deployment target нужно поднимать до 27.1.
Для целевого UX на Duo удобно ориентироваться на сборку с iOS 27.1 SDK. Но пересборка не исправит вёрстку, если фиксированная ширина или симметричные отступы не помещаются в доступное окно.
// проблема останется после пересборки
let width = UIScreen.main.bounds.width
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
Edge-to-edge значит, что фон и системные панели могут доходить до края экрана. Это не значит, что текст и кнопки нужно класть вплотную к краю. Контент нужно размещать с учётом safe area и layout margins.
// UIKit: фон на весь экран, контент в safe area
view.backgroundColor = .systemBackground
let safeFrame = view.bounds.inset(by: view.safeAreaInsets)
// SwiftUI: фон edge-to-edge, контент по умолчанию в safe area
ZStack {
Color(.systemBackground).ignoresSafeArea()
content
}
На iOS 27.1 отступы часто асимметричны: safe area учитывает боковые панели, а reservedRegions - сгиб и камеры. Слева и справа их нужно читать отдельно, а не удваивать одну сторону.
Что сделает система, а что придётся править в коде
Стандартные контейнеры возьмут часть работы на себя
Если приложение использует стандартную навигацию, писать всю адаптацию с нуля не придётся. Системные контейнеры уже умеют перестраиваться при изменении доступного пространства.
Задача | SwiftUI | UIKit |
|---|---|---|
Навигация между экранами |
|
|
Навигация с несколькими колонками |
|
|
Переключение разделов |
|
|
Таблица показывает соответствие задач. Одинаковое поведение API она не обещает.
NavigationSplitView и UISplitViewController показывают колонки рядом, когда места достаточно, и сворачивают их при уменьшении ширины. NavigationStack сам в split не превратится. Если на широком экране нужны список и деталь рядом, split-навигацию нужно закладывать сразу. Переключать stack при обнаружении Duo не стоит.
// не так: отдельная ветка под Duo
if isDuo {
showSplitView()
} else {
NavigationStack { ... }
}
// так: адаптивный контейнер
NavigationSplitView {
NotesList()
} detail: {
NoteDetail()
}
Проверять модель устройства не нужно. Контейнер ориентируется на ширину окна и horizontalSizeClass. На внутреннем дисплее Duo при regular width split покажет две колонки, на внешнем при compact width останется одна.
// UIKit: тот же принцип
let splitVC = UISplitViewController(style: .doubleColumn)

Источник: Apple, Design for iPhone Duo, ~7:48.

Источник: Apple, Design for iPhone Duo, ~8:27.

Источник: Apple, Design for iPhone Duo, ~8:13.
Структуру навигации по-прежнему выбирает разработчик. Система не подменит NavigationStack на split автоматически. Выбор делается при проектировании: одна колонка (NavigationStack) или адаптивные колонки (NavigationSplitView / UISplitViewController). Примеры адаптации контейнеров на Duo есть в Design for iPhone Duo и Prepare your app for iPhone Duo.
Кнопки навигации и toolbar меняют расположение
При сборке с iOS 27.1 SDK система может размещать элементы стандартных панелей вертикально у бокового края. Это заявленное поведение из Tech Talks, его стоит перепроверить после выхода SDK. Такое размещение освобождает место по высоте и делает кнопки доступнее, когда пользователь держит устройство в руках. При портретном положении внутреннего дисплея элементы остаются горизонтальными. Речь о физическом положении устройства, а не о значении interfaceOrientation.

Источник: Apple, Raise the bar with iPhone Duo.
Для такого поведения нужны панели, которыми управляют системные контейнеры. В SwiftUI это .toolbar внутри NavigationStack, в UIKit это панели UINavigationController и UITabBarController.
// системный toolbar — участвует в вертикальном layout
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Share", systemImage: "square.and.arrow.up") { }
}
}
Если создать UIToolbar, UINavigationBar или UITabBar вручную и добавить во view, их содержимое в вертикальную панель не попадёт. Это ограничение описано в Raise the bar with iPhone Duo.
// кастомная панель во view — вне системного вертикального layout
let bar = UINavigationBar()
view.addSubview(bar)
Даже в системном toolbar в вертикальную колонку переходят не все элементы. Иконки обычно встают вертикально. Текстовые кнопки и широкие custom views вроде UISegmentedControl чаще остаются горизонтальными, потому что не помещаются в узкую боковую панель.
Что проверить в своих элементах: остаются ли действия доступны, если часть ушла в overflow, и есть ли у иконки текстовая подпись для accessibility. Подробности Apple разбирает в Raise the bar with iPhone Duo
Системные всплывающие элементы адаптируются автоматически
Alert, sheet, popover и контекстное меню система переставляет с учётом доступного пространства и reserved regions у сгиба и камер. Меняется место и способ показа системного элемента. Вёрстку внутри sheet система за вас не переделывает.

Источник: Apple, Design for iPhone Duo, ~2:25.
// системный sheet: позиция и презентация на стороне системы
.sheet(isPresented: $showSettings) {
SettingsView()
}
// UIKit: системный popover
button.popoverPresentationController?.sourceView = button
present(popoverVC, animated: true)
Для кастомных оверлеев, меню и модалок такую проверку нужно делать вручную. Если меню всегда рисуется по центру экрана, на Duo оно может попасть на сгиб.
// плохо: центр экрана, без учёта сгиба
.overlay {
CustomMenu()
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
// лучше: привязка к safe area и reserved regions (iOS 27.1)
.overlay(alignment: .topTrailing) {
CustomMenu()
.padding(.top, geo.safeAreaInsets.top)
.padding(.trailing, reservedTrailingInset(from: geo))
}
Что проверить у кастомных всплывашек: не перекрывают ли сгиб и камеру, остаются ли кнопки в зоне досягаемости, не обрезается ли меню при смене pose. Системные sheet и popover смещаются от сгиба сами. Кастомный overlay нужно позиционировать относительно safe area и reserved regions, а не центра UIScreen.
Подробнее про сгиб и reserved regions Apple разбирает в Strike a pose with adaptive layouts on iPhone Duo.
Своя вёрстка и safe area
Safe area обозначает безопасную область view. На обычном iPhone safeAreaInsets.left и safeAreaInsets.right часто совпадают, и код с симметричными отступами работает. На Duo отступы по сторонам разные: система выносит navigation bar и toolbar на боковой край, плюс камера и сгиб дают reserved regions.
Асимметрия появляется не только в landscape. Она есть на внешнем дисплее, где панели стоят сбоку, на внутреннем в landscape, в Split View и при частичном сгибе, когда интерактивные элементы уходят от сгиба.

Источник: Apple, Design for iPhone Duo, ~4:23.
Если ширина контента считается как ширина view минус удвоенный левый отступ, на Duo правый край уйдёт под панель.
// плохо: симметрия
let hInset = view.safeAreaInsets.left
let contentWidth = view.bounds.width - hInset * 2
// так: каждая сторона отдельно
let insets = view.safeAreaInsets
let contentWidth = view.bounds.width - insets.left - insets.right
// SwiftUI
GeometryReader { geo in
let insets = geo.safeAreaInsets
Content()
.frame(width: geo.size.width - insets.leading - insets.trailing)
}
Системный контейнер не исправит расчёты во вложенной view. Если кастомная ячейка или overlay задаёт отступы вручную, их нужно проверять по bounds и safeAreaInsets именно этой view, а не по значениям с корневого экрана.
Split View и несколько окон
На внутреннем дисплее Duo два приложения могут стоять рядом. Это системный Split View: у каждого приложения своё окно и своя доля ширины. Панели navigation и toolbar при этом смещаются к внешнему краю своей половины, а не к центру экрана.

Системный Split View: два приложения, у каждого своя ширина. Источник: Apple, Design for iPhone Duo.
Слово split встречается и в NavigationSplitView для SwiftUI, и в UISplitViewController для UIKit, и эти вещи часто путают. Там речь уже про одно приложение: sidebar, список и деталь живут в одном окне и делят ширину между собой. Задача другая, но правило то же. Колонки, формы и панели должны ужиматься под доступную ширину, а не под полный экран.

NavigationSplitView: колонки внутри одного приложения. Источник: Apple, Design for iPhone Duo.
Проверять только полноэкранный режим недостаточно. На Duo узкое окно появляется и без соседа: при Split View с другим приложением, при уменьшении окна на iPad или в Stage Manager. Ориентиры уже знакомые: horizontalSizeClass и контейнеры NavigationSplitView или UISplitViewController. Отдельная ветка на случай Duo не нужна.
// Один layout на все ширины окна, не только fullscreen
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
if horizontalSizeClass == .regular {
NavigationSplitView { sidebar } detail: { detail }
} else {
NavigationStack { list }
}
}
Есть и отдельная история с несколькими окнами своего приложения. iOS может открыть второе окно той же заметки или списка, если в Info.plist включена поддержка нескольких scene. Тогда состояние нужно делить явно:
Что | Где хранить | Пример |
|---|---|---|
Общие данные | App-level store, iCloud, база | все заметки |
Состояние окна | конкретная scene | выбранная заметка, фильтр, путь навигации |
Без этого выбор заметки в одном окне переключит список и деталь во втором.
// Общее для всех окон
@main
struct NotesApp: App {
@State private var store = NotesStore() // одна база заметок
var body: some Scene {
WindowGroup {
NotesRootView()
.environment(store)
}
}
}
// Своё для каждого окна
struct NotesRootView: View {
@Environment(NotesStore.self) private var store
@SceneStorage("selectedNoteID") private var selectedNoteID: String?
@SceneStorage("listFilter") private var listFilter = ListFilter.all
// navigation path — тоже на уровне scene, не в singleton
}
Для UIKit то же разделение: UIWindowScene и scene(_:willConnectTo:) на каждое окно, общий NotesStore вне scene, selectedNoteID и navigation stack привязаны к конкретной scene.
API и возможности для адаптации под Duo
Основой вёрстки остаются size class и размеры контейнера. В iOS 27.1 к ним добавляются инструменты для работы со сгибом, для размещения контента и для взаимодействия с двумя дисплеями. Часть API ниже существует до Duo и просто получает новое применение: не всё это новые API 27.1.
Reserved regions: где проходит сгиб и что перекрывает контент
safeAreaInsets задают отступы от краёв safe area. Сгиб при этом может проходить внутри view, а не только у края. Для таких внутренних областей предусмотрены reserved regions. Они описывают место, которое вёрстка может учитывать, но разрывать любой текст или список по сгибу они не требуют.
Механизм | Что обозначает | Где встречается |
|---|---|---|
safe area ( | Безопасная зона от краёв view: вырез, Dynamic Island, индикатор Home, скругления | Все iPhone. Основной способ не залезть под челку |
| Делит доступное пространство, обычно в месте сгиба | Duo. Активна при частичном сложении, на плоском устройстве имеет нулевую ширину |
| Область, перекрывающая контент (камера на дисплее) | Duo. Внешняя камера постоянно, внутренняя under-display FaceTime только пока камера активна. Это геометрия размещения, а не объект |
На iPhone с вырезом вёрстку по-прежнему строят от safe area: верхний inset отодвигает контент от челки или Dynamic Island. Reserved regions этот сценарий не дублируют и не заменяют. На Duo safe area и reserved regions работают вместе: insets задают отступы от краёв окна, а .division и .occlusion уточняют сгиб и камеры внутри доступной области.
В SwiftUI области читают через GeometryProxy.reservedRegions(kind:), в UIKit через UIView. Их геометрию используют в своей вёрстке, когда системных контейнеров не хватает.
// Обычный iPhone: вырез, Dynamic Island, home indicator
let topInset = geometry.safeAreaInsets.top
// Duo: сгиб и камеры — отдельный запрос
let folds = geometry.reservedRegions(kind: .division)
let cameras = geometry.reservedRegions(kind: .occlusion)
let foldFrames = folds.map(\.frame)
// UIKit — тот же смысл
let folds = view.reservedRegions(kind: .division)
let cameras = view.reservedRegions(kind: .occlusion)
Когда устройство полностью раскрыто, область сгиба становится неактивной. По умолчанию запрос возвращает только активные области. Если решение должно быть стабильным при открывании и закрывании, например всегда чётное число колонок сетки, передайте .includeInactive:
let folds = geometry.reservedRegions(kind: .division, options: .includeInactive)
ArrangementView: как разместить два блока контента
На iPad две колонки уже даёт NavigationSplitView или UISplitViewController: при regular horizontal size class sidebar и detail стоят рядом, при compact остаётся одна колонка и стек навигации. На Duo эта схема по-прежнему работает для списка и детали.
ArrangementView в SwiftUI и UIArrangementViewController в UIKit решают другую задачу: разместить ровно два представления с учётом сгиба и доступной геометрии. Контейнер не обещает, что оба слота всегда видны одновременно. При нехватке места или в неподходящей позе может остаться одно представление.
Стиль | Поведение |
|---|---|
| Два блока рядом. Направление layout может меняться при сгибе. При нехватке места показывается одно представление вместо двух |
| Блоки наслаиваются. Расположение меняется при сгибе, перекрытие не гарантировано как постоянный режим |
Если сейчас два блока стоят в HStack или VStack, начинайте со стиля .split. Если контент наслаивается через ZStack, смотрите на .overlay. Это ориентир, а не замена стека: ArrangementView следует адаптивным правилам системы и не копирует HStack или ZStack один в один.
Каждый слот нужно проектировать так, чтобы он был полноценным и без соседа. Второй блок может не появиться, и приложение не должно от этого ломаться. Модель данных может быть одна, но UI каждого слота лучше держать отдельным view. Навигацию ArrangementView не заменяет.
Контейнер отвечает только за расположение. В примере Apple он стоит внутри NavigationStack. Снаружи остаются NavigationSplitView, TabView или UISplitViewController. Вкладывать в ArrangementView ещё один split-контейнер или класть его в List и ScrollView Apple не рекомендует.
// Навигация снаружи, два слота вокруг сгиба внутри
NavigationStack {
ArrangementView(style: .split) {
PrimaryPane()
} secondary: {
SecondaryPane()
}
}
// UIKit: тот же порядок — split/navigation снаружи
let arrangement = UIArrangementViewController(
style: .split,
primary: primaryVC,
secondary: secondaryVC
)
navigationController?.setViewControllers([arrangement], animated: false)
Что когда выбирать:
Сценарий | Инструмент |
|---|---|
Список + деталь, sidebar + content |
|
Два кастомных блока вокруг сгиба (игра, canvas, своя панель) |
|
Обход сгиба в ручном layout без пары слотов |
|
Угол сгиба: для взаимодействий и эффектов
За состоянием сгиба следят onHingeChange в SwiftUI и UIHingeInteraction в UIKit. Они сообщают состояние устройства (closed, partiallyOpen, fullyOpen) и непрерывный угол между половинками.
Это инструмент для интеракций и анимаций. Расставлять по нему view не стоит. Он подходит там, где физический сгиб работает как аналоговый ввод: плавно менять параметр, скорость прокрутки, интенсивность эффекта. В Strike a pose Apple показывает pitch bend в музыкальном приложении.
Позицию кнопок, колонок и панелей по углу сгиба не стоит считать вручную. Для layout Apple предлагает reservedRegions и ArrangementView. Иначе придётся самим подбирать пороги углов и правила смещения на каждую pose.
// SwiftUI: реакция на угол, не на layout
ContentView()
.onHingeChange { context in
switch context.state {
case .closed, .fullyOpen:
effectIntensity = 0
case .partiallyOpen:
effectIntensity = context.angle // непрерывное значение
@unknown default:
break
}
}
// UIKit: UIHingeInteraction на view или view controller
let hinge = UIHingeInteraction { context in
updateEffect(intensity: context.angle)
}
view.addInteraction(hinge)
Задача | API |
|---|---|
Где сгиб и камеры, куда сдвинуть контент |
|
Два блока вокруг сгиба |
|
Жест, анимация, параметр от угла |
|
Несколько сцен: новые окна только на внутреннем дисплее
На Duo независимые окна приложения можно открыть только на внутреннем дисплее. На внешнем дисплее новая scene не создаётся. Программный запрос может завершиться ошибкой, и её нужно обработать. Это ограничение относится к независимым scene и не противоречит scene accessories: accessory показывает дополнительный UI на другом дисплее, но второе окно приложения не открывает.
Если приложение уже поддерживает несколько окон на iPad, на Duo механизм тот же. Меняются только проверка доступности и обработка отказа на внешнем дисплее.
Как открыть окно
Через меню. Это рекомендуемый путь Apple.
UIWindowScene.ActivationAction(с iOS 15, в Objective-C называетсяUIWindowSceneActivationAction) встраивается вUIMenu, кнопку или bar button item. При выборе пункта система создаёт scene и передаётNSUserActivityс контентом. Пункт сам скрывается, когда новое окно недоступно. Для iPhone без multi-window или для внешнего дисплея Duo можно задать alternate action с другим поведением.
let alternate = UIAction(title: "Показать детали") { _ in
showDetailInCurrentWindow()
}
let openInNewWindow = UIWindowScene.ActivationAction(alternate: alternate) { _ in
let activity = NSUserActivity(activityType: "com.example.openNote")
activity.userInfo = ["noteID": note.id]
return UIWindowScene.ActivationConfiguration(userActivity: activity)
}
Программно в UIKit. В
UIApplication.shared.requestSceneSessionActivationпередайтеnilвsceneSession, чтобы создать новую scene, или существующую сессию, чтобы поднять уже открытое окно.
UIApplication.shared.requestSceneSessionActivation(
nil,
userActivity: activity,
options: nil
) { error in
// На внешнем дисплее Duo или без SupportsMultipleScenes
handleSceneActivationFailure(error)
}
В SwiftUI. Объявите
WindowGroupсid, откройте через environment:
@main
struct NotesApp: App {
var body: some Scene {
WindowGroup { NotesRootView() }
WindowGroup(id: "note", for: String.self) { $noteID in
if let noteID { NoteDetailView(noteID: noteID) }
}
}
}
struct OpenNoteButton: View {
@Environment(\.supportsMultipleWindows) private var supportsMultipleWindows
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Открыть в новом окне") {
openWindow(id: "note", value: noteID)
}
.disabled(!supportsMultipleWindows)
}
}
Если окно с таким value уже есть, система поднимет его, а не создаст дубликат.
Как закрыть окно
Пользователь закрывает окно системным жестом или кнопкой. В коде это выглядит так:
// UIKit: убрать scene из переключателя приложений
UIApplication.shared.requestSceneSessionDestruction(
windowScene.session,
options: nil,
errorHandler: { error in /* ... */ }
)
// SwiftUI: закрыть конкретное окно или текущее
@Environment(\.dismissWindow) private var dismissWindow
@Environment(\.dismiss) private var dismiss
Button("Закрыть") {
dismissWindow(id: "note", value: noteID) // или dismiss() внутри этого окна
}
Последнее окно приложения система обычно не даёт закрыть программно.
Что разделить между окнами
Общие данные, базу заметок и синхронизацию, хранят на уровне приложения. Выбранную заметку, фильтр и navigation path хранят на уровне scene, через @SceneStorage или отдельный store на окно. Иначе действие в одном окне переключит содержимое другого.
Механизм | Назначение |
|---|---|
| Открытие из меню, автоскрытие при недоступности |
| Программное открытие / поднятие scene |
| Программное закрытие scene |
| То же в SwiftUI |
Scene accessory | UI на внешнем дисплее без второго окна |
Подробности: Leverage multiple displays and scenes on iPhone Duo.
Scene Accessories
Scene Accessories связывают основной интерфейс с дополнительным контентом на другом дисплее. Это не способ открыть произвольное второе окно снаружи и не то же самое, что независимая scene. Когда и где показать accessory, решает система. Приложение объявляет контент и должно нормально работать, даже если accessory недоступен.
Критерий | Независимая scene | Scene accessory |
|---|---|---|
Что это | Второе окно приложения | Доп. UI на другом дисплее |
Кто управляет | Запрос через | Система по условиям |
На Duo | Только внутренний дисплей | Часто внешний дисплей |
Пример | Две заметки в двух окнах | Телесуфлёр снаружи, съёмка внутри |
Типичный случай на Duo: CameraCaptureAccessory. Это подсказки, превью для модели или телесуфлёр на внешнем экране, пока интерфейс съёмки остаётся на внутреннем. В Leverage multiple displays and scenes Apple указывает условия:
приложение full screen на внутреннем дисплее;
идёт активная сессия камеры;
accessory включён пользователем (
isEnabled).
Доступность accessory меняется динамически: устройство закрыли, камеру остановили, условия перестали выполняться. Нужно подписаться на onAvailabilityChange и отключать переключатель. Оставлять кнопку, которая ведёт в пустоту, не стоит.
struct CameraRootView: View {
@State private var model = TeleprompterModel()
var body: some View {
CameraView(model: model)
.sceneAccessory {
CameraCaptureAccessory(isEnabled: $model.isEnabled) {
TeleprompterView(model: model) // текст для человека перед камерой
}
.onAvailabilityChange { model.isAvailable = $0 }
}
.toolbar {
TeleprompterToggle(isEnabled: $model.isEnabled)
.disabled(!model.isAvailable)
}
}
}
Паттерн для любого accessory один и тот же. На корневой view scene вешается modifier .sceneAccessory, внутри него конкретный тип accessory, например CameraCaptureAccessory для камеры. Флаг isEnabled приходит от пользователя, а onAvailabilityChange от системы. UI на основном дисплее не должен зависеть от того, показался ли контент снаружи.
Подробности и другие сценарии: Leverage multiple displays and scenes on iPhone Duo.
Переключение камер
На Duo две фронтальные камеры: на внешнем дисплее и под внутренним. Обе отчитываются как position == .front, но при смене дисплея и pose меняется то, какая камера смотрит на человека перед текущим view.
Для автоматического переключения есть Virtual Front Camera. Система выбирает внутреннюю или внешнюю камеру при раскрытии и складывании устройства. Виртуальное устройство даёт общее подмножество возможностей обеих физических камер, а не полный набор каждой. В примере Apple это 1080p, 60 fps, без depth. Для 4K или 120 fps с внешней камеры нужно обращаться к физическому устройству напрямую.
Критерий | Обычный iPhone | iPhone Duo |
|---|---|---|
Запрос | Одна физическая фронтальная камера | Virtual Front Camera с автопереключением |
Задние камеры | Как раньше | Как раньше |
| Обычно не нужен | Нужен, если UI и камера на разных дисплеях |
Прямой доступ к сенсору | По типу устройства в Discovery Session |
|
Если нужны возможности конкретной физической камеры, переключение становится задачей разработчика. AVCaptureDeviceDirectionCoordinator сообщает направление камер относительно view, а не физическое положение человека в комнате. Простого .front недостаточно: обе камеры фронтальные, но при переходе между дисплеями меняется то, какая из них направлена к текущему экрану.
// Минимальный путь: виртуальная камера, система переключает сама
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInWideAngleCamera],
mediaType: .video,
position: .front
)
let device = session.devices.first // на Duo — Virtual Front Camera
// Полный контроль: физические камеры + координатор направления
let outer = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInOuterUltraWideCamera],
mediaType: .video,
position: .front
)
// directionCoordinator — относительно конкретного preview view
Preview, зеркалирование и поворот при смене дисплея Apple разбирает в Build a great camera experience for iPhone Duo.
Подключать все API из этого раздела каждому приложению не нужно. Для обычного списка хватит системного контейнера. Для собственного layout пригодятся reservedRegions. Возможности двух дисплеев, нескольких окон и camera accessory имеет смысл добавлять под конкретный сценарий, а не «на будущее».
Насколько сложной будет адаптация
Ниже ориентир по объёму доработок UI. Таблица помогает понять, с какого слоя начать аудит конкретного приложения. Финальный объём зависит от экранов, кастомных панелей и того, насколько layout уже строится от размера окна, а не от модели устройства.
Сложность | Состояние приложения | Что потребуется |
|---|---|---|
Низкая | Системные контейнеры, приложение уже переживает resize на iPad или в Mirroring, мало кастомных панелей | Сборка с актуальным SDK, smoke-тест основных сценариев, включая складывание и раскрытие на симуляторе Duo (после Xcode 27.1) |
Средняя | Кастомные панели, layout по ориентации, расчёты через | Аудит и перенос расчётов на геометрию контейнера, size class и safe area |
Высокая | Игры, canvas или свой layout вокруг сгиба. Не каждая игра сюда попадает автоматически | Поведение контента у сгиба, |
Чеклист аудита и типичные ошибки
Ниже чеклист для прохода по коду. Удобный порядок такой: сначала поиск конкретных API и антипаттернов, затем проверка поведения при изменении размера окна в Device Hub, Mirroring или симуляторе Duo (после Xcode 27.1).
Отдельного мигратора под Duo Apple не даёт. В Xcode 27 есть app modernization skill для базовой адаптивности iOS 27: scene lifecycle, UIScreen.main, переход от ориентации к size class (WWDC26-278). Через MCP (xcrun mcpbridge) внешний агент может собирать проект и читать диагностику, но fold-aware API 27.1 он сам не внедрит. Skill ускоряет первый проход по таблице ниже, diff и тесты остаются за командой.
Что проверить в коде
Что искать | Что проверить |
|---|---|
| UIKit на scene lifecycle: в Info.plist есть манифест или в AppDelegate реализован |
| Размеры layout из bounds контейнера или геометрии scene. |
| Ориентация не определяет доступное пространство |
| Idiom не режет интерфейс до одной колонки на широком окне |
| Отступы с каждой стороны отдельно, без |
Кастомные navigation bar / toolbar | Ось размещения, вертикальный toolbar, overflow, подпись у иконки (iOS 27.1) |
| Если нужны несколько окон: ключ включён, навигация и выбор разделены по scene |
Фиксированные размеры и собственные расчёты frame | Контент помещается в доступное окно. Фиксированная ширина допустима, если не ломает узкие и широкие сценарии |
Доступность новых API | Обёртка |
SwiftUI App lifecycle | Проверяем |
Три частые проблемы
1. Две колонки только в альбомной ориентации
Окно может стать широким без изменения ориентации интерфейса. Если переключение колонок привязано к повороту, приложение продолжит показывать одну колонку. Для выбора layout используем size class и размеры контейнера.
2. Ширина элемента рассчитывается от UIScreen.main.bounds
Карточка или форма с шириной от экрана может не поместиться, когда окно занимает только часть дисплея. Проверяем расчёт относительно контейнера.
3. На широком экране просто растягивается одна колонка
Формально такая layout может ничего не обрезать, но длинные строки текста и растянутые ячейки не всегда удобны. Стоит проверить, можно ли использовать место для списка и выбранного элемента, боковой навигации или дополнительных карточек.
При этом две колонки нужны не каждому экрану. Для формы может быть достаточно ограничить ширину содержимого и оставить поля. Выбор зависит от контента и задачи пользователя.
Что проверить в работающем приложении
После grep по коду пройти основные сценарии в Device Hub, Mirroring или на симуляторе Duo.
Сохранение выбора и навигации пользователь воспринимает как норму, но система не гарантирует восстановление произвольного состояния. То, что должно пережить смену дисплея, fold или resize, хранят явно: @SceneStorage, отдельный store на scene, восстановление через NSUserActivity.
# | Сценарий | На что смотреть |
|---|---|---|
1 | Экран на внешнем дисплее → раскрыть → снова сложить | Выбор и стек навигации не сбрасываются |
2 | Ввод текста → изменить размер окна | Текст на месте, поле доступно, клавиатура не перекрывает действия |
3 | Уменьшить окно в Split View | Формы, колонки, кнопки помещаются или корректно уходят в overflow |
4 | Меню или кастомный popover → частично сложить | Панель не оказывается под сгибом и не обрезается камерой |
5 | Длинные подписи, Dynamic Type, вертикальный toolbar | Overflow действий, читаемость подписей |
6 | Два окна приложения с разным выбором | Окна не влияют друг на друга. На внешнем дисплее нет «Открыть в новом окне»; программный запрос scene обрабатывает ошибку |
7 |
|
|
8 | API 27.1 под | На системе ниже 27.1 нет краша и есть запасной путь |
9 | Строки про биометрию | Подпись по |
Сравним с Android foldable
Foldable на Android и iPhone Duo решают одну задачу: интерфейс должен пережить смену формы экрана и появление сгиба. На Android состояние сгиба разработчик получает раньше и явнее.
Задача | Android | iOS (Duo) |
|---|---|---|
Узнать про сгиб |
|
|
Узнать позу и угол |
|
|
Понять, сколько места |
| Size class и размеры контейнера сцены |
Разложить типичный экран | Разработчик сам, в том числе через готовые scaffold вроде | Системные контейнеры навигации; |
Обойти вырезы и камеры |
| Reserved regions |
Показать несколько окон | Multi-window вместе с состояниями сгиба | Split View двух приложений и несколько сцен на внутреннем дисплее |
Проверить на этапе разработки | Пресеты foldable в эмуляторе | Изменение размера окна в Xcode 27; симулятор Duo заявлен в Xcode 27.1 beta |
Главное отличие не в списке API, а в том, кто собирает layout. На Android сигнал сгиба приходит разработчику напрямую, и он сам решает, как расставить панели вокруг FoldingFeature. На iOS типичный UI собирают системные контейнеры, а сгиб описан как геометрия (reservedRegions), которую layout обходит при необходимости.
Для кастомного UI на обеих платформах нужно одно и то же: знать, где сгиб и сколько места осталось. Разница в том, сколько работы система берёт на себя до этого момента.
Модель Android: Adaptive Apps. Про insets на Android писал мой коллега в статье Готовим Window Inset под соусом Jetpack Compose и щепоткой View, рекомендую, если интересно сравнить.
Compose Multiplatform
У KMP-команд вопрос обычно звучит так: сколько адаптации закроет общий код. Структуру экрана действительно можно описать один раз: в commonMain для этого есть WindowSizeClass и контейнеры вроде ListDetailPaneScaffold. Колонки и брейкпоинты остаются общими для Android и iOS. Модель описана в документации Kotlin по adaptive layouts.
На iOS это уже закрывает resize окна и типичный list+detail. Платформенной остаётся работа со сгибом. На Android доступны FoldingFeature и поза устройства. На iOS reservedRegions, hinge и ArrangementView в Compose пока не обёрнуты. Добираться до них придётся через expect/actual или interop с UIKit. Рассчитывать, что общий код закроет сгиб на iOS сам, пока нельзя.
Заявка в YouTrack | Статус | Что покрывает |
|---|---|---|
CMP-10776 Ensure compatibility with iPhone Duo | Submitted, цель 1.13.0 | Insets, reserved regions, переход с внешнего дисплея на внутренний, drag-and-drop в Split View, частично открытое состояние |
CMP-8299 Support window insets rulers | Fixed | База insets на iOS, а не поддержка сгиба Duo |
Отдельной заявки только на hinge или ArrangementView в Compose нет: всё перечислено внутри CMP-10776.
Практический вывод для KMP такой: общий adaptive layout можно делать уже сейчас, а fold-aware поведение на iOS стоит планировать отдельно: либо ждать CMP-10776, либо закладывать iosMain с нативным interop.
Заключение
Duo продолжает линию iOS 27: приложение уже должно жить в окне изменяемого размера. Duo добавляет к этому второй дисплей и сгиб.
Есть обязательная база. При сборке с iOS 27 SDK нужен scene lifecycle, размеры элементов стоит считать от контейнера вместо UIScreen.main, структуру интерфейса выбирать по size class, а не по ориентации и userInterfaceIdiom, навигацию строить на системных контейнерах. Всё это нужно и без Duo. Такое же поведение видно в iPhone Mirroring и при запуске iPhone-only приложения на iPad. Одна пересборка с 27.1 SDK чеклист аудита не закрывает.
Остальное подключают по необходимости. reservedRegions пригодятся в своей вёрстке, ArrangementView для двух блоков контента, плюс несколько scene, scene accessories и работа с камерами. Добавлять их стоит под конкретный сценарий, а не ради галочки.
До выхода 27.1 можно пройти аудит по чеклисту, убрать расчёты от экрана и ориентации и проверить приложение при изменении размера окна на iPad и в Mirroring. Симулятор Duo для этой части не нужен. В KMP-проектах сюда же входит общий layout по WindowSizeClass. Поддержка сгиба на iOS ждёт CMP-10776.
После выхода SDK останется перепроверить названия и доступность API, условия accessories и виртуальной камеры, поведение при складывании на симуляторе или устройстве.
Кстати, мои коллеги тоже пишут о мобильной разработке. Рекомендую:
За 3 дня запустить Android приложение на iOS: опыт адаптации приложения под CMP
Compose Multiplatform 1.8.0: поддержка iOS переходит в stable
40 ударов палкой и Kotlin Multiplatform: как устроена мобильная разработка в Катаре (интервью с Сергеем Раковым, разработчиком в Snoonu — катарском IT-гиганте)
KMP, догфудинг и велосипеды в стартапе американской версии «Кухни на районе» (интервью с Сеней Суздальницким, CTO Sizl — стартапа доставки еды в Чикаго)
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.