Designing for iPhone Duo, from layout to code
Twelve chapters, with SwiftUI and UIKit for the same situation. Learn one checkable judgment at a time, then see why the tabs stay at the bottom.
Make the interface look good at every width first, then treat the folding hardware as a bonus. iPhone Duo has an outer display, an inner display, and a hinge. It is not a new idiom, and it should not grow a second app. What follows is written along a learning curve: each chapter delivers one judgment you can check. Look first at the version that breaks, then at why the design is shaped this way, then at SwiftUI and UIKit for the same situation.
Signatures are checked against the iOS 27.1 SDK in Xcode 27.1. The behavior comes from Designing for iPhone Duo, Preparing your app for iPhone Duo, and Three steps. The body is a retelling. It is not a verbatim copy of those pages.
How to read this: each chapter starts with “After this chapter, you can,” then an example that breaks, the way to write it, and “when to use / when not to use.” Answer the checkpoints yourself first. The answers are at the end of the article. Chapters 0–6 do not query the fold, do not use Arrangement, and do not read the hinge angle. Those come later.
Chapter 0 · Six postures are an acceptance check, not six interfaces
After this chapter, you can. When you watch a Duo close, open, half-fold, and rotate, say which width the interface should be thinking about, instead of writing a separate version for each posture.
The example that breaks
Do not organize the product like this: closed portrait, closed landscape, open portrait, open landscape, half-folded like a book, and half-folded standing on a table, each with its own view tree. One open or close swaps in a new navigation, swaps the buttons, and drops the scroll position.
SwiftUI branching on posture:
enum Pose { case closed, open, half }
struct Root: View {
let pose: Pose
var body: some View {
switch pose {
case .closed: PhoneHome()
case .open: TabletHome()
case .half: BookHome()
}
}
}
UIKit branching on posture:
func installRoot(for pose: String) {
let root: UIViewController
switch pose {
case "closed": root = PhoneHomeController()
case "open": root = TabletHomeController()
default: root = BookHomeController()
}
// 三套控制器,三份选中项
}
Why Apple gives you only two widths
Duo has an outer display and an inner display. The hinge can close, open all the way, or stop in between. People hold it upright, hold it sideways, open it like a book, or stand it on a table. All of those are continuous changes inside one session of use. The HIG asks for a compact width on the outer display and a regular width on the inner display, and for the layout you already have to grow with the space that is available. Do not rebuild the app for each way of holding it. You are still designing an iPhone. The iOS rules for layout, bars, and safe areas still apply.
| What you see | What it is | How to think about it while designing |
|---|---|---|
| Closed | The outer display. Wider and shorter than a typical iPhone | Compact width. In portrait, the height is still regular |
| Fully open | A large inner display, regular × regular | A natural extension of an iPad-style layout, and still an iPhone app |
| Half-folded | The inner display divided into two sides by the fold | Still the same hierarchy. This chapter does not deal with the fold yet |
| Rotation, Split View | Different containers on the same device | Width passes through values in between. There are not only the two stops “outer display / full inner display” |
Remember one sentence about the direction of the bars: Portrait on the inner display is the only posture that keeps horizontal bars at the top and bottom. On the outer display, and in landscape on the inner display, the system bars move to the side. In Split View, the bar on each side sits against that side’s own outer edge. Those details become code in Chapter 5.
The right way to organize it
One interface. State lives outside the layout. When the width changes, only the arrangement changes. The data does not.
struct Root: View {
@State private var selection: Item.ID?
var body: some View {
Home(selection: $selection)
}
}
final class RootController: UIViewController {
let model = HomeModel()
// 开合时只让同一个控制器重新布局
}
Terms to get right in this chapter
There is no Duo API yet. Get the words right first, or the later chapters will be wired to the wrong thing.
Outer display / inner display. Closed, you are looking at the outer display. Open, you are looking at the inner display. The primary scene switches displays on its own. When to use: when you are describing the hardware, and when acceptance needs you to say which face you tested. When not to use: do not treat the two faces as two canvases that you manage yourself.
Posture. Closed, open, and half-folded, multiplied by orientation. When to use: in Device Hub, open each one and see whether the same interface is still there. When not to use: do not
switch posein order to pick a layout.compact / regular. Design the outer display as a compact width, and the inner display as a regular width. When to use: deciding between “one layer at a time” and “two layers visible together.” When not to use: do not take regular to mean “this is an iPad.” How you read it is Chapter 3.
Horizontal bar / vertical bar. Portrait on the inner display keeps the top and bottom bars. The other common postures gather navigation, the toolbar, and the tab bar onto the side. When to use: when you draw the bars, draw both directions. When not to use: do not draw a fake sidebar yourself in order to imitate it.
Checkpoint
In portrait on the inner display, are the system bars at the top and bottom, or on the side?
How many interfaces should the six postures become?
After the Duo opens, has it become an iPad app?
Not in this chapter yet. Do not write if isDuo, do not read the hinge angle, and do not query the fold. Right now you only need to reject “one UI for each posture.”
Source: HIG · Designing for iPhone Duo, Tech Talk 111466.
Chapter 1 · When it gets wider, reveal the next layer. Do not stretch the sentence
After this chapter, you can. Given a region that has grown wider, decide whether to add a column, add another column of items, or merely stretch the narrow interface you already had. You can also say what may cross the fold and what may not.
The example that breaks
On the outer display, a settings page is a comfortable single column. After the inner display opens, every row of that same list stretches until it is nearly the full screen. The title and the switch sit at opposite ends, with a wide empty stretch between them. Another way it breaks: the primary button, a QR code, or a face sits exactly at the center of the screen, and the fold cuts through it when the device is half-folded.
Four principles you can take into a review
Features and state stay continuous. What you could do while the device was closed is still there after you open it. Scroll position, selection, and content in the middle of an edit must not reset because the device opened or closed. What changes is how many layers you can see at once.
Spend the extra width on revealing hierarchy. Mail on the outer display is “a list, and the message appears only after a tap.” The inner display can put the list and the message side by side. That is seeing one more layer. It is not turning the line length of the message into 900pt.
The fold is like the spine of a book. A background, a gradient, and an article that scrolls may cross it. A button, a sentence, a face, a QR code, or a drag handle must not sit astride the center. This chapter only establishes that judgment. How you query the fold is Chapter 7.
Keep a control near the content it acts on. Buttons that manage a list stay above the list. Do not chase all of them off to a distant edge just because a vertical bar has appeared on the side.
A game should fill the current posture. Prefer changing the picture’s aspect ratio over leaving black bars above and below, or on the left and right. Keep the physical size of text and buttons as steady as you can.
The same settings page, at two widths
Give long text and forms a readable maximum, and leave the extra space for the hierarchy beside them, not for a longer sentence.
Form {
Toggle("提醒", isOn: $reminders)
Text("说明保持在容易扫读的宽度里。")
}
.frame(maxWidth: 700)
.frame(maxWidth: .infinity)
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let readable = view.readableContentGuide.layoutFrame
formWidth.constant = min(readable.width, 700)
}
Once this region is already wide enough to hold both a list and a detail at the same time, the next chapter uses the system split view to do that. For now, do not hand-write an HStack sidebar, and do not enlarge the type size a step just because the region got wider.
Questions to take into a review. Not APIs yet
Ask before you widen. Persistent navigation, supplementary detail, an inspector, a preview, more controls, higher information density. When to use: when you move from compact to very wide, look in that order for structure that can be shown at the same time. When not to use: do not start by increasing spacing and type size in order to fill the empty space.
The spine test. Imagine a seam down the center that cannot be tapped accurately. May cross: a photographic background, texture, and a list or article that keeps scrolling. Must not cross: a button, a short sentence that has to be read all the way through, a face, a QR code, a text field, a small icon.
Group things nearby. Leave a frequent action beside the block it affects. When to use: filters for a list stay on the list; sharing a detail stays on the detail. When not to use: do not drop the primary action into a corner far from the content for the sake of symmetry.
Games fill the display. When the posture changes, change the aspect ratio. When to use: you may lock portrait or landscape, but the current posture has to be filled. When not to use: do not make do with black bars. If you truly must leave a margin, fill it with picture, not with a flat color.
Checkpoint
On the inner display, a settings form is stretched to 900pt wide. What is wrong?
When the device is half-folded, which of a gradient background and a Pay button may stay in the center?
Should Select All on a mail list follow the list, or follow the system bar at the far right?
Not in this chapter yet. Do not rebuild the whole page for the half-folded posture, and do not write code that queries the fold yet. On the mock, you only need to mark the elements that must not sit astride the center.
Chapter 2 · Which SDK you compile with decides how fully it fills the display
After this chapter, you can. Tell the deployment target apart from the SDK you compile with, and judge why a binary that already opens on Duo still leaves black margins, with the bars still at the top.
The example that breaks
A binary built with the iOS 26 SDK can be installed. After the inner display opens, the window sits in the center, with empty space around it. When the device is closed, content stops to the left of the status bar and the camera. Someone then writes another patch: if this is a Duo, stretch the frame until it is full.
Three SDK tiers, and no separate Duo switch
Filling the display, and vertical bars, come in with the compile SDK. They are not a Boolean in Info.plist. The deployment target can stay on an older system, so older devices can still install. But if the binary is going to use the full screen on Duo, and let the system bars take their vertical form, it must be compiled with the iOS 27.1 SDK or a newer SDK. Before you ship a 27.1 binary, confirm that content can still be tapped after the bars have become vertical.
| SDK used to compile | Inner display when open | Bars |
|---|---|---|
| iOS 26 or earlier | Centered, with empty space around it. On the outer display, content sits to the left of the status bar and the camera | Keeps the horizontal bars you already had |
| iOS 27 | Fills most of the inner display, but still avoids the status bar on the right | The toolbar stays at the top |
| iOS 27.1 or newer | Uses the full display area | The toolbar and the tab bar form a vertical bar under the status bar |
The deployment target can stay lower. Calls must be gated
The two snippets below still do not call any fold API. They only establish the habit every later chapter has to keep: 27.1 symbols are wrapped in an availability check. On Mac Catalyst, a known issue in Xcode 27.1 is that these 27.1 symbols may fail to compile, so they need to be walled off as well.
var body: some View {
Home()
.modifier(DuoReadyChrome())
}
struct DuoReadyChrome: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 27.1, *) {
content // 第 5 章才把垂直栏调优放进来
} else {
content
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
if #available(iOS 27.1, *) {
// 同一个控制器。不要在这里换 root。
}
}
#if !targetEnvironment(macCatalyst)
// 27.1 专有调用放这里,避免 Catalyst 编不过
#endif
Facts that belong to compilation
Deployment target
IPHONEOS_DEPLOYMENT_TARGET. When to use: deciding the oldest system that can still install the app. When not to use: raising it to 27.1 is not the prerequisite for filling the screen. Filling the screen depends on the SDK you compile with.iOS 27.1 SDK. The switch for a full screen plus vertical bars. When to use: compile with this SDK when you are ready to ship the complete form on Duo. When not to use: do not ship a 27.1 binary before the vertical bars have been adapted.
#available(iOS 27.1, *). When the deployment target is below 27.1, new symbols must be wrapped. When to use: every call to a 27.1 API from Chapter 5 onward. When not to use: do not use it to wrap an ordinary size class. Those already exist on earlier systems.Mac Catalyst
#if !targetEnvironment(macCatalyst). When to use: when a 27.1-only API keeps the Catalyst target from compiling, isolate those calls. When not to use: do not delete the adaptation on iPhone because of that. The camera accessory itself is unavailable on Catalyst.
Checkpoint
The deployment target is still iOS 18, but you compile with the 27.1 SDK. Do vertical bars appear?
The binary already launches on Duo, and there is empty space around it. Is the next step to detect the model and stretch the frame, or to change the SDK?
Why can’t a new 27.1 call be written directly on an iOS 18 code path?
Not in this chapter yet. Do not read the screen size in order to fill the display. The next chapter is when you replace UIScreen.main. The rules for items in a vertical bar are not here yet either.
Source: Three steps to make your app shine on iPhone Duo. Xcode 27.1 requires macOS Tahoe 26.6 or newer. In Device Hub, the simulator opens, closes, rotates, and folds.
Chapter 3 · Ask how wide this region is. Do not ask whether this is a Duo
After this chapter, you can. Take layout branches written against the device, the orientation, and UIScreen.main, and change them so they recalculate from the size class and the size of the current container.
The example that breaks
The same Duo can show up as the outer display, as full screen on the inner display, and in Split View on the inner display. The safe area and the bars change as well. A screen measurement taken once at launch goes stale the moment you fold. The inner display also ignores the orientations you declared in Info.
let tallPhone = UIScreen.main.bounds.height == 844
let scale = UIScreen.main.scale
if UIDevice.current.userInterfaceIdiom == .pad
|| horizontalSizeClass == .regular {
IPadOnlyLayout()
}
let window = UIWindow(frame: UIScreen.main.bounds)
if UIDevice.current.orientation == .landscape { wide() }
if view.window?.windowScene?.effectiveGeometry
.interfaceOrientation.isLandscape == true { wide() }
let width = view.bounds.width
- view.safeAreaInsets.left * 2
Two facts that are easy to remember backwards
A regular width is no longer the same thing as iPad. The inner display is regular in width and regular in height. Code that used to mean “regular, so take the iPad-only page” now runs on iPhone. Apple’s requirement is that the inner display should look like a natural extension of your iPad layout, so repair that layout. Do not add another idiom check in order to turn it off.
Orientation is not shape. To know whether you are wider than you are tall, or taller than you are wide, compare the width and height of the region you actually own. Portrait on the outer display is a compact width plus a regular height. Landscape on the outer display is compact in both directions. The inner display is regular in both directions. From here on, an iPhone app can meet any combination of size classes.
Size changes in the middle of a session. Do not write it down once inside viewIsAppearing and then keep using that note until the end.
Recalculate from the container
struct Stage: View {
@Environment(\.horizontalSizeClass) private var widthClass
@Environment(\.verticalSizeClass) private var heightClass
@Environment(\.displayScale) private var displayScale
@State private var size: CGSize = .zero
var body: some View {
content
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newSize in
size = newSize
}
}
private var isWiderThanTall: Bool { size.width > size.height }
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let bounds = view.bounds
let wider = bounds.width > bounds.height
let scale = traitCollection.displayScale
let usable = bounds.inset(by: view.safeAreaInsets)
layout(for: usable.size, wider: wider, scale: scale)
}
override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate { _ in self.view.setNeedsLayout() }
}
let window = UIWindow(windowScene: scene)
When you truly need a UIScreen, take it from view.window?.windowScene?.screen at the moment you use it. Do not store it as a property. Create the window from the scene. Do not use the main screen’s bounds as the frame.
API
horizontalSizeClass. SwiftUI:@Environment(\.horizontalSizeClass). UIKit:traitCollection.horizontalSizeClass. When to use: telling compact width from regular width, together with the vertical size class. When not to use: do not translate.regularinto “the iPad page.”onGeometryChange. In SwiftUI, you receive the containerCGSizewhen the size changes. The UIKit counterparts areviewDidLayoutSubviewsandviewWillTransition(to:with:). When to use: comparing width and height, or measuring locally against the current container. When not to use: do not use it to readUIScreen.main.bounds.displayScale. SwiftUI:@Environment(\.displayScale). UIKit:traitCollection.displayScale. When to use: when you need the pixel scale. When not to use: do not readUIScreen.main.scaleanymore. The main-screen reference is on the way out.UIWindow(windowScene:). Create the window with the scene. When to use: replacingUIWindow(frame: UIScreen.main.bounds). When not to use: layout math still looks at the bounds of the view or the window, not at the whole physical screen.
Checkpoint
Can
horizontalSizeClass == .regularbe true on an iPhone now?The inner display has turned sideways. Is
interfaceOrientationa reliable way to decide “it is wider now”?Why does
safeAreaInsets.left * 2get the usable width wrong?
Not in this chapter yet. Do not use a width threshold such as if width > 700 to switch between two navigation trees. The next chapter uses a system container to solve “push when narrow, side by side when wide.”
Source: Step 2 of Three steps, and the resizing considerations in Preparing. Tech Talk 111461.
Chapter 4 · Let the system container decide side by side or push
After this chapter, you can. Keep the selection with one split container, decide a grid’s column count from a minimum item width, and do a local rearrangement from “which arrangement fits” rather than from “which device am I on.”
The example that breaks
Compact uses a NavigationStack. Regular uses a hand-written HStack sidebar. Each one stores its own selection. The grid is written as “three columns on Duo, otherwise one column.” A button group uses if width > 700 to split, between horizontal and vertical, into two completely different views.
What to reach for first
If a system navigation container can do the job, use the container. Repeated cards use an adaptive grid. A local “if it does not fit across, stack it downward” uses a layout that can probe the space that remains. Size class comes after that. Exact geometry comes last. The information architecture of a split view is “collection, selection, detail.” When the container is narrow, the list pushes the detail. When it is wide, they sit side by side. The selection has to live through that change. The extra width is there to reveal this layer. Long text still has a maximum width.
List and detail
NavigationSplitView {
List(items, selection: $selection) { item in
Text(item.title)
}
} detail: {
if let selection {
Detail(id: selection)
.frame(maxWidth: 700)
} else {
Text("选择一项")
}
}
let split = UISplitViewController(style: .doubleColumn)
split.setViewController(list, for: .primary)
split.setViewController(detail, for: .secondary)
// 选中项放在 list 与 detail 共用的 model 上
// preferredPrimaryColumnWidth 用 min / ideal / max
// 不要写死侧栏点数
Card grids: a minimum width, not a column count
private let columns = [
GridItem(.adaptive(minimum: 220))
]
LazyVGrid(columns: columns, spacing: 12) {
ForEach(cards) { Card($0) }
}
func makeLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { _, env in
let width = env.container.effectiveContentSize.width
let count = max(1, Int(width / 220))
let item = NSCollectionLayoutItem(
layoutSize: .init(
widthDimension: .fractionalWidth(1 / CGFloat(count)),
heightDimension: .estimated(160)))
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(
widthDimension: .fractionalWidth(1),
heightDimension: .estimated(160)),
subitem: item, count: count)
return NSCollectionLayoutSection(group: group)
}
}
When Split View cuts you down to a middle width, the column count lands on 2 by itself, instead of jumping from 3 straight back to 1. If you want an even column count while the device is half-folded, that is Chapter 7. Do not write it now.
Change how the same views are arranged. Do not change their identity
ViewThatFits(in: .horizontal) {
HStack { Preview(); Inspector() }
VStack { Preview(); Inspector() }
}
let layout = isWiderThanTall
? AnyLayout(HStackLayout())
: AnyLayout(VStackLayout())
layout {
Chart()
Legend()
}
UIKit has no ViewThatFits. The matching idea is this: at layout time, ask “does it still fit horizontally?”, then change the axis of the same UIStackView. Do not destroy the child view controllers and create them again, or text in the middle of an edit will be lost.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let horizontal = stack.bounds.width
> preview.intrinsicContentSize.width
+ inspector.intrinsicContentSize.width
stack.axis = horizontal ? .horizontal : .vertical
}
API
NavigationSplitView. UIKit:UISplitViewController. When to use: collection, selection, detail. Push when narrow, side by side when wide. When not to use: do not replace it with a hand-writtenHStacksidebar. It is not the two-content arrangement in Chapter 8.GridItem(.adaptive(minimum:)). UIKit: divide the container width by the minimum item width, and that quotient is the column count. When to use: repeated content such as cards, a photo grid, or a dashboard. When not to use: do not writeisDuo ? 3 : 1.ViewThatFits. Along a given axis, it tries each subtree and keeps the one that fits. When to use: a local choice, such as a button group, a line of metadata, or a preview plus an inspector. When not to use: do not use it to switch navigation for the whole app.AnyLayout. It swaps the layout container and keeps the identity of the child views. When to use: a chart and its legend, a preview and its properties — the arrangement changed, and they are still those two views. When not to use: do not write the child views once in each of twoifbranches.
Checkpoint
When you fold back from side by side to a single column, where should the selection live?
After Split View, the width only fits two cards of 220pt. Where should the column count come from?
Does
ViewThatFitsanswer “which device,” or “which arrangement fits”?
Not in this chapter yet. Whether the tab bar should become side navigation waits until the next chapter. Keeping two pieces of content off the fold waits until Chapter 8. Do not introduce Arrangement now.
Chapter 5 · System bars stand on the side by themselves. A fake bar will not
After this chapter, you can. Make navigation, the toolbar, and the tab bar recognizable in both the horizontal form and the vertical form, and hand secondary actions to the system overflow menu.
The example that breaks
The top of the page is a hand-drawn HStack, or a UINavigationBar, UIToolbar, or UITabBar created on its own with new. The system will not gather that content into the vertical bar. Another common problem: the share button has only the title "Share". The vertical bar shows icons only. An item with no icon never stands up vertically at all. If you then draw your own “…” menu, it competes with the system overflow menu for the same place.
When a bar is vertical, and which items are allowed in
After you compile with the 27.1 SDK, the outer display combines the navigation bar, the toolbar, and the tab bar into a single bar on the side. Landscape on the inner display does the same, and so do some views that sit in the leading or trailing position. Portrait on the inner display still uses horizontal bars. Inside a split view, only the detail column takes part in the vertical bar. The sidebar column and the content column stay horizontal. An inspector stays horizontal.
The bar lines up with a hardware edge. A right-to-left language does not move it to the other side. In Split View, the app on the left has its bar on the left, and the app on the right has its bar on the right.
An item must have both a title and an icon. The vertical form uses the icon. The horizontal form prefers the icon, and it may also use the title. In the overflow menu, the icon and the title appear together. A custom view stays in the horizontal bar by default, unless you declare that it is suitable for vertical placement. A text-only button stays in the horizontal bar. The system Edit button is the one that switches between a symbol and text in exactly this way.
The top of the vertical bar is reserved for back or close, and then for a prominent action such as Done. The rest of the items keep the groups they already had. Overflow collects from the bottom upward by default. A navigation-style page prefers to keep the tab bar. A page whose job is to get something done prefers to keep the toolbar items.
The same set of actions, usable from either kind of bar
In SwiftUI, attach them inside a NavigationStack:
NavigationStack {
DetailView()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") { dismiss() }
}
ToolbarItem(placement: .topBarPinnedTrailing) {
Button("Done", systemImage: "checkmark") { save() }
}
ToolbarItem(placement: .bottomBar) {
Button("Share", systemImage: "square.and.arrow.up") { share() }
}
.visibilityPriority(.high)
ToolbarOverflowMenu {
Button("Duplicate", systemImage: "plus.square.on.square") {
duplicate()
}
}
}
.toolbarVerticalCompressionBehavior(.automatic)
}
In UIKit, attach them on the child controller that lives inside a navigation controller:
let close = UIBarButtonItem(
title: "Close", image: UIImage(systemName: "xmark"),
target: self, action: #selector(closeTapped))
let done = UIBarButtonItem(
title: "Done", image: UIImage(systemName: "checkmark"),
target: self, action: #selector(doneTapped))
let share = UIBarButtonItem(
title: "Share", image: UIImage(systemName: "square.and.arrow.up"),
target: self, action: #selector(shareTapped))
share.visibilityPriority = .high
share.axisBehavior = .automatic
navigationItem.leadingItemGroups = [
UIBarButtonItemGroup.fixedGroup(items: [close])
]
navigationItem.pinnedTrailingGroup = UIBarButtonItemGroup.fixedGroup(items: [done])
toolbarItems = [share]
navigationItem.verticalBarCompressionBehavior = .automatic
The back button is placed by the navigation controller itself. A custom close goes on the leading side. Done goes into pinnedTrailingGroup. When search occupies all of the space, the pinned items are the last ones to enter the overflow. additionalOverflowItems is how you gather your own More into the system menu. Leave the ellipsis for the overflow. Give any other menu a different symbol.
An item whose symbol and text trade places should use .horizontalOnly: when there is no horizontal bar, it hides, instead of crowding into the vertical bar. If a custom view really does have a vertical drawing, use .verticalPreferred. Flexible space has a size of 0 on the vertical axis. Group items with ToolbarItemGroup / UIBarButtonItemGroup. Do not insert a fixed gap by hand. Put a count in badge. Do not write the number into the title, or that item will have a hard time becoming a symbol on its own.
Leaving the vertical bar is a stable exception
NavigationStack {
CalculatorView()
.toolbarVerticalBehavior(.disabled)
}
override var preferredVerticalBarBehavior: UIVerticalBarBehavior {
.disabled
}
The default is .automatic. Opt out only on these interfaces: full-screen video, a non-scrolling full-width layout such as a calculator, and a sheet whose only control is a close button. That is a stable choice. Do not flip it back and forth from page state inside viewWillAppear. If one particular page only needs the bar hidden, use the visibility API. Do not turn the vertical behavior off for that. In UIKit, if the preference really did change, call setNeedsUpdateOfVerticalBarConfiguration(). A navigation controller hands the decision to the top of the stack by default. A tab bar hands it to the page that is currently selected.
Know which side the bar is on, and how the background reaches past it
@Environment(\.toolbarVerticalEdge) private var verticalEdge
// HorizontalEdge?;这个上下文从不用垂直栏时为 nil
Image("hero")
.backgroundExtensionEffect()
let edge = traitCollection.verticalBarEdge
// .leading / .trailing / .unspecified
// 表示系统偏好的边,不表示栏此刻是否可见
// 从不用垂直栏的上下文返回 .unspecified
let backdrop = UIBackgroundExtensionView()
backdrop.contentView = hero
To observe this edge in UIKit, pass UITraitCollection.systemTraitsAffectingVerticalBarEdge into registerForTraitChanges. The trait can change while the edge stays the same.
Top-level destinations: the inner display can become side navigation
On iPhone, writing only .sidebarAdaptable is still a bottom bar. To become side navigation where that is supported, also add the iOS 27 modifier defaultTabBarPlacement(.sidebar). Do not use the iOS 18 defaultAdaptableTabBarPlacement. That one is for iPad. UI that depends on the sidebar looks at isTabViewSidebarAvailable. Do not go back to guessing from the size class.
TabView {
Tab("Home", systemImage: "house") { HomeView() }
Tab("Library", systemImage: "books.vertical") { LibraryView() }
}
.tabViewStyle(.sidebarAdaptable)
.defaultTabBarPlacement(.sidebar)
tabBarController.sidebar.preferredPlacement = .sidebar
A product may also choose, on purpose, to keep the bottom bar, and to leave the widened space for a split view inside the content. Both choices hold. Do not draw a fake sidebar yourself from the width.
API
toolbarVerticalBehavior(iOS 27.1)..automatic/.disabled. UIKit: overridepreferredVerticalBarBehavior, of typeUIVerticalBarBehavior. When to use: a calculator, full-screen video, a sheet with a single close button. When not to use: do not flip it back and forth with navigation, or with the state of some view.axisBehavior(iOS 27.1). SwiftUI:.automatic/.horizontalOnly/.verticalPreferred. UIKit:UIBarButtonItem.axisBehavior, the same three values. When to use: an item that switches between a symbol and text uses horizontalOnly; a custom view that really has a vertical drawing uses verticalPreferred. When not to use: an ordinary button that already has a title and an icon stays automatic.visibilityPriority(iOS 27.0). SwiftUI:.automatic/.low/.high, plusinit(lowerThan:)/init(higherThan:). UIKit:.standard/.low/.high. When to use: give the whole group a priority first, then the individual items inside the group. Leave frequent items, and items that carry a badge, until last before they overflow. When not to use: do not build a second “…” menu yourself.ToolbarOverflowMenuandadditionalOverflowItemsare the system menu. UIKit’sadditionalOverflowItemshas been available since iOS 16.Compression behavior (iOS 27.1). SwiftUI:
.automatic/.prefersToolbarItems/.prefersTabBar. UIKit:navigationItem.verticalBarCompressionBehavior, with.automatic/.prefersBarItems/.prefersTabBar. When to use: a navigation-style page uses the default (gather toolbar items first, and keep the tabs). A task-style page switches to gathering the tab bar first. When not to use: do not write two copies of the bars in order to imitate compression.topBarPinnedTrailing(iOS 27.0). The matching UIKit property,pinnedTrailingGroup, has been available since iOS 16. Close uses.cancellationAction. UIKit puts that item intoleadingItemGroups. When to use: a primary action such as Done that has to stay near Back. When not to use: do not pin a secondary action here. Those should be allowed to overflow.toolbarVerticalEdge(iOS 27.1). SwiftUI:HorizontalEdge?, and nil when this context never uses a vertical bar. UIKit:traitCollection.verticalBarEdge, with the values leading, trailing, or unspecified. It reports the preferred edge, even when the bar is not visible at this moment. When to use: when custom content has to line up with that bar. When not to use: do not read unspecified as “the bar was hidden a moment ago.” It means this context will not use a vertical bar.backgroundExtensionEffect()(iOS 26). UIKit:UIBackgroundExtensionView. PlacecontentViewinside the safe area, and fill the edges with the extension. When to use: a hero, or a background, that should run underneath the vertical bar. When not to use: tappable foreground must not use it to slip out of the safe area.defaultTabBarPlacement(iOS 27.0)..sidebarAdaptableplus.sidebar. UIKit:sidebar.preferredPlacement = .sidebar. The environment valuesisTabViewSidebarAvailableandtabBarPlacementare iOS 27 as well. When to use: when a top-level section is a good fit for navigation that stays on screen in a wide layout. When not to use: do not usedefaultAdaptableTabBarPlacementfor Duo. That modifier has existed since iOS 18, and it acts on iPad.
Checkpoint
Will a button that has only a title, and no icon, enter the vertical bar?
Of the three columns in a split view, which column’s bar becomes vertical?
When a navigation-style page runs out of room, does the default gather the toolbar items first, or the tab bar first?
Why will a hand-drawn
UIToolbarnot stand on the side?
Not in this chapter yet. Do not query the fold in order to decide whether the bar is on the left or on the right. The system places the bar’s edge into the trait and the environment. Which side a sheet attaches to is the next chapter.
Source: HIG · Vertical controls; Preparing · Optimize bars. Tech Talk 111462.
Chapter 6 · Measure the four edges separately. A sheet declares which side it attaches to
After this chapter, you can. Choose a position for a sheet, compute the usable area from a safe area whose edges are not symmetric, and keep a full-bleed picture with corner radii that are concentric with the screen, plus a focus point.
The example that breaks
Take the left inset, multiply it by two, and treat that product as the whole safe area. Split View, or a vertical bar, makes left and right unequal, and the content shifts. Another class of problem is building your own full-screen mask and using it as a sheet: system sheets, alerts, menus, and popovers avoid the fold. Your mask does not. A hero cropped with .fill shows a face on the outer display, then crops that face away on the inner display.
A sheet’s bar follows where you place the sheet
On the outer display, a sheet that has a toolbar defaults to a vertical bar. On the inner display, a sheet defaults to centered, and its bar is horizontal. The leading side has no vertical bar. The trailing side has one. Only a sheet honors placement. Do not build another custom transition for the sake of this. A sheet whose only control is a close button is the case the previous chapter allows to turn the vertical bar off. When sourceView is set, UIKit ignores preferredPlacement.
.sheet(item: $selection) { item in
ItemDetail(item: item)
.presentationPlacement(.trailing)
}
let sheet = detail.sheetPresentationController
sheet?.preferredPlacement = .trailing
// .automatic / .leading / .center / .trailing
// sourceView 非 nil 时,这个属性被忽略
The four safe-area edges are independent
content
.safeAreaPadding(.horizontal)
background
.ignoresSafeArea()
let usable = view.bounds.inset(by: view.safeAreaInsets)
// 分别使用 left、right、top、bottom
// 折痕不是 safe area inset
Decoration may reach outside the safe area. Tappable content, and text that has to be read, stay inside. The camera, the vertical bar, and Split View can all make left differ from right, or top differ from bottom. A custom shape that sits against the screen’s rounded corner uses a concentric rectangle. Do not hard-code the radius.
RoundedRectangle(cornerRadius: 12) // 不要写死去贴屏幕角
ConcentricRectangle()
view.cornerConfiguration = .corners(radius: .containerConcentric())
A full-bleed picture has to keep its subject
Image("cover")
.resizable()
.aspectRatio(contentMode: isWide ? .fit : .fill)
.frame(maxWidth: .infinity, maxHeight: isWide ? 420 : 280)
.clipped()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let wide = view.bounds.width > view.bounds.height
hero.contentMode = wide ? .scaleAspectFit : .scaleAspectFill
hero.clipsToBounds = true
}
If the subject is not at the geometric center, give the crop a focus point, instead of only switching between fill and fit. A game fills the display and changes the aspect ratio. See Chapter 1.
API
presentationPlacement(iOS 27.0)..automatic/.center/.leading/.trailing. UIKit:UISheetPresentationController.preferredPlacement. When to use: look at every sheet while closed, while open, and while half-folded, and only then decide whether it attaches to an edge or sits in the center. When not to use: do not put this modifier on a popover or an alert. WhensourceViewis set, UIKit ignores it.Insets that are not symmetric.
bounds.inset(by: safeAreaInsets). When to use: anywhere you compute a usable width or height by hand. When not to use: do not mirror one edge’s inset onto the opposite edge. Do not stuff the fold into the safe area either.ConcentricRectangle(iOS 26). UIKit: the container-concentric radius onUICornerConfiguration.UIView.cornerConfigurationis there to set. When to use: a custom panel that should meet the corner radius of the current screen or container. When not to use: a card’s own small corner radius can still use a fixed radius. Do not use this to avoid the fold.fill / fit. Choose from the current aspect ratio or size class, or set a focus point. When to use: a hero, or a video poster, that has to keep its subject from the narrow outer display through to the wide inner display. When not to use: do not pick
contentModeonly once, at launch.
Checkpoint
On the inner display, is the toolbar of a trailing sheet horizontal, or vertical?
Why can’t
left * 2produce the content width?A system sheet avoids the fold. Does a full-screen mask that you draw yourself?
Not in this chapter yet. When a custom canvas, a QR code, or a drag control has to stay off the fold, the next chapter is when you query reserved regions. Do not guess the fold’s coordinates now.
Chapter 7 · The fold and the camera are two reserved regions, not the safe area
After this chapter, you can. In a custom layout, find the fold and the camera that are currently active, and move only the piece that is blocked, instead of rebuilding the whole page.
The example that breaks
The moment the device is half-folded, swap in a different interface tree. Or hard-code “the fold is at 50% of the width.” After Split View, or after a rotation, that fraction is no longer the center of your view. The other extreme is moving every paragraph of an article off the fold. Content that scrolls can already roll across that seam.
Two kinds of region. Most system components already avoid them
A reserved region is a rectangle that something else occupies inside your coordinate system. frame includes margins. Each region has a kind, a flag for whether it is active, and an identifier. What the query returns are the regions that intersect the current view. If your view occupies only half of the inner display, do not assume you can see the fold of the entire screen.
division, a split. When the device is half-folded, the hinge divides one large area into two sides. After the device is fully flat, this region is not active, and its width is 0.
occlusion, something covering the screen. The front camera on the outer display is always present, and it also expands into the Dynamic Island. The front camera on the inner display is normally hidden under the screen. Only while it is in use does it occupy a region and make the interface move aside.
Alerts, menus, sheets, and split views adapt on their own. A custom canvas, a QR code, a face, a short sentence that has to stay continuous, and an object in the middle of a drag are the cases that require you to read these rectangles. Move only the element that is blocked, and take the controls that belong with it along. Do not send it to the far end of the screen. Do not shift a scrolling article, or a scrolling list, as a whole.
Filter down to what is in effect, then place the custom content
SwiftUI’s reservedRegions can also take layoutDirectionBehavior:. The default is .mirrors. Pass .fixed only when you mirrored the geometry yourself. Otherwise the geometry is mirrored twice. The camera’s physical position does not flip when the language does. By default, SwiftUI mirrors the geometry for you first. The proxy you get from onGeometryChange can run the same query.
GeometryReader { proxy in
let folds = proxy.reservedRegions(kind: .division)
.filter(\.isActive)
let cameras = proxy.reservedRegions(kind: .occlusion)
.filter(\.isActive)
CanvasPage(avoiding: folds + cameras)
}
override func layoutSubviews() {
super.layoutSubviews()
let folds = reservedRegions(kind: .division)
.filter(\.isActive)
let cameras = reservedRegions(kind: .occlusion)
.filter(\.isActive)
placeControls(avoiding: folds + cameras)
}
A structural decision, such as an even column count, still has to be visible while the device is lying flat and the division region is not active yet. That is the moment you pass .includeInactive. You still use isActive to decide whether the controls should move immediately.
let divisions = proxy.reservedRegions(
kind: .division,
options: .includeInactive)
let evenColumns = !divisions.isEmpty
let divisions = reservedRegions(
kind: .division,
options: .includeInactive)
let evenColumns = !divisions.isEmpty
API
reservedRegions(kind:options:)(iOS 27.1). In SwiftUI, it is onGeometryProxy. UIKit:view.reservedRegions(kind:options:), which returns[UIView.ReservedRegion]. When to use: custom content that a system container will not move for you. When not to use: an ordinary list, a split view, a sheet, or an alert. Do not treat the result as a safe-area inset.ReservedRegion(iOS 27.1).id,kind,frame,margins,isActive. The kinds are.divisionand.occlusion. When to use: place things withframe.framealready includes the margins. When not to use: do not use an inactive division to push a button aside. While the device is flat, that region should not change where a tap lands..includeInactive(iOS 27.1).ReservedRegion.QueryOptions. When to use: a decision that does not depend on whether the device is half-folded right now, such as “if a fold exists at all, the grid uses an even number of columns.” When not to use: do not move a button early just because the query found a region that is not active.layoutDirectionBehavior..mirrorsor.fixed. When to use: keep the default, so subviews laid out automatically and the region share one direction. When not to use: pass.fixedonly when you are mirroring by hand.
Checkpoint
After the device is fully flat, is division still active? What is its width?
For an article that can scroll, should every paragraph be moved off the fold?
Does the camera on the inner display occupy an occlusion during ordinary use?
Not in this chapter yet. Paired content, such as a player plus a playlist, is handed to Arrangement in the next chapter. Do not use the hinge angle to decide whether to avoid the fold.
Source: ReservedRegion; Preparing · Adapt to reserved regions. Tech Talk 111463.
Chapter 8 · When two pieces of content form one experience, let the container trade their places
After this chapter, you can. Judge whether a pair of views should use split or overlay, and leave navigation outside this container.
The example that breaks
Replacing TabView or NavigationSplitView with Arrangement. Or putting Arrangement inside a List, a ScrollView, or one column of a split view, so that half of the content cannot be scrolled into reach and cannot be tapped.
If it already looks like one of the two stacks, map it across
Arrangement has only a primary view and a secondary view. It looks at space, aspect ratio, size class, and the division region. It does not look at whether you are on a Duo. The navigation container wraps the outside. Apple’s own example is a navigation stack with an Arrangement placed inside it. Do not nest another split view inside an Arrangement.
split. It corresponds to the old
HStack/VStack, or to a primary and a secondary where neither one should cover the other. When the region is wider than it is tall, the two sides split left and right. When it is taller than it is wide, they stack from top to bottom. When a division region appears, the container adjusts both sides so they stay off the fold. Once you have restricted it to a single axis, if that axis cannot be used, only the primary view remains..split.axes(.horizontal)shows only the primary content when the pair does not fit on the horizontal axis.overlay. It corresponds to the old
ZStack. When no division region is active, the primary view covers the secondary view (the device is closed, or fully open). When the device is half-folded, the primary view moves to the back side or the lower side of the fold, and the secondary view moves to the front side or the upper side.
A player and a list: split
NavigationStack {
ArrangementView {
Player()
} secondary: {
Queue()
}
.arrangementViewStyle(.split)
}
let arrangement = UIArrangementViewController()
arrangement.setViewController(player, for: .primary)
arrangement.setViewController(queue, for: .secondary)
arrangement.updateArrangement(.split)
navigationController.setViewControllers([arrangement], animated: false)
Left and right only. When the device is upright, keep only the player:
.arrangementViewStyle(.split.axes(.horizontal))
arrangement.updateArrangement(.split.axes(.horizontal))
Controls covering the picture: overlay
ArrangementView {
TransportControls()
} secondary: {
Stage()
}
.arrangementViewStyle(.overlay)
arrangement.setViewController(controls, for: .primary)
arrangement.setViewController(stage, for: .secondary)
arrangement.updateArrangement(.overlay)
Ratio and layering have these further adjustments, and all of them are iOS 27.1: splitArrangementLayoutRatio, splitArrangementLayoutSize, splitArrangementFixedLayoutSize, overlayArrangementEdge. The environment values splitArrangementAxis and overlayArrangementZIndex let a child view know which arrangement it is in now. UIKit reads zIndex, splitAxis, and isHidden through state(for:). The size ranges of the primary and secondary views live on UISplitArrangement.ViewProperties. The edge that gets covered lives on UIOverlayArrangement.ViewProperties.edge.
API
ArrangementView(iOS 27.1).inittakes a primary and a secondary. UIKit:UIArrangementViewController, and the position argument ofsetViewController(_:for:animated:)is.primary/.secondary. When to use: a player and a queue, an editor and an inspector, a canvas and its properties. The two pieces are one experience. When not to use: the top-level navigation of the app. Do not place it inside a split view, a list, or a scroll view..split(iOS 27.1).arrangementViewStyle(.split), with.axes(.horizontal)or.vertical. UIKit:updateArrangement(.split.axes(...)). When to use: both sides need space of their own, and neither one covers the other. When not to use: controls that are supposed to float on top of the content. That is overlay..overlay(iOS 27.1). When there is no division region, the primary view is on top. When the device is half-folded, the primary view goes to the back side or the lower side, and the secondary view goes to the front side or the upper side. When to use: foreground and background, where covering each other in ordinary use is acceptable. When not to use: two-column mail. That is the split view from Chapter 4, or split here.updateArrangement(_:animated:)(iOS 27.1). Switch between split and overlay, or change only the axis. When to use: the product explicitly wants one axis locked, or wants to switch between the two styles. When not to use: do not recompute frames yourself on every rotation and then call this. The default style already follows the container.
Checkpoint
It used to be a
ZStack, and when half-folded it should split onto the two sides of the fold. Do you use split, or overlay?Should Arrangement wrap the navigation, or should the navigation wrap Arrangement?
What happens if you put it inside a
ScrollView?
Not in this chapter yet. Do not read the hinge angle in order to choose split or overlay. The angle is an interaction input, and that is Chapter 10.
Source: the SwiftUI and UIKit samples in Preparing; HIG · Arrangement views. Signatures are checked against ArrangementView and UIArrangementViewController in the iOS 27.1 SDK.
Chapter 9 · Opening and closing is a layout change, not starting the app over
After this chapter, you can. Keep navigation, scroll position, selection, editing, and playback above the layout, and know the limits of Split View and of new windows on Duo.
The example that breaks
While the device is closed, the list has the third message selected. After it opens, a different view tree starts at the first message, and the draft is empty as well. Another way it breaks: you tested only full screen on the outer display and full screen on the inner display. Partway into Split View, the bars overlap and the text is clipped. You also assumed that once the device is open, the layout must be very wide.
One continuous width, plus two limits that come from the hardware
On the inner display, every app takes part in Split View, and video can also stack on top of an app. Each app’s vertical bar sits against that app’s own outer edge, so your bar may be on the left, or it may be on the right. Test both sides.
A layout that is very wide has to be able to fall back to a middle width, and then fall back again to compact. State — the navigation path, the scroll position, the selection, the editor, playback progress, an unsaved form — lives above the layer that decides one column or two columns.
Duo is the first iPhone that supports multiple scenes. An app that opens a new window on iPad will do so here as well. A new window cannot be created on the outer display. If the request fails, stay in the window you already have. The configuration closure of UIWindowScene.ActivationAction must return an ActivationConfiguration. Do not go and mutate a request object. When the platform does not support multiple windows, take the alternate you provided. Do not assume that a tap on the outer display is certain to open a second window. UIRequiresFullScreen still takes effect, but the interface still changes size when the device opens and closes.
The selection lives outside the layout
struct Mailbox: View {
@State private var path: [Message.ID] = []
@State private var selection: Message.ID?
@State private var draft = ""
var body: some View {
NavigationSplitView {
MessageList(selection: $selection)
} detail: {
Editor(text: $draft, messageID: selection)
}
}
}
let action = UIWindowScene.ActivationAction(
title: "新窗口",
image: UIImage(systemName: "plus.rectangle")
) { _ in
let activity = NSUserActivity(activityType: "mailbox.message")
return UIWindowScene.ActivationConfiguration(userActivity: activity)
}
For the scroll position, use the preservation that belongs to the system scroll view, or an identifier kept in the model. Do not scrollTo back to the top when the device closes. Playback uses the same player object. Do not replace it when the size changes.
Facts worth checking
Lift the state up. The selection, the path, the draft, and the player live outside the adaptive container. When to use: any page that moves between compact and regular. When not to use: do not make one state object for the outer display and another for the inner display.
Split View. The bar sits on that app’s outer edge. The left half has its bar on the left. The right half has its bar on the right. When to use: in Device Hub, place yourself on the left, and then on the right. When not to use: do not assume the vertical bar is always on the trailing edge.
UIWindowScene.ActivationAction(iOS 15). The closure returnsUIWindowScene.ActivationConfiguration(userActivity:). When the platform does not support multiple windows, usealternate. When to use: the app already supports multiple windows on iPad, and you want the same thing on Duo’s inner display. When not to use: do not force a request for a new scene on the outer display and then ignore the failure. A new window cannot be created on the outer display.UIRequiresFullScreen. The full-screen requirement is still honored. When to use: keep the original setting when you really do need the screen to yourself. When not to use: do not expect it to stop the size change that opening and closing bring with them. The layout still has to follow that change.
Checkpoint
After the inner display opens, which copy of state should the draft restore from?
When the app is on the right side of Split View, which side is the vertical bar more likely to be on?
Can a new window be created on the outer display?
Not in this chapter yet. Do not reset navigation from the hinge angle. Opening and closing are already expressed through size and through reserved regions. Leave the angle for interaction that is actually physical.
Source: the HIG notes on continuity across the two displays, and on bars that sit on the outer edge in Split View; Preparing’s requirement that you test more than one size. Tech Talk 111464.
Chapter 10 · The angle drives an effect. It does not drive bars or column counts
After this chapter, you can. When a hinge is present, turn the angle into pitch, a camera, or gameplay, and return to a safe value when there is no hinge, or when the interaction has left the view hierarchy.
The example that breaks
if hinge.angle > someRadians { showSidebar = true }
Lying flat, Split View, and an ordinary iPhone will not hand you an angle in the way you expect. Whether a sidebar should appear was already answered with space, in Chapter 3 and Chapter 4.
Layout looks at space. Only interaction looks at the hinge
What the angle is good for: treating it as physical input, a mechanism in a game, a musical instrument, the pose of a camera, an interaction on a tabletop, or an effect you have explicitly bound to opening and closing. What it is not good for: a sidebar, a column count, how many cells a grid has, or whether navigation is collapsed.
In SwiftUI, DeviceHingeContext.hinge being nil means this device has no hinge. It does not mean “the device is closed.” Closed is .closed. angle is an Angle. status is .closed, .partiallyOpen, or .fullyOpen.
Nil means something different in UIKit. When the interaction has left the hierarchy that can supply hinge updates, update.hinge is nil. The angle is in radians. UIHinge.Status has one extra case, .unknown. How often the updates arrive, and how fine they are, is decided by the system. Do not depend on one particular frame rate. If all you care about is closed, partially open, or fully open, read status.
While partially open, turn the angle into pitch. Otherwise return to zero
.onHingeChange { _, context in
if let hinge = context.hinge, hinge.status == .partiallyOpen {
pitch = bend(angle: hinge.angle)
} else {
pitch = 0
}
}
let interaction = UIHingeInteraction { [weak self] _, update in
guard let self else { return }
guard let hinge = update.hinge, hinge.status == .partiallyOpen else {
self.pitch = 0
return
}
self.pitch = self.bend(radians: hinge.angle)
}
view.addInteraction(interaction)
isEnabled on onHingeChange(isEnabled:_:) defaults to true. UIKit uses interaction.isEnabled. While it is off, there is no callback, and the updates you missed are not queued. When you turn it back on, if the hierarchy you are in still has a hinge, the current state is delivered once more, right away. The closure escapes. Use [weak self] so you do not form a retain cycle.
API
onHingeChange(iOS 27.1).(isEnabled:_:). The parameters are the oldDeviceHingeContextand the new one. When to use: an effect that has to change continuously as the hinge moves. When not to use: staying off the fold, a split view, or a grid. Those use reserved regions and Arrangement.DeviceHinge(iOS 27.1).status, plusangle: Angle.hingeon the context is optional. When to use: when it is nil, return the effect to zero. Do not crash. When not to use: do not read nil as “the device is closed.” Closed is.closed.UIHingeInteraction(iOS 27.1).init { interaction, update in }.update.hingeis an optionalUIHinge.angleis in radians. The status includes.unknown. When to use: add it to the view that should observe the hinge. The official sample callsaddInteractioninsideviewDidLoad. When not to use: nil here means the interaction left the hierarchy that provides updates. It does not necessarily mean “this phone has no hinge.”How often it updates. The header says the rate and the granularity change with the state of the system. When to use: treat the angle as a continuous input, and tolerate dropped frames. When not to use: do not calibrate, and do not animate layout, from a rule such as “it must arrive N times a second.”
Checkpoint
Can you decide whether a sidebar should appear by reading
hinge.angle?In SwiftUI, what does
hinge == nilmean? What does nil mean in UIKit?If you only want to know whether the device is partially open, do you read the angle, or
status?
Not in this chapter yet. Do not treat the outer display as a second window that you place yourself. That is the next chapter, and it is true only while you are capturing.
Chapter 11 · Extra content on the outer display is shown by the system only while you are capturing
After this chapter, you can. Explain how the primary scene moves between the two displays, and when you are allowed to put one piece of supplementary content on the outer display.
The example that breaks
When the device opens, split the interface into two UIWindows. One draws the inner display. The other draws the outer display. The system will not leave your second canvas on the face that has closed. The simulator has no camera either, so this kind of code cannot be checked in Device Hub.
The main experience follows the single primary scene
When the device opens or closes, the primary scene moves itself onto the other face. Every critical button stays on the primary interface. Supplementary content on the outer display may be interactive, but there should be little of it.
The only supported path is a camera-capture accessory. The device is open, the app is in the foreground and full screen on the inner display, and a capture session is underway. Only then might the system show that accessory on the outer display. The system decides when and where, and it may take the display back at any moment. Turn it off with isEnabled. Do not unregister in order to toggle it. State goes through the same model.
The simulator has no camera, so this section needs a real device. Which way a camera faces can reverse after the device opens, closes, or rotates. When you choose a camera, choose it by which face it points at. See Choosing a camera by the direction it faces.
ExternalNonInteractiveAccessory is a connected external display, and AirPlay. It is not the outer display of a Duo.
A prompter, while the primary interface can still capture
CameraCaptureAccessory is marked in the SDK as unavailable on macOS, tvOS, watchOS, visionOS, and Mac Catalyst. sceneAccessory and registerSceneAccessory(_:) have existed since iOS 27.0. cameraCapture(sceneConfiguration:) and the session role .windowCameraCaptureAccessory are iOS 27.1. When you need to hand context across to the scene delegate, use the factory method that takes userInfo, and read it back from UISceneConnectionOptions.sceneAccessoryUserInfo.
CameraView(model: model)
.sceneAccessory {
CameraCaptureAccessory(isEnabled: $model.prompterEnabled) {
Prompter(model: model)
}
.onAvailabilityChange { model.prompterAvailable = $0 }
}
let configuration = UISceneConfiguration(
name: "Capture Accessory",
sessionRole: .windowCameraCaptureAccessory)
let accessory = UISceneAccessory.cameraCapture(
sceneConfiguration: configuration)
registration = registerSceneAccessory(accessory)
// 配件场景的 delegate 画提词器。
// 快门仍在主场景里。
API
CameraCaptureAccessory(iOS 27.1).init(isEnabled:content:), attached insidesceneAccessory.onAvailabilityChangereports whether the system is still willing to show it. When to use: a prompter, a countdown, or letting the person being photographed see the picture. Register it on the same view as the capture UI. When not to use: the shutter, focus, and settings on the primary path. The outer display can disappear at any time.registerSceneAccessory(iOS 27.0). UIKit:UISceneAccessory.cameraCapture(sceneConfiguration:), with the role.windowCameraCaptureAccessory. The registration you get back is what you pass tounregisterSceneAccessory. When to use: a supplementary scene for as long as a capture session exists. When not to use: do not unregister in order to hide it for a moment. Use the enabled state.ExternalNonInteractiveAccessory. A display you are connected to, or AirPlay. When to use: you already intend to support a large external screen, and the content does not need to be interactive. When not to use: the outer display of a Duo that has been closed.The simulator. Device Hub can open, close, rotate, and fold. It has no camera. Most app extensions also cannot run in the Duo simulator. When to use: layout, bars, sheets, and Split View. When not to use: do not announce, from the simulator, that the capture accessory has already been verified.
Checkpoint
When you are not capturing, can you pin a dashboard to the outer display?
When the prompter is unavailable, where should the shutter be?
Why can’t this section be finished in the simulator alone?
That is as far as it goes. If the product has no camera, this chapter does not have to contain any code. Chapters 0 through 9 already cover the main path, from the design through to an adaptive layout.
Source: Preparing · Improve your app’s camera handling. Tech Talk 111465.
Appendix · A cross-check, the red flags, and the answers
The twelve chapters above build the judgments in order. This appendix puts the three layers of API, the patterns that should not appear, and the checkpoint answers in one place, so that after you finish a chapter you can come back and compare.
Three layers. Do not skip ahead
| Layer | When you write it | SwiftUI | UIKit |
|---|---|---|---|
| 1 · Any size | Chapters 3–6. Every Apple window benefits | NavigationSplitView, TabView, GridItem(.adaptive), ViewThatFits, AnyLayout, size class, onGeometryChange, system .toolbar |
UISplitViewController, UINavigationController, UITabBarController, container width, trait, layoutSubviews, viewWillTransition |
| 2 · Once you know about the fold and vertical bars | In Chapters 5–8, when a system container is not enough | reservedRegions, ArrangementView, toolbarVerticalBehavior, axisBehavior, visibilityPriority, presentationPlacement, toolbarVerticalEdge |
reservedRegions(kind:options:), UIArrangementViewController, preferredVerticalBarBehavior, axisBehavior, verticalBarEdge, preferredPlacement |
| 3 · Only the hardware itself | Chapters 10–11, and only when the product truly needs it | onHingeChange, CameraCaptureAccessory, sceneAccessory |
UIHingeInteraction, UISceneAccessory.cameraCapture, the role .windowCameraCaptureAccessory |
The rules for items in a vertical bar live in Chapter 5 because the 27.1 SDK stands the system bars up, not because you are supposed to query the fold. Querying the fold begins only in Chapter 7.
Availability, so the tiers do not get mixed together
| System | What this tier is the first to include |
|---|---|
| iOS 26 | ConcentricRectangle, backgroundExtensionEffect(), UIBackgroundExtensionView, UICornerConfiguration |
| iOS 27.0 | visibilityPriority, ToolbarOverflowMenu, topBarPinnedTrailing, presentationPlacement, defaultTabBarPlacement, isTabViewSidebarAvailable, sceneAccessory / registerSceneAccessory |
| iOS 27.1 | Vertical-bar behavior and the vertical-bar edge, axisBehavior, compression behavior, reserved regions, Arrangement, the hinge, CameraCaptureAccessory |
UIKit’s leadingItemGroups, pinnedTrailingGroup, and additionalOverflowItems have been available since iOS 16. On Duo they are what control order and overflow inside the vertical bar, but the properties themselves did not first appear in 27.1.
SwiftUI’s visibility priorities are .automatic / .low / .high. The matching values in UIKit are .standard / .low / .high. Compression behavior differs by one name as well: SwiftUI uses .prefersToolbarItems, and UIKit uses .prefersBarItems.
When you open a file, search for these first
If they are being used to do ordinary layout, they should be changed:
UIScreen.main.bounds,UIScreen.main.scale, and aUIScreenthat was stored for laterUIWindow(frame: UIScreen.main.bounds)userInterfaceIdiom == .pador.phoneUIDevice.current.orientation,statusBarOrientation, andinterfaceOrientation, includingeffectiveGeometry.interfaceOrientationTreating
horizontalSizeClass == .regularas the door into an “iPad-only” pagebounds.height == 844, or a table of known device heightssafeAreaInsets.left * 2if isDuo,if isFolded, and using the hinge angle to decide a sidebar or a column countA hard-coded large frame, a hard-coded sidebar width, or a hard-coded number of grid columns
A hand-drawn bar across the top, or a
UIToolbar/UINavigationBar/UITabBarthat you created on its ownA toolbar button that has only a title and no icon, and a “…” menu that you drew yourself
Critical content centered so that it sits on the fold, and one copy of state for compact with another copy for the expanded layout
A hero that uses
.fillwith no focus point, and that never chooses between fill and fit as the aspect ratio changestoolbarVerticalBehavior(.disabled)used outside the exceptions the documentation names, or flipped back and forth from page state
Postures to have looked at at least once in Device Hub
| Posture | What you should see |
|---|---|
| Closed, portrait | The outer display, compact width + regular height, a vertical bar |
| Closed, landscape | Compact in both directions, a vertical bar |
| Open, portrait | The inner display, regular × regular, horizontal bars. This is the only common posture that keeps bars at the top and bottom |
| Open, landscape | regular × regular, a vertical bar |
| Half-folded | The same hierarchy. Precise content does not sit astride the fold. Scrolling content does not reroute the whole page |
| Split View on the inner display, with you on the left and then on the right | The bar sits against your own outer edge. The state is still there |
Open every sheet, and every popover, once in each of these postures. Screenshots for the store follow the Screenshot specifications in App Store Connect. A common set to prepare is 1398×2034 for the outer display and 2007×2853 for the inner display, with the two sides swapped for landscape. Do not write that set of numbers into the layout. If a document says that submissions need Duo screenshots starting in April 2027, the exact date is whatever Apple announces at the time.
Checkpoint answers
Chapter 0. In portrait on the inner display, the system bars are at the top and bottom. The six postures are still one interface. After the device opens, it is still an iPhone app. The inner display has simply become regular × regular.
Chapter 1. A settings form that is 900pt wide has stretched the sentences out. The label and the switch are pulled apart by empty space. It should have a maximum width, and the spare space should be left for the next layer of the hierarchy. A gradient may stay in the center. A Pay button may not. Select All on a list follows the list. It does not follow the system bar at the far right.
Chapter 2. The deployment target can still be iOS 18. After you compile with the 27.1 SDK, the vertical bars appear. When there is empty space around the window, change the SDK you compile with. Do not detect the device model in order to stretch the frame. The new symbols do not exist on the older system, so they have to live inside #available.
Chapter 3. A regular width is true on Duo’s inner display, so it is no longer the same thing as iPad. The inner display ignores the orientations you declared. Whether the region is wide or tall is a comparison of that container’s width and height. The left inset and the right inset can differ. left * 2 miscalculates the other side.
Chapter 4. The selection lives in the same piece of state, outside the split view. The column count is the container width divided by the minimum item width, and here it lands on 2. ViewThatFits answers which arrangement fits.
Chapter 5. A button that has only a title does not enter the vertical bar. Inside a split view, only the bar of the detail column is vertical. A navigation-style page, by default, gathers the toolbar items into the overflow first and keeps the tab bar. A UIToolbar created on its own is not a bar supplied by the navigation container, so the system will not stand it up.
Chapter 6. On the inner display, the toolbar of a trailing sheet is vertical. The left and right safe areas are not symmetric, so they cannot be mirrored. A full-screen mask that you draw yourself does not avoid the fold the way a system sheet does.
Chapter 7. After the device is fully flat, division is not active, and its width is 0. Do not move a scrollable article off the fold one paragraph at a time. The camera on the inner display does not occupy an occlusion during ordinary use. It occupies one only while it is in use.
Chapter 8. A ZStack maps to overlay. Arrangement is wrapped by the navigation. Do not reverse that. Putting it inside a ScrollView makes one of the two pieces impossible to reach.
Chapter 9. The draft restores from the same piece of state, rather than from some other view tree. The vertical bar of an app on the right side of Split View is on the right, which is that app’s outer edge. A new window cannot be created on the outer display.
Chapter 10. A sidebar does not consult the hinge angle. In SwiftUI, nil means this device has no hinge. In UIKit, nil means the interaction has left the hierarchy that can provide updates. If you only want to know whether the device is partially open, read status.
Chapter 11. When you are not capturing, you cannot pin a dashboard to the outer display. The shutter stays on the primary interface. The simulator has no camera, so the capture accessory has to be tried on a real device.
Where to start in the official material
ReservedRegion, and UIKit’s
UIView.ReservedRegionArrangementView, and
UIArrangementViewControllerTech Talks: 111466 Design, 111461 Prepare, 111462 Vertical bars, 111463 Posture and Arrangement, 111464 Multiple displays and scenes, 111465 Camera
The Preparing page also lists WWDC 2026 · 278 and WWDC 2025 · 282
Best practice · Leave the bottom destinations where they are. Give the width to the content
After you have read this, you can. Hold this app up against the rules above, and separate what has already landed the way Apple describes it, what is a product choice, and what still cannot be treated as verified.
This app does not have a Duo edition. The deployment target is still iOS 18. Compiling with the 27.1 SDK is what fills the display and lets the system bars become vertical. Opening and closing do not change the route, and they do not change the page model. The extra regular width is used for the left and right columns inside Today and inside Breathe, rather than turning the three top-level destinations into a sidebar. The hinge angle, a camera view on the outer display, and multiple windows have no reason in this product, so they are not written.
The cross-check
| Judgment in the tutorial | What this app does |
|---|---|
| Chapters 0–2: one interface, and the SDK is the switch | Do not branch on the name of the device. Wrap new APIs in #available. On older systems, keep an HStack / VStack that expresses the same relationship |
| Chapters 3–4: reveal the next layer from the width of the container | A compact shell stays a single column no matter how wide the container gets. At regular, 800pt is still one column, and side by side begins only at 801pt. Only when there is room for a primary column of at most 480pt plus a readable detail of 720pt (1201pt together) does the layout enter the roomier tier. 1024pt is still side by side. The sentences are not stretched to fill the width |
| Chapter 5: system bars, while a sidebar is not mandatory | .sidebarAdaptable together with defaultTabBarPlacement(.tabBar), so the three tabs stay at the bottom |
| Chapter 6: a sheet declares its position; a mask you draw yourself will not avoid the fold | On regular, system sheets follow the intent and use trailing or center. The compact paywall is still a mask on the root, and it will not dodge the fold on its own |
| Chapters 7–8: move only the two pieces that belong together | Breathe, the horizontal band on the paywall, and equal-width primary and secondary columns use the split style of ArrangementView. The scrolling home screen and the scrolling detail do not reroute because of the fold |
| Chapters 9–11: state stays continuous, and the hardware APIs are optional | The selection and the route live outside the layout. There is no onHingeChange, no capture accessory, and no multiple windows |
Root navigation keeps three destinations
The inner display is able to become a sidebar. This product chooses not to. Today, Trends, and Breathe remain tabs along the bottom. Settings is pushed from Today. The iPad path uses defaultAdaptableTabBarPlacement(.tabBar), and on iOS 27 the code states defaultTabBarPlacement(.tabBar) again, explicitly. After the inner display opens, the extra space appears inside the page: Today puts cards on the left and detail on the right, and Breathe puts the mode on the left and the exercise on the right.
Side by side has to satisfy both of these at the same time: the shell of this page is regular, and the current horizontal size class is regular as well. That way the narrow tree cannot take the shared selection away. If the shell is compact, the tier stays a single column even when the container is very wide.
TabView(selection: adaptiveSectionSelection) {
// 三个 Tab
}
.tabViewStyle(.sidebarAdaptable)
.defaultAdaptableTabBarPlacement(.tabBar)
// iOS 27:content.defaultTabBarPlacement(.tabBar)
adaptiveShellRole == .regular && horizontalSizeClass == .regular
Arrangement only describes a relationship that already exists
Navigation stays on the outside. The layout on an older system and the Arrangement on 27.1 are the same relationship. They are not a second set of pages.
| Situation | The relationship it already had | 27.1 |
|---|---|---|
| The breathing exercise | The hero is on top, and the controls are below | .split. Tall means top and bottom, wide means left and right, and it stays off the fold. The axis is not locked, and the hinge angle is not consulted |
| The paywall, laid out across | The hero and the purchase sit side by side | .split. On older systems it is still an HStack |
| Equal-width primary and secondary columns | Both columns need space of their own | .split.axes(.horizontal), at a ratio of 0.5. The background may cross the fold. Content that can be tapped lives inside the Arrangement |
What the system will do, and what still cannot count as finished
Read the safe area on all four edges. Pictures inside a scrolling list, and pictures inside a detail, do not shift because the device is half-folded. System sheets: compact uses .automatic, and regular chooses trailing or center according to the intent.
Explicitly not done: do not place the breathing exercise, or the sidebar, from the hinge angle. There is no camera, so no capture accessory is registered. Do not create a new window on the outer display. Do not copy a page model once for each of the six postures. Come back to the question only when the product truly wants opening and closing to count as input, or when a capture screen appears.
Still not something you can call finished: the six postures still have to be looked at by hand in Device Hub. The simulator commands that are public cannot open, close, or half-fold the device. The compact paywall mask is not a system sheet. For an explanatory sheet that has only a close button, whether to turn the vertical bar off is still undecided. The fixed bar at the top, the one the shared-element transition uses, is still left outside the zoom layer. A build that compiles is not the same thing as having looked at the fold and at the vertical bars.
How a new page joins the app: put it into an existing tab, or into the navigation stack, first, and leave the state on the outside. Then decide single column or side by side from the container’s tier, and stop long text at about 720pt. Use Arrangement only when two pieces of content were already going to trade places, and keep the older layout behind #available(iOS 27.1, *). Write the system toolbar, the size class, and the readable width first. Write reserved regions and Arrangement after that. Keep leaving the hinge, and the secondary display, unwritten until the product needs them.
Check yourself against this app
After the inner display gets wider, should the three tabs rise into a sidebar, or stay along the bottom?
When a regular container is only 800pt wide, should Today be a single column, or side by side?
Why do the sphere and the controls in the breathing exercise use
.splitrather thanonHingeChange?When the compact paywall mask sits on the fold while the device is half-folded, can you say “the system sheet will avoid it”?
Answers. The three tabs stay along the bottom. 800pt is still a single column. Side by side starts at 801pt. The sphere and the controls are the upper and lower parts of the same exercise, so they are handed to split. The angle does not decide the layout. The paywall mask is not a system sheet, so it will not avoid the fold on its own.
Keep reading
Try these ideas in Stress HRV
Download on the App StoreThese notes share product thinking for personal wellness. They are not medical advice.