iPhone Duo: адаптация приложения
Разбор шести сессий Apple Developer и Human Interface Guidelines. Каждая фича разделена на дизайн и разработку, со схемами Apple, тайм-кодами видео и общим критерием готовности.
Часть 0 · Контекст устройства
БАЗА Что такое iPhone Duo
Читают все. Два дисплея, шарнир посередине, две фронтальные камеры. Приложение должно жить во всех позах без отдельного макета под каждую.
📎 Референсы
- Видео: 00:27Design for iPhone Duo — обзор устройства и поз
- HIG: Anatomy · Device poses · Best practices
Аппаратно
- Два дисплея. Внешний (устройство закрыто) — шире и ниже обычного iPhone. Внутренний (открыт) — самый большой экран на iPhone.
- Шарнир посередине внутреннего дисплея.
- Две фронтальные камеры. Внешняя — в углу, всегда видима, разворачивается в Dynamic Island. Внутренняя — первая under-display камера на iPhone, невидима пока не активна.
Size classes — единая система координат
| Состояние | Horizontal | Vertical |
|---|---|---|
| Внешний, portrait | Compact | Regular |
| Внешний, landscape | Compact | Compact |
| Внутренний (обе ориентации) | Regular | Regular |
Три железных правила
- Функционал одинаковый во всех позах и на обоих дисплеях. Нельзя привязывать фичу к позе.
- Иерархия информации не меняется между дисплеями. На внутреннем можно показать дополнительный уровень той же иерархии, но не другую структуру.
- Люди складывают и раскладывают устройство постоянно во время работы. Переходы должны быть предсказуемыми.
🖼 Кадры из сессий Apple (4)
Часть 1 · Фундамент
F1 Resizability и size classes
Суть. Приложение должно свободно ресайзиться. Это предпосылка для всего остального — без неё не работает ничего ниже.
📎 Референсы
- Prepare your app for iPhone Duo:
01:38 принципы гибкого layout ·
02:47 size classes ·
03:31 ориентации ·
03:58 отказ от
UIScreen.main· 04:19 concentricity - 03:42Design for iPhone Duo — две цели: compact и regular width
- HIG: Dynamic layouts · Size classes
- Проектируем два состояния — compact width и regular width. Всё остальное должно «вытекать» само.
- Запрещаем себе: фиксированные ширины, собственные брейкпоинты под конкретные девайсы, макеты «под 393pt».
- Не «переизобретаем» приложение при ресайзе. Существующий макет расширяется под доступное пространство, а не перестраивается во что-то другое.
- Строим всё на layout margins и safe area insets — это общий язык с разработкой.
Если у вас уже есть iPad-версия или поддержка iPhone Mirroring — 80% работы сделано.
// SwiftUI
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
// UIKit
traitCollection.horizontalSizeClass
traitCollection.verticalSizeClass
Что удалить из кодовой базы
UIScreen.main— будет deprecated. На двухэкранном устройстве это неоднозначно. Вместо: environment,traitCollection,scene.bounds, или динамическиwindowScene.screen.- Проверки
UIDevice.userInterfaceIdiomдля layout-решений. - Проверки interface orientation для layout-решений.
- Фиксированные константы ширины экрана.
Concentricity — форма углов на Duo другая
ConcentricRectangle() // SwiftUI
UICornerConfiguration // UIKit
UIRequiresFullScreen не спасает: приложение всё равно ресайзится при открытии и закрытии и масштабируется в Split View.
✅ Готово, когда приложение корректно выглядит во всех шести позах в Device Hub без единой проверки idiom, orientation или screen в layout-коде.
🖼 Кадры из сессий Apple (4)
ConcentricRectangle. Радиусы углов на Duo отличаются — не хардкодьте corner radius. ▶ 4:25F2 Safe area и layout margins
Суть. Из-за вертикальных контролов сбоку safe area на Duo несимметрична. Это источник бага №1.
📎 Референсы
- Prepare your app: 06:06 safe area и margins · 07:26 асимметричные инсеты · 08:08 Reserved Region API
- 06:33Design for iPhone Duo — три схемы выравнивания на внешнем дисплее
- API:
GeometryProxy.safeAreaInsets·UIView.safeAreaInsets - Для макетов: Apple Design Resources — точные значения margins и safe areas
- В макетах всегда помечайте отдельно левую и правую safe area — они разные.
- В Split View контролы левого приложения уходят влево, правого — вправо. Зеркальный случай тоже нужно нарисовать.
- Layout margins тоже асимметричны: контент может подойти ближе к вертикальному бару, сохранив полное поле с противоположной стороны.
Разделяйте слои в макете
| Слой | Правило |
|---|---|
| Интерактив, текст, управление | внутри safe area |
| Фон, артворк, hero-изображение | edge-to-edge, под safe area |
Три схемы выравнивания на внешнем дисплее
- Автоофсет (default) — контент инсетнут по horizontal safe area, не прячется под контролами. Подходит почти всем.
- Центрирование по всему экрану, без офсета — только для иммерсивных визуальных нескроллящихся интерфейсов. Условие: интерактив не попадает под правый рейл.
- Гибрид — фон и хедер на всю ширину, скроллящийся контент инсетнут. Условие: весь интерактив живёт внутри скроллящейся области.
let width = view.bounds.width - view.safeAreaInsets.left * 2let width = view.bounds.inset(by: view.safeAreaInsets).width// Foreground — в пределах safe area
foreground.frame = view.bounds.inset(by: view.safeAreaInsets)
// Background — edge-to-edge
backgroundView.frame = view.bounds // UIKit
BackgroundView().ignoresSafeArea() // SwiftUI
Свои кастомные бары — новый API iOS 27.1
Позволяет выйти за safe area, не конфликтуя с системным UI:
ReservedRegion // SwiftUI
UIViewReservedRegion // UIKit
✅ Готово, когда grep по кодовой базе не находит ни одного safeAreaInsets.left * 2 и подобных допущений, а макеты имеют оба зеркальных состояния для Split View.
🖼 Кадры из сессий Apple (4)
view.bounds.inset(by: view.safeAreaInsets) вместо арифметики с одним инсетом. ▶ 6:45
Часть 2 · Вертикальные бары
F3 Toolbar / Tab bar / Navigation на вертикальной оси
Суть. На внешнем дисплее и на внутреннем в landscape toolbar, tab bar, navigation controls, status bar и Dynamic Island переезжают в вертикальную полосу справа. Исключение: внутренний дисплей в portrait — там остаются привычные горизонтальные бары.
📎 Референсы — основное видео: Raise the bar with iPhone Duo
- Raise the bar (YouTube · Tech Talk 111462): 00:21 зачем контролы уехали вбок · 02:00 как включить · 03:02 где полоса есть, а где нет · 04:36 порядок элементов · 05:52 какой контент годится для вертикали · 08:32 axisBehavior · 09:25 бейджи · 10:36 toolbarVerticalEdge · 14:21 когда отключать
- Design for iPhone Duo: 01:02 принцип боковой полосы · 05:07 состав полосы · 06:03 маппинг существующих контролов
- HIG: Vertical controls · Toolbars
- API:
Label·UIBarButtonItem·ToolbarItemGroup·UIBarButtonItemGroup
Зачем: ширина есть, высоты мало. Контролы сбоку освобождают вертикаль под контент и попадают под большой палец правой руки.
Что делит вертикальную полосу, сверху вниз
- Live Activities и Dynamic Island (система)
- Status bar (система)
- Ваши navigation-контролы — Back / Close
- Prominent action — Done
- Остальные toolbar-элементы в своих группах
- Tab bar — прижат к низу
Система сама вставляет вертикальный разделитель между тем, что было в верхнем баре, и тем, что было в нижнем.
Маппинг существующего приложения
| Было | Стало |
|---|---|
| Top bar actions | верх вертикальной полосы |
| Bottom toolbar actions | низ вертикальной полосы |
| Tab bar | низ, под тулбаром |
Что НЕ переезжает
- Текстовые кнопки («Select», «Edit»)
- Segmented controls
- Всё, что слишком широко для фиксированной ширины полосы
- Accessory bar над клавиатурой — остаётся прикреплённым к клавиатуре
Правила дизайна
- Вертикальная полоса = symbol-only. Горизонтальный бар: фиксированная высота, гибкая ширина. Вертикальный: фиксированная ширина, гибкая высота. Поэтому туда идут только иконки.
- Всегда задавайте и название, и символ для каждого нетекстового элемента. Название нужно системе для overflow-меню и развёрнутых форм, даже если вы его нигде не видите.
- Минимизируйте текстовые кнопки. Тест: текст дублирует смысл символа → выбросьте текст. Текст несёт самостоятельную информацию (сумма в корзине) → оставьте элемент горизонтальным.
- Паттерн «текст+иконка → иконка+бейдж»: вместо «Inbox 7» inline — иконка конверта с бейджем 7.
- Группируйте, не расставляйте вручную. Никаких кастомных спейсеров — группы сами дают правильные отступы.
- Держите позиции контролов постоянными между позами, чтобы человек не учил приложение заново при складывании.
- RTL: полоса привязана к железу и остаётся на той же физической стороне. Зеркалится только контент.
- Reduce Transparency: вертикальная полоса получает фон. Проверьте читаемость кастомного содержимого.
Где полоса появляется, а где нет
| Контекст | Поведение |
|---|---|
| Split view | только detail-колонка; остальные — горизонтальные |
| Inspector (развёрнутый) | не получает собственной полосы |
| Sheet на внешнем дисплее | вертикальная полоса |
| Sheet на внутреннем | центрирован, горизонтальные бары |
| Sheet, смещённый вправо | получает вертикальную полосу |
| Sheet, смещённый влево | не получает |
Когда отключать — редко
- Одностраничное приложение с «тяжёлым низом» — калькулятор.
- Sheet с единственной кнопкой Close — полоса съедает пространство зря. Тогда sheet останавливается чуть ниже фронтальной камеры, а status bar перепозиционируется.
Включение — два шага, оба обязательны
1. Пересобрать против iOS 27.1 SDK. 2. Использовать бары от навигационных контейнеров:
// SwiftUI ✅
NavigationStack {
ContentView()
.toolbar {
ToolbarItem(placement: .bottomBar) { … }
}
}
// UIKit ✅
let vc = UIViewController()
vc.toolbarItems = [...]
let nav = UINavigationController(rootViewController: vc)
// UIKit ❌ — содержимое будет проигнорировано, ошибки не будет
let toolbar = UIToolbar()
toolbar.items = [...]
UIToolbar, UINavigationBar, UITabBar молча не получат вертикальную раскладку.Размещение элементов
// Back / Close
.toolbar { ToolbarItem(placement: .cancellationAction) { … } }
// UIKit:
navigationItem.leftItemsSupplementBackButton = false // default
navigationItem.leadingItemGroups = [UIBarButtonItemGroup(...)]
// Prominent action (Done)
.toolbar { ToolbarItem(placement: .topBarPinnedTrailing) { … } }
// UIKit:
navigationItem.pinnedTrailingGroup = UIBarButtonItemGroup(...)
Управление осью вручную
// Элемент, переключающийся текст ↔ символ (кастомный Select/Done)
.axisBehavior(.horizontalOnly)
item.axisBehavior = .horizontalOnly
// Кастомный view, умеющий вертикальное представление (напр. компас)
.axisBehavior(.verticalPreferred)
let item = UIBarButtonItem(customView: CompassView())
item.axisBehavior = .verticalPreferred
Системная Edit-кнопка уже ведёт себя правильно автоматически.
Бейджи (iOS 26+)
InboxButton().badge(7) // SwiftUI
item.badge = .count(7) // UIKit
Чтение контекста в кастомных view
@Environment(\.toolbarVerticalEdge) var edge // SwiftUI
traitCollection.verticalBarEdge // UIKit
// nil / .unspecified — когда вертикального бара нет
Кастомные view, идущие вертикально, должны либо влезать в фиксированную ширину, либо иметь вертикально адаптированную раскладку — например прятать подписи и становиться ниже.
Спейсеры: flexible в вертикальной оси = нулевой размер; fixed уважают минимум. Используйте ToolbarItemGroup / UIBarButtonItemGroup.
Отключение
.toolbarVerticalBehavior(.disabled) // SwiftUI
override var preferredVerticalBarBehavior: UIVerticalBarBehavior { .disabled } // UIKit
✅ Готово, когда нет ни одного ручного UIToolbar, каждый нетекстовый item имеет title и symbol, RTL и Reduce Transparency проверены.
🖼 Кадры из сессий Apple (9)
ToolbarItem(placement: .cancellationAction) и navigationItem.leadingItemGroups. ▶ 5:05
.topBarPinnedTrailing / pinnedTrailingGroup. ▶ 5:25
toolbarItems / navigationItem получают вертикальную раскладку. ❌ Свой UIToolbar — нет, и молча. ▶ 2:45
@Environment(\.toolbarVerticalEdge) / traitCollection.verticalBarEdge. ▶ 10:45
.axisBehavior(.horizontalOnly) — элемент, который не должен уезжать в вертикальный бар. ▶ 8:35
.axisBehavior(.verticalPreferred) — наоборот, элемент, которому в вертикальном баре лучше. ▶ 8:55
F4 Overflow и компрессия баров
Суть. На внешнем дисплее в landscape вертикали мало, ещё и клавиатура с PiP конкурируют за место. Переполнение — норма, а не край.
📎 Референсы
- Raise the bar: 11:40 управление overflow · 12:23 compression behavior · 12:43 слияние меню · 13:44 visibility priority
- HIG: Vertical controls
- API:
ToolbarOverflowMenu·additionalOverflowItems·ToolbarItemVisibilityPriority·UIBarButtonItemVisibilityPriority
Решение №1 — чем жертвуем первым, toolbar или tab bar?
Это продуктовое решение, принимается для каждого экрана отдельно.
| Тип экрана | Что сжимается первым | Логика |
|---|---|---|
| Navigation-focused лента, каталог | toolbar → в overflow | главные разделы должны оставаться доступными. Это дефолт. |
| Task-oriented экран выполнения задачи | tab bar минимизируется | действия для задачи важнее переключения разделов |
Решение №2 — приоритеты видимости
По умолчанию элементы уходят в overflow снизу вверх. Нужно явно сказать системе, что важнее:
- Дольше всего остаются частые действия: Compose в почте, New Note в заметках.
- Также долго остаются элементы со статусом — всё, что имеет бейдж, потому что их смысл в glanceability.
- Приоритизируйте сначала группами, потом отдельными элементами внутри группы.
Решение №3 — одно overflow-меню
- Собственное «…» меню нужно влить в системное, чтобы человек искал всё в одном месте.
- Эллипсис зарезервирован исключительно под overflow.
- Другим меню дайте собственный, содержательный символ.
- Не тащите символы с других платформ (kebab, hamburger).
// Приоритет toolbar vs tab bar — на уровне экрана
TabView {
Tab("Recents", systemImage: "clock") {
ContentView()
.toolbarVerticalCompressionBehavior(.prefersToolbarItems)
}
}
// UIKit:
navigationItem.verticalBarCompressionBehavior = .prefersBarItems
// Слияние собственного overflow с системным
.toolbar {
ToolbarOverflowMenu {
Button("Scan") { … }
Button("Connect") { … }
}
}
// UIKit:
navigationItem.additionalOverflowItems = UIDeferredMenuElement { provider in
provider(self.persistentOverflowItems())
}
// Приоритеты видимости
.toolbar {
ToolbarItem { Button(…) { … } }
.visibilityPriority(.high) // .high / .low / кастомное
}
// UIKit:
item.visibilityPriority = .high
✅ Готово, когда для каждого экрана осознанно выбран compression behavior, у всех toolbar items выставлен приоритет, собственного overflow-меню в приложении больше не существует.
🖼 Кадры из сессий Apple (5)
toolbarVerticalCompressionBehavior / verticalBarCompressionBehavior — что сжимать первым. ▶ 12:25
ToolbarOverflowMenu (SwiftUI) и additionalOverflowItems (UIKit). ▶ 12:45
.visibilityPriority(.high / .low / custom) решает, кто останется на виду. ▶ 13:45
Часть 3 · Сгиб и адаптивные контейнеры
F5 Reserved regions и displacement
Суть. На Duo пространство формируют не только safe areas, но и reserved regions — зоны, которые контент должен обходить.
📎 Референсы — основное видео: Strike a pose with adaptive layouts
- Strike a pose (YouTube · Tech Talk 111463): 00:42 что такое reserved regions · 01:42 аналогия с книжным корешком · 02:26 принцип displacement · 03:46 что НЕ смещается · 04:00 куда двигать по позам · 05:40 split view 50/50 · 05:51 пример с сеткой · 06:38 API
- Design for iPhone Duo: 02:03 поведение контента у сгиба · 08:37 fold-avoidance в системных компонентах
- HIG: Reserved regions
Три reserved regions
| Регион | Тип | Когда активен |
|---|---|---|
| Внешняя фронт-камера | occlusion | всегда, разворачивается в Dynamic Island |
| Внутренняя фронт-камера | occlusion | только когда камера активна — UI разъезжается, обозначая её присутствие |
| Сгиб (fold) | division | только когда устройство частично сложено — делит дисплей на две рабочие зоны |
Ментальная модель: это то же, к чему ваш макет уже адаптируется на iPad (window controls).
Displacement — паттерн смещения
Цель формулируется тремя словами: контент должен оставаться visible, reachable, unobstructed.
| Не смещается | Смещается |
|---|---|
| Непрерывный скролл-контент: статьи, ленты, документы, списки, фотосетки. Они уже адаптируются прокруткой — смещение ломает непрерывность. | Алерты, меню, попапы, action sheets, отдельные контролы, иногда целые контейнеры. |
Правила смещения
- Выбирайте scope. Элемент, адаптирующийся самостоятельно — двигайте отдельно. Связанные элементы — вместе. Пример: контекстное меню фотографии двигается вместе с самой фотографией и выравнивается у сгиба, а не улетает отдельно в правую половину.
- Избегайте избыточного движения. Далёкое смещение разрушает визуальную связь между элементом и его источником.
- Избегайте резких изменений. Контрол, который исчезает или прыгает далеко, сложнее найти. Мелкая корректировка лучше перестановки.
Куда двигать — зависит от позы
| Поза | Логика размещения |
|---|---|
| «Книжка» | алерты уходят на trailing-сторону — туда, где они продолжат жизнь, когда устройство закроют |
| Tabletop | верх — контент, который смотрят издалека. Низ — тапабельные контролы: стабильная поверхность для касания |
| Любая | приоритет — контекстность: поле поиска остаётся над тем view, который ищет, меняя ширину и позицию |
- Адаптируется не только позиция и размер, но и margins, corner radius.
- Сетки: если на экране может появиться сгиб — предпочитайте чётное количество колонок. Решение принимается один раз, независимо от того, активен ли сгиб сейчас.
- Grid-пример: сохраняем внешние поля и увеличиваем отступ вокруг сгиба, чтобы каждая карточка осталась внутри своей зоны и тапабельной.
- Split view: система сама подгоняет ширины колонок к ровному 50/50 вокруг сгиба.
Notes до и после складывания — эталонный пример
// SwiftUI — через GeometryReader или onGeometryChange
GeometryReader { proxy in
let regions = proxy.reservedRegions(kind: .division)
let frames = regions.map(\.frame)
}
// Включая неактивные
proxy.reservedRegions(kind: .division, options: .includeInactive)
// Камеры
proxy.reservedRegions(kind: .occlusion)
// UIKit
let regions = view.reservedRegions(kind: .division)
- Division region делит площадь на несколько зон. Активен только при сложенном устройстве; когда плоское — неактивен, ширина 0.
- Occlusion region не делит, а перекрывает — маленький фрейм внутри ваших bounds.
- Зачем
includeInactive: позволяет принимать решения высокого уровня заранее. Например, выбрать чётное количество колонок в сетке по одному факту наличия division region, не дожидаясь складывания.
✅ Готово, когда все центрированные макеты проаудированы, высокоприоритетные кастомные контролы используют reserved regions API, сетки имеют чётное количество колонок там, где это уместно.
🖼 Кадры из сессий Apple (7)
proxy.reservedRegions(kind: .division) / view.reservedRegions(kind:). ▶ 6:55
options: .includeInactive — чтобы получить регион даже когда он сейчас неактивен (например, камера выключена). ▶ 7:25
ReservedRegion / UIViewReservedRegion — новое в iOS 27.1. ▶ 8:25F6 ArrangementView — новый layout-контейнер
Суть. Контейнер, который держит primary и secondary view и сам решает, как их разложить, учитывая size class, соотношение сторон и активные reserved regions.
📎 Референсы
- Strike a pose: 08:21 место layout-контейнеров в иерархии · 09:33 разбор на примере Podcasts · 10:31 входы и выходы · 11:20 код · 12:34 ограничение оси · 13:16 overlay · 14:32 как выбрать стиль · 16:08 когда НЕ использовать
- HIG: Arrangement views
- API:
HStack·VStack·ZStack— исходные паттерны для переноса
Место в иерархии контейнеров
Навигационные: NavigationStack · NavigationSplitView · TabView
Layout: ArrangementView ← новый уровень
Контентные: List · ScrollView
Что это даёт — пример с плеером и транскриптом
- iPad, широко → плеер и транскрипт рядом
- Duo открыт → то же самое
- Duo сложен, транскрипт выключен → плеер не центрируется по всему экрану, а остаётся в своей левой зоне, чтобы контролы были достижимы и не попадали на сгиб
- Duo повёрнут вертикально → split не применяется вовсе, транскрипт показывается inline
Два стиля
| Стиль | Поведение | Когда выбирать |
|---|---|---|
| Split дефолт | делит площадь между двумя view. Горизонтально — если шире высоты; вертикально — если выше ширины | main / detail связь. Ни один из двух view нельзя перекрывать. Пример: плеер + транскрипт |
| Overlay | накладывает один на другой. При складывании — расходятся по половинам | foreground / background связь. Частичное перекрытие приемлемо, фон можно проскроллить. Пример: контролы читалки поверх текста |
Как выбрать — правило переноса
| У вас сейчас | Берите |
|---|---|
HStack / VStack-подобный макет | Split |
ZStack-подобный макет | Overlay |
| Ничего похожего нет | по типу связи: main/detail → split, foreground/background → overlay |
- Можно ограничить ось: «делиться только горизонтально». Если split не может разделиться вдоль основной оси — покажется только один view. Это нужно нарисовать.
- В overlay вторичный view может иметь свёрнутое и развёрнутое состояние — при складывании он получает больше места и может раскрыться. Оба состояния должны быть в макете.
- Это не замена NavigationSplitView. Нужна навигация со сворачиванием колонок — берите split view.
// SwiftUI
NavigationStack {
ArrangementView {
PlayerView()
} secondary: {
UpNextView()
}
.arrangementViewStyle(.split.axes(.horizontal)) // или .overlay
}
// UIKit
let arrangementVC = UIArrangementViewController()
let nav = UINavigationController(rootViewController: arrangementVC)
arrangementVC.setViewController(playerVC, for: .primary)
arrangementVC.setViewController(upNextVC, for: .secondary)
arrangementVC.updateArrangement(.split.axes(.horizontal))
Overlay: читаем z-index, чтобы изменить представление
struct UpNextView: View {
@Environment(\.overlayArrangementZIndex) private var zIndex: Int
var minimization: UpNextMinimization { zIndex > 0 ? .collapsed : .expanded }
var body: some View { UpNextList(minimization: minimization) }
}
// UIKit
let primaryState = arrangementVC.state(for: .primary)
model.minimization = (primaryState?.zIndex ?? 0) > 0 ? .collapsed : .expanded
- Не кладите навигационные контейнеры (
NavigationSplitView,TabView) внутрь ArrangementView — он не даёт навигационной инфраструктуры. Ставьте их вокруг. - Не кладите ArrangementView внутрь
ListилиScrollView.
✅ Готово, когда кастомные двухколоночные и наложенные макеты переведены на ArrangementView, навигация осталась снаружи, оба состояния secondary view нарисованы и реализованы.
🖼 Кадры из сессий Apple (10)
ArrangementView — новый layout-контейнер. ▶ 9:25
.arrangementViewStyle(.split) — две панели рядом. ▶ 12:15
.split в другой позе — система сама переключает ось. ▶ 12:35
.split.axes(.horizontal) — ограничить ось; при нехватке места вторая панель схлопывается. ▶ 12:45
.arrangementViewStyle(.overlay) — вторичный контент поверх основного. ▶ 13:45
overlayArrangementZIndex — узнать, находится ли ваша вью сверху, и подстроить её плотность. ▶ 14:05
Часть 4 · Навигация и презентации
F7 Внутренний дисплей — три стратегии
Суть. Нельзя просто растянуть iPhone-приложение на большой экран.
📎 Референсы
- 07:34Design for iPhone Duo — все три стратегии подряд: split view (Mail) → reflow в колонки (Music) → tab bar как sidebar (Health)
- 05:13Prepare your app — навигационные контейнеры и sidebar
- HIG: Split views (Duo) · Split views (общее)
- API:
NavigationSplitView·UISplitViewController
| Стратегия | Что делает | Кому подходит | Пример |
|---|---|---|---|
| 1. Split view | показывает несколько уровней иерархии одновременно | приложения со списком и деталью | |
| 2. Reflow в колонки | вертикальный стек перестраивается в 2 колонки | контентные экраны со сложенным хедером | Music |
| 3. Tab bar → Sidebar | нижний таббар становится левым сайдбаром | только info-dense приложения | Health |
Mail — эталон стратегии №1
Ограничения, общие для всех трёх
- Иерархия не меняется между внешним и внутренним дисплеем. Split view просто показывает оба уровня сразу вместо одного.
- Ни одна функция не привязана к одному дисплею.
- Sidebar — не для всех. Если у вас 3–4 простых таба, сайдбар лишний.
- Split view на внешнем дисплее сворачивается в одну колонку — так же, как между regular и compact на любом iPhone.
// Split
NavigationSplitView { … } detail: { … } // SwiftUI
UISplitViewController // UIKit
// Tab bar как sidebar
TabView { … }.defaultTabBarPlacement(.sidebar) // SwiftUI
tabBarController.sidebar.preferredPlacement = .sidebar // UIKit
Split view, собранный из стандартных компонентов, сам адаптирует ширины колонок и поля под сгиб.
✅ Готово, когда для внутреннего дисплея осознанно выбрана одна из стратегий, а иерархия идентична на обоих дисплеях.
F8 Sheets, alerts, menus, popovers
Суть. Все презентации адаптируются к позе автоматически — если они системные.
📎 Референсы
- 08:37Design for iPhone Duo — поведение sheets во всех позах и fold-avoidance
- 03:59Raise the bar — когда sheet получает вертикальную полосу
- 05:23Strike a pose — автоперепозиционирование popover вокруг сгиба
- 05:52Prepare your app — sheets, popovers, меню, алерты по позам
| Контекст | Поведение sheet |
|---|---|
| Внешний дисплей | контролы переезжают в вертикальную полосу; кнопки могут раскладываться вертикально |
| Внешний, sheet с одной кнопкой | можно отключить вертикальную полосу → sheet останавливается чуть ниже фронт-камеры, status bar перепозиционируется |
| Внутренний, portrait и landscape | центрирован, стандартные горизонтальные бары |
| Частично сложен | съезжает вбок со сгиба |
Fold-avoidance встроен в: sheets, alerts, menus, action sheets, popovers, toolbar buttons и другие системные компоненты. Кнопки, попадающие на сгиб, трудно нажать — поэтому система их отодвигает.
- Системные
.sheet,.alert,.confirmationDialog,.popover,UIMenu— из коробки. - Для кастомных оверлеев —
reservedRegions(kind: .division)и смещение вручную. - Управление наличием бара в sheet — через
toolbarVerticalBehavior(.disabled)/preferredVerticalBarBehavior.
✅ Готово, когда нет ни одного кастомного модального слоя без обработки division region.
🖼 Кадры из сессий Apple (1)
Часть 5 · Позы и уникальные возможности
F9 Опциональный tabletop-layout
Суть. Единственный случай, когда Apple разрешает спецмакет под позу.
📎 Референсы
- 04:36Design for iPhone Duo — пример Apple Music: лирика сверху, транспорт снизу + условие функциональной эквивалентности
- 04:23Strike a pose — логика «верх смотреть, низ трогать»
- HIG: Device poses
Устройство стоит на столе, руки свободны. Естественное распределение:
- Верхняя половина — медиа и контент, который смотрят (обложка, видео, лирика)
- Нижняя половина — тапабельные контролы на стабильной поверхности (transport controls, скраббер, клавиатура)
Это опция, а не требование. Если приложение не медийное — пропускайте.
Реализуется через reserved regions (.division) или overlay-arrangement, а не через угол шарнира.
✅ Готово, когда либо макет не делался вообще, либо он функционально эквивалентен другим позам.
F10 Split View multitasking и PiP
Суть. Все приложения участвуют, отказаться нельзя. Контролы уходят к внешнему краю.
📎 Референсы
- 02:43Design for iPhone Duo — жест 50/50, контролы к внешнему краю, PiP с прикреплением к верху
- Leverage multiple displays: 02:59 две раскладки · 03:25 инструменты
- 07:51Prepare your app — как тестировать в симуляторе
- Две раскладки: 50/50 рядом и стопкой (видео сверху + приложение снизу). Для вас они ведут себя одинаково.
- Жест: перетащить приложение от home-индикатора к краю.
- PiP: видео можно прикрепить к верху экрана. Приложение ресайзится вертикально в реальном времени. При складывании видео растягивается на половину экрана — приложение снова подстраивается.
- В макетах нужны зеркальное состояние (контролы слева) и состояние уменьшенной высоты (PiP сверху).
- Инструменты те же: size classes + scene geometry. Ничего специального.
- Если работает resizing на iPad или в iPhone Mirroring — базово уже работает.
- Тест в симуляторе: открыть на внутреннем дисплее в Device Hub, потянуть от home-индикатора вбок. Проследить, как вертикальные бары перепрыгивают на противоположную сторону.
✅ Готово, когда приложение корректно в левой и правой половине и при сжатии по высоте через PiP.
F11 Несколько сцен (multiple windows)
Суть. Duo — первый iPhone с несколькими одновременными инстансами UI приложения. Но с важным ограничением.
📎 Референсы
Значит кнопка «открыть в новом окне» должна исчезать, а не быть неактивной или давать ошибку.
- Если поддерживаете multiple scenes на iPad — работает и здесь.
- Доступность динамическая — в отличие от iPad, где окно можно создать в любой момент.
- Обрабатывайте ошибки создания сцены.
- Используйте
UIWindowScene.ActivationAction— он автоматически прячется, когда создание недоступно.
✅ Готово, когда нет ни одного пути, где запрос новой сцены на внешнем дисплее даёт ошибку пользователю.
F12 Hinge API — интеракции и эффекты
Суть. Угол шарнира доступен непрерывно, не только как «открыто/закрыто».
📎 Референсы
- Системный пример: обои плавно зумятся во время раскрытия.
- Пример из сессии: гитарное приложение, где сгиб работает как рычаг вибрато и плавно гнёт высоту звука.
- Эффект должен быть дополнением, а не единственным способом что-то сделать — правило «функционал не привязан к позе».
struct InstrumentView: View {
@State private var pitchBend: Double = 0
var body: some View {
GuitarView(pitchBend: pitchBend)
.onHingeChange { _, context in
if let hinge = context.hinge, hinge.status == .partiallyOpen {
pitchBend = calculatePitchBend(angle: hinge.angle)
} else {
pitchBend = 0 // обязательный сброс
}
}
}
}
- UIKit:
UIHingeInteraction. - Даёт дискретный
status:.closed/.partiallyOpen/.fullyOpen, и непрерывныйangle. context.hinge == nil→ устройство без шарнира. Обрабатывать обязательно.- Не забывайте
else-ветку со сбросом состояния.
✅ Готово, когда эффект корректно деградирует на устройствах без шарнира, а layout от угла не зависит.
🖼 Кадры из сессий Apple (3)
F13 Scene accessories — контент на двух экранах
Суть. Дополнительный контент на втором дисплее параллельно с основным UI.
📎 Референсы
- Leverage multiple displays: 04:26 что такое scene accessories · 05:00 CameraCaptureAccessory и его условия · 05:43 демо телесуфлёра · 06:10 переключатель и доступность
- 05:27Build a great camera experience — связка с камерой
- Общий кейс (iPhone/iPad): телефон как геймпад, игра на внешнем экране.
- Новый кейс для Duo —
CameraCaptureAccessory: основное UI камеры на внутреннем дисплее, а на внешнем — что-то для человека, которого снимают:- Телесуфлёр для того, кто записывает видео
- Мультик или анимация, чтобы ребёнок смотрел в камеру
- Превью для модели во время съёмки
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)
}
}
}
Условия доступности CameraCaptureAccessory
- приложение fullscreen на внутреннем дисплее
- активная camera session
- регистрировать на том же view, что и камерный UI — тогда аксессуар живёт ровно столько, сколько видна камера
✅ Готово, когда UI переключателя корректно реагирует на все изменения доступности, включая закрытие устройства во время записи.
🖼 Кадры из сессий Apple (4)
CameraCaptureAccessory: fullscreen на внутреннем дисплее, активная camera session, регистрация на той же вью. ▶ 5:25
onAvailabilityChange и связанный toggle в toolbar. ▶ 6:25F14 Камера
Суть. Две фронтальные камеры с квадратным сенсором. «Фронтальная» больше не означает «смотрит на пользователя».
📎 Референсы — основное видео: Build a great camera experience
- Build a great camera experience (YouTube):
00:30 две фронтальные камеры ·
01:14 виртуальная фронтальная ·
02:10 таблица возможностей и цена простоты ·
03:14 почему
.frontбольше не значит «смотрит на вас» · 04:02 direction coordinator · 05:38 concurrency · 06:34 зеркалирование · 07:11 компоновка превью · 08:38 оптимизация - Дока: AVFoundation — «Choosing a camera by the direction it faces», «Supporting device rotation in your camera app»
| Камера | Разрешение | FPS | Depth |
|---|---|---|---|
| Внутренняя (under-display) | 1080p | 60 | ✅ |
| Внешняя | 4K | 120 | ✅ |
| «Виртуальная фронтальная» авто-переключение | 1080p | 60 | ❌ |
Продуктовое решение №1 — простота или возможности
Брать простую «виртуальную фронтальную» камеру (сама переключается при открытии и закрытии) или управлять обеими вручную? Цена простоты — общий знаменатель возможностей и отсутствие depth: нет портретного режима и эффектов глубины.
Продуктовое решение №2 — зеркалирование
Дисплеи смотрят в разные стороны, поэтому «фронтальная камера» больше не означает «смотрит на пользователя». Когда задняя камера становится той, что смотрит на человека (устройство перевернули в открытом состоянии) — превью нужно зеркалить, чтобы селфи ощущалось естественно.
Превью на внутреннем дисплее
При полном поле зрения задней камеры вокруг превью остаётся свободное пространство. Два варианта, выбирает дизайн:
- Сдвинуть превью и сгруппировать контролы в свободной зоне
- Растянуть превью на весь дисплей
Квадратный сенсор фронталок позволяет заполнить экран landscape-кадром на внутреннем дисплее. Внутренняя камера — occlusion region: когда неактивна, её не видно; при активации UI разъезжается. Если видоискатель центральный для вашего опыта — держите важный контент и контролы подальше от этой зоны.
// Типы устройств
.builtInOuterUltrawideCamera // новый
.builtInInnerUltrawideCamera // новый
// Виртуальная: обычный DiscoverySession с position: .front + wide/ultrawide
// Direction coordinator — где камеры относительно КОНКРЕТНОГО view
directionCoordinator = AVCaptureDeviceDirectionCoordinator(
view: view,
deviceTypes: [.builtInOuterUltrawideCamera,
.builtInInnerUltrawideCamera,
.builtInDualWideCamera],
changeHandler: { [weak self] map in self?.updateCameraSession(map) }
)
Правила concurrency
- Coordinator изолирован к Main Actor.
- В change handler не вызывайте AVFoundation напрямую. Он отдаёт
AVCaptureDeviceDescriptor— Sendable-представление, которое безопасно передаётся в ваш camera actor, и уже там создаётсяAVCaptureDevice. - Используете оба дисплея одновременно — отдельный coordinator на каждый view; каждый отчитывается относительно своего view.
В change handler три вещи: переконфигурировать AVCaptureSession · пересмотреть решение о зеркалировании · обновить UI.
Превью и производительность
previewLayer.videoGravity // как превью ложится в bounds слоя
device.dynamicAspectRatio // landscape-соотношение с квадратного сенсора
AVCaptureDeviceRotationCoordinator // обязательно, обновляется при переезде между дисплеями
photoOutput.isCameraSensorOrientationCompensationEnabled = false
✅ Готово, когда камера корректно переключается при складывании и раскладывании, зеркалирование правильное во всех четырёх комбинациях, превью не обрезано и не леттербоксное.
🖼 Кадры из сессий Apple (8)
AVCaptureDeviceRotationCoordinator держит превью правильно ориентированным. ▶ 7:15
AVCaptureDeviceDirectionCoordinator — сообщает, какая камера смотрит в нужную сторону. ▶ 4:05
isCameraSensorOrientationCompensationEnabled = false — когда нужна ручная компенсация ориентации сенсора. ▶ 8:45F15 Игры
Суть. Можно залочить ориентацию, но заполнять экран обязательно во всех позах.
📎 Референсы
- HIG: Best practices — пункт «Make your game playable in every device pose»
- HIG: Designing for games
- Можно залочить portrait или landscape — но обязательно заполнять экран во всех позах.
- Приоритет решений: изменить aspect ratio → letterbox/pillarbox только если иначе никак.
- Если чёрных полос не избежать — заполните их артвортом, чтобы опыт ощущался полноэкранным.
- При ресайзе держите размеры текста и контролов постоянными — иначе UI «дышит» и становится неуправляемым.
Те же принципы resizability; избегайте привязки к конкретному экрану.
✅ Готово, когда нет ни одной позы с чёрными полосами без артворка, а размер hit-таргетов стабилен.
Часть 6 · Процесс
F16 Инструменты и тестовая матрица
📎 Референсы
- Prepare your app: 00:30 сравнение трёх уровней SDK · 01:20 Device Hub и кнопки поз · 09:12 скилл app-resizability
- 02:00Raise the bar — два шага включения вертикальных баров
- Apple Design Resources → iOS Apps — шаблоны для макетов
- Xcode 27.1
- Сборка против iOS 27.1 SDK
- Симулятор iPhone Duo в Device Hub — кнопки снизу: open / close / rotate / fold
- Агентский скилл Xcode
app-resizability(бывший app-modernization, теперь поддерживает SwiftUI и Duo). Промпт: «Make my app follow all resizability best practices». В частности автоматически заменяетUIScreen.main.scale→traitCollection.displayScale.
Уровни поддержки по версии SDK — чтобы понимать, где вы сейчас
| Собрано с | Закрытое состояние | Открытое состояние |
|---|---|---|
| до iOS 27.0 | только область слева от status bar и камеры | «классический» iPhone-размер с чёрными полями |
| iOS 27.0 | + заходит в зону status bar | расширяется горизонтально, но с padding по краям |
| iOS 27.1 | edge-to-edge, бары вертикально | edge-to-edge на весь внутренний экран |
Для каждого ключевого экрана:
- Внешний дисплей, portrait
- Внешний дисплей, landscape (здесь чаще всего overflow)
- Внутренний дисплей, portrait — горизонтальные бары!
- Внутренний дисплей, landscape — вертикальные бары
- Внутренний, частично сложен — состояние с division region
- Split View, левая половина — контролы слева
- Состояние максимального overflow
Плюс, где применимо: tabletop-макет, состояние с активной внутренней камерой, состояние с PiP сверху.
✅ Общая тестовая матрица. 6 поз × (обычный / Split View левый / Split View правый) × RTL × Reduce Transparency.
F17 Чеклист миграции
Отметки сохраняются в браузере. Прогресс считается отдельно для дизайна и разработки.
⚠️ Топ-5 ловушек
| # | Ловушка | Чья зона | Последствие |
|---|---|---|---|
| 1 | Допущение о симметричных safe area insets | ⚙️ Dev | самый частый баг; контент съезжает или обрезается |
| 2 | Кастомно созданный UIToolbar | ⚙️ Dev | молча не получает вертикальную раскладку, ошибки нет |
| 3 | Запрос нового окна на внешнем дисплее | ⚙️ Dev + 🎨 Design | провалится; на iPad такого ограничения нет |
| 4 | Layout, привязанный к углу шарнира вместо reserved regions | ⚙️ Dev | ломается в Split View и на внешнем дисплее |
| 5 | Функция, доступная только в одной позе | 🎨 Design | прямо запрещено HIG; люди складывают устройство постоянно |
Приложение
A1 Указатель схем Apple
Все схемы живут на странице HIG: Designing for iPhone Duo. В этом документе они встроены в соответствующие разделы.
| Что показывает | Раздел HIG | Где в документе |
|---|---|---|
| Шесть поз устройства | Device poses | База, F9 |
| Внешний дисплей: шарнир и камера | Anatomy | База |
| Внутренний дисплей: шарнир и камера | Anatomy | База |
| Home Screen, внешний и внутренний | Intro | База |
| Зона внешней камеры | Reserved regions | F2, F5 |
| Зона сгиба и внутренней камеры | Reserved regions | F5 |
| Notes: открыт → сложен (панели 50/50) | Reserved regions | F5 |
| Split arrangement | Arrangement views | F6 |
| Overlay arrangement | Arrangement views | F6 |
| Mail: внешний и внутренний | Best practices | F7 |
| Состав вертикальной полосы | Vertical controls | F3 |
| Split View: контролы у внешних краёв | Vertical controls | F10 |
| Calculator на всю ширину (4 колонки → 5) | Vertical controls | F2 |
| Контролы рядом со своей панелью (Mail) | Vertical controls | F3 |
| Сжат toolbar / сжат tab bar | Vertical controls | F4 |
A2 Источники
| Материал | Длит. | Фокус | Ссылки |
|---|---|---|---|
| Design for iPhone Duo | 10:45 | дизайн-принципы, позы, боковые контролы | YouTube · Apple 111466 |
| Prepare your app for iPhone Duo | 10:10 | SDK, layout, safe areas, инструменты | YouTube |
| Strike a pose with adaptive layouts | 18:01 | reserved regions, displacement, ArrangementView | YouTube · Apple 111463 |
| Raise the bar with iPhone Duo | 15:42 | вертикальные бары, контент, overflow | YouTube · Apple 111462 |
| Leverage multiple displays and scenes | 07:18 | hinge, multitasking, scene accessories | YouTube |
| Build a great camera experience | 09:27 | AVFoundation, две фронтальные камеры | YouTube |
| HIG: Designing for iPhone Duo | — | нормативные правила | developer.apple.com |
| Apple Design Resources | — | шаблоны, гриды, safe areas | developer.apple.com |