iPhone Duo Adaptation Field Notes: 8 Layout Pitfalls from a Shipping Photo App
Recently, I adapted my photo editing app Awaking for iPhone Duo. My initial thought was to enlarge the canvas and move the panel to the right. When I actually did it, every flow, home, gallery, editor, Light Tracing color matching, showcase detail, and export, had its own spatial problems.
Some problems were obvious. The three-column cards crossed the inspector edge, titles and sliders always seemed to miss by a bit, and a popover squeezed the photo very small. Some problems hid in the process. Opening directly in landscape worked, but opening in portrait and rotating changed everything. Buttons were still there, but their positions followed the previous layout. Another time, rotating from portrait to landscape directly triggered a continuous layout feedback loop.
First think clearly about what users can do after unfolding
Awaking's main scenario is continuous creation around a single photo. Users may first seek light and color from a reference image, then try filters, adjust parameters, add masks, and finally save the work or export it.
The most precious space here is the photo itself. Users need to see changes when dragging a parameter, know the current region when switching masks, and keep a comparison object when matching a reference image. The height the panel occupies directly affects these judgments.
I compiled the layouts for several common windows into the table below.
| Space form | Awaking's arrangement | Corresponding usage goal |
|---|---|---|
| Normal iPhone, Duo folded outer screen | Portrait canvas and compact bottom toolbar | Preserve one-handed browsing and quick editing order |
| Duo fully unfolded, portrait window | Larger canvas and expanded bottom area | Fit photo, assets, and parameters together |
| Duo fully unfolded, landscape window | Tool rail, canvas, and right-side Inspector | Make adjustment and observation side-by-side workspaces |
| Partially unfolded or constrained window | Allocate content based on active division and available size | Keep primary interactions inside actionable area |
That is Awaking's product choice. Other apps can choose their own structures based on reading, navigation, or creative tasks.
Home: put continue editing and start creating side by side
People who open Awaking sometimes want to continue the last photo, sometimes import a new one, and sometimes seek the direction of their next creation from a reference image. The home page needs to make these entries equally easy to find.
In the unfolded landscape space, I arranged "Recent Work" and "Start" as two columns. The left shows work thumbnails, draft status, time, and continue entries; the right concentrates opening photos, importing files, Light Tracing color matching, and other operations. Below that, color inspiration and other content let users either handle the task at hand or scroll down to browse.
The screenshot only shows two recent works, so a low-contrast placeholder card is left in the bottom-left to keep the bottom edges of the two columns aligned. It is static and excluded from taps and accessibility navigation. When there are three works, three cards appear. When there are none, an empty-state guide shows.
This cannot rely on whether the screen is landscape. The current implementation checks aspect ratio, at least 500pt height, and the width each column needs. The recent-work card then allocates space based on the actual height of the right-side action area, while ensuring text and buttons still fit. When English text grows or font size increases, the card can keep growing taller.
When switching to portrait or a narrow window, the two blocks stack vertically. The number of columns for color inspiration below also changes with available width. With this arrangement, in an unfolded landscape screen, "continue the last photo" and "start a new creation" are right in front of the user.
Showcase: bring more works into the same view
Each card in the showcase corresponds to a work that can be continued. When users come back, they need to recognize the image, title, draft or completed status, and the last processed time all at once.
So I gave most of the new width to the work directory. In the Duo landscape screenshot below, a row shows four works, with title, status, and time below each card. The page title stays fixed on top, while search and filter follow the directory and can stick to the top while browsing.
Search can find works by title, source, tag, or month. "All, Draft, Completed" narrows the current task scope. The "recently deleted" recovery hint is shown separately, so organizing works and continuing editing have clear entries.
Of course, four columns must fit. Code combines window width, minimum card width, and font size to decide the column count. As the window widens, columns increase; when it narrows or font size grows, they decrease. Work IDs, the current filter, and selected details stay in their original states; rotation only adjusts card positions.
Home's recent work and the showcase use the same type of work data. To continue the last photo, go in from home. To find older works, filter by status, or organize drafts, enter the showcase. When allocating space to these two pages, I also kept adjusting around these two usage patterns.
Editor: give space to the photo and current tool
The screenshot above is from the Duo simulator during adaptation. Tools are selected on the left, the photo is viewed in the middle, parameters for the current object are adjusted on the right, and actions like save and back go into the system side area. While adjusting parameters, the photo can stay in view.
After finishing these pages, I prefer to first see what the person on the page is trying to do, then decide how to divide the space. The home page suits common entries placed side by side, the gallery suits showing more images, and the editor needs the photo and parameters closer together.
Apple's preparation guide emphasizes getting size from the current view or window and using size classes and actual space to judge layout. Once Duo is unfolded, an iPhone app may enter a regular environment. Every branch in the project that directly treats regular as iPad is worth rechecking. Apple's Duo preparation guide
1. First distinguish window size from content size
The first thing to sort out here is where sizes come from.
Photo editors typically have multiple nested containers. The root window wraps navigation, below navigation is a workspace, and inside the workspace are canvas, tool rail, and Inspector. Each layer may receive a different width and height.
If a page, after deducting navigation, again decides the whole-page structure, the navigation position may participate in the next round of layout selection. Later I handled the two issues separately.
- The root window expresses the current folded window's layout intent based on system hinge state, size class, and root container aspect ratio.
- The workspace allocates concrete regions based on local available area, occlusions, and each part's minimum size.
- Pages, panels, and navigation all read the same layout result.
This is a code excerpt for the root window decision. size comes from the root GeometryReader, and both portrait and landscape meanings use the current window aspect ratio.
enum AdaptiveWindowLayout: Equatable {
enum HingeState: Equatable {
case unavailable, closed, partiallyOpen, fullyOpen
}
case standard
case compactPhone
case expandedPhonePortrait
case expandedPhoneLandscape
case dividedPhone
static func resolve(
size: CGSize,
hinge: HingeState,
isRegularWidth: Bool,
isRegularHeight: Bool
) -> Self {
guard hinge != .unavailable else { return .standard }
guard hinge != .closed, isRegularWidth else { return .compactPhone }
guard hinge == .fullyOpen else { return .dividedPhone }
guard isRegularHeight, size.width > 0, size.height > 0 else {
return .standard
}
return size.width > size.height
? .expandedPhoneLandscape
: .expandedPhonePortrait
}
}
The result is passed down through the environment. When hinge information is unavailable, it falls back to a regular window. Local task cards and narrow popovers still lay out by their own sizes; the rules for whole-page expanded layout need to be re-scoped here.
Another concept easily conflated is structure vs. visual density. A workspace using a bottom panel does not directly decide font size, spacing, and tap targets. A window that is very wide but not tall enough can fall back to bottom structure while keeping a visual density suited for a wide window.
In code, WorkspaceMode represents layout structure, a width tier is recorded separately, and both are placed into WorkspacePresentation. This lets panel placement and control font size/spacing each adjust independently to actual space.
During window changes, the photo-level tool model, drafts, undo history, and navigation state are continuously held by the upper layer. For the editor, the photo and edit target selected before rotation are the starting point for the next layout.
2. Beyond the safe area, also handle local reserved regions
Beyond safe areas, Duo brings local regions that need separate understanding.
division describes regions that separate content, and occlusion describes partial obstruction. Custom canvases and tool panels need to arrange content around these regions. Awaking obtains them at a unified entry and keeps frames in the current GeometryProxy's local coordinate system. Apple's introduction to reserved regions and adaptive containers
static func resolve(
geometry: GeometryProxy,
environment: EnvironmentValues
) -> Self {
if #available(iOS 27.1, macOS 27.1, *) {
return Self(
availableSize: geometry.size,
horizontalSizeClass: environment.horizontalSizeClass,
verticalBarEdge: environment.toolbarVerticalEdge,
divisionRegions: geometry.reservedRegions(kind: .division)
.filter(\.isActive).map(\.frame),
occlusionRegions: geometry.reservedRegions(kind: .occlusion)
.filter(\.isActive).map(\.frame),
windowLayout: environment.adaptiveWindowLayout
)
}
return Self(
availableSize: geometry.size,
horizontalSizeClass: environment.horizontalSizeClass
)
}
There were two actual pitfalls here.
The first is double-subtracting the safe area. The outer layer already gives a safe content size; subtracting an inset again shrinks the canvas a further amount. Later I defined this boundary clearly. The outer layer handles safe area, and the inside uses the obtained content area directly for calculation.
The second is stacking margins. The system side bar's adjacent position already has content spacing; if the page continues to add full padding, one side looks loose and the other tight.
I measured the system adjacent spacing as 12pt in this layout. When the target visual spacing is 16pt, the app adds 4pt on the corresponding side. This value belongs to the current layout's measurement and implementation configuration; other projects should verify their own containers.
Also, the side bar's leading and trailing must be handled separately. Left-right split screen changes adjacency, so padding calculation and grid width calculation must use the same set of margins.
This partial image preserves the actual relationship of canvas edge, parameter panel, and system side area. Margin calculation must consider both adjacent sides; the exact point values are still determined by the containing container's measurement and layout configuration.
3. Buttons are still there, but position is wrong after rotation
Save, back, share, these buttons, when moved, easily lose their original enabled conditions, confirmation popovers, and action order. During Duo adaptation, these all need to be checked together.
Awaking's unfolded inner-screen navigation arrangement is as follows.
| Current workspace | Back or close | Other page-level actions |
|---|---|---|
| Portrait, or landscape without Inspector | Top navigation leading | Same navigation trailing |
| Landscape Inspector regular task page | Below right status area | Action group anchored at bottom right |
| Landscape Inspector showcase detail | Below right status area | Right of artwork info title |
| Landscape Inspector photo organization | Below right status area | Right of organization suggestion title |
Above is the portrait top navigation for Light Tracing, below is the landscape back and action grouping for the refine page. Cropping these pieces and putting them side by side makes the button position changes clearer. Both action bars use the same set of position rules.
The latter two items are arranged separately according to page purpose. Showcase detail actions sit with the work info; photo organizing actions sit with the organizing suggestion. The page declares this configuration and lets the shared navigation component decide the displayed position.
During migration, a very typical regression appeared. After the first round of adjustments, the action buttons stopped at the bottom when rotated to portrait, then stayed at the top when rotated back to landscape. The buttons' enabled states were correct, but the layout positions were wrong.
I then split top and side toolbar items into fixed identities, each bound to a fixed placement and axis behavior. The outer navigation host remains stable, and on rotation it switches between the corresponding item sets. Below is the simplified organization, where business button contents and callbacks are passed in by the page.
.toolbar {
if isVertical {
ToolbarItem(id: "navigation_side_back", placement: .cancellationAction) {
backButton
}
.axisBehavior(.verticalPreferred)
ForEach(sideItems) { item in
ToolbarItem(id: "navigation_side_\(item.id)", placement: .bottomBar) {
item.content
}
.axisBehavior(.verticalPreferred)
.visibilityPriority(item.isPrimary ? .high : .automatic)
}
} else {
ToolbarItem(id: "navigation_top_back", placement: .topBarLeading) {
backButton
}
.axisBehavior(.horizontalOnly)
ForEach(topItems) { item in
ToolbarItem(id: "navigation_top_\(item.id)", placement: .topBarTrailing) {
item.content
}
.axisBehavior(.horizontalOnly)
}
}
}
In this code, isVertical represents the already-resolved side navigation presentation. The page no longer needs to read the physical orientation to decide direction. New APIs like axisBehavior are used within an iOS 27.1 availability branch; older systems continue to use the corresponding compatibility entry. Apple's toolbar axis documentation
There are several more places to check together.
- For an untitled editing workspace, decide whether to register a principal toolbar item based on actual content.
- The save button observes the real editing state inside the toolbar, so the first load completion and edit end update availability promptly.
- A tap on top or side continues through the original mask handling, exit confirmation, and save flow.
- When space shrinks, give key actions a visible priority and check the system overflow menu.
- When verifying buttons, check uniqueness, position, tappability, and return target together.
Apple's vertical bar design notes are worth reading side by side, especially action order, axis expression of custom content, and overflow priority. When a page has multiple independent buttons, expressing these semantics item by item is clearer. Apple's Duo navigation and toolbar introduction
4. Portrait-to-landscape hangs: layout feedback loop
This round's most time-consuming issue happened when the refine page rotated from portrait to landscape.
The on-site logs kept showing Observation feedback loop and navigation constraint conflicts. After further sampling, I observed the Inspector alternately entering and exiting under the same navigation size, then obtained two consecutive inputs for the workspace solver.
| Stage | Available area | Solver result at the time |
|---|---|---|
| Top navigation | 867 × 553pt | Enough to arrange tool rail, canvas, and Inspector |
| Side navigation | 867 × 611pt, partial occlusion at top-right | Max-area strategy selected left area 807 × 611pt |
The mid-width workspace budget is 56pt tool rail, at least 480pt canvas, and 280pt Inspector.
807 - 56 - 480 = 271
Only 271pt remained, so the Inspector exited. After exiting, navigation returned to top, the input became the first row again, and the Inspector re-entered.
Below the top-right occlusion, there is actually another safe rectangle of 867 × 553pt. Its area is slightly smaller, but it satisfies the workspace constraints. The original algorithm compared area first and missed whether a candidate region could support the layout.
After the fix, candidate regions first pass the same Inspector feasibility check. When feasible candidates exist, area is compared among them; when space really is insufficient, it falls back normally. The core filtering code is short.
let preferred = preferredRegion.map { candidates.filter($0) } ?? []
return (preferred.isEmpty ? candidates : preferred).max {
$0.width * $0.height < $1.width * $1.height
} ?? available
Here preferredRegion reuses the workspace's existing size judgement. The whole fix means that region selection and structure selection obey the same set of constraints.
I also wrote the on-site consecutive inputs into a regression test. The test selects the next round's input based on the previous round's Inspector state, solves eight rounds consecutively, and checks structure, minimum canvas, Inspector width, and occlusion relation each round.
for _ in 0..<8 {
let context = presentation?.panelPlacement == .inspector ? side : top
let resolved = WorkspaceLayoutMetrics.presentation(
for: context,
reservedLeadingWidth: 80,
current: presentation
)
XCTAssertEqual(resolved.panelPlacement, .inspector)
XCTAssertGreaterThanOrEqual(
resolved.primaryRegion.width - resolved.toolRailWidth,
480
)
XCTAssertEqual(resolved.inspectorWidth, 280)
if !context.occlusionRegions.isEmpty {
XCTAssertFalse(resolved.contentRegion.intersects(camera))
}
presentation = resolved
}
The passed-in reservedLeadingWidth expresses the tool-rail placeholder need, and the solver gets the actual rail width from the current workspace. The test also includes fallback when no feasible Inspector region exists, ensuring the choice always respects occlusions.
After this, I started specifically testing continuously changing inputs. Given just one width and height, the calculation can be completely correct. After navigation position changes, the next set of sizes and occlusion information is produced; the whole chain has to be connected and run several rounds to see whether the layout stabilizes.
5. Three-column catalog overflows: cards must shrink too
The next area is the frame and effect panel. At first glance it was just the right side extending out; actually it involved inspector width, page margins, column spacing, and card width itself.
These two catalogs ultimately stayed at three columns, with cards dividing equally across the available content width. This keeps their browsing rhythm within the Inspector. The gallery and preset catalogs choose column counts based on their own content, width, and text length.
The calculation starts from the actual Inspector width, subtracts the left and right padding used in this drawing pass, then subtracts two column spacings.
static func inspectorCatalogItemWidth(contentWidth: CGFloat) -> CGFloat {
let columns = CGFloat(inspectorCatalogColumnCount)
let availableWidth = max(
0,
contentWidth - inspectorCatalogColumnSpacing * (columns - 1)
)
return floor(availableWidth / columns)
}
Here is a partial three-column catalog in the filter panel, to show the relationship between cards, column spacing, and the selected outline. Frames and effects also need to check their own card sizes and internal previews separately.
When the outer grid narrows, the frame examples, template previews, and card heights must shrink as well. Changing only the GridItem to flexible while the preview inside still uses a fixed width can still overflow.
The column count must be judged together with the page's responsibility. How the main canvas avoids an active folded area is handled by the upper workspace. A particular Inspector already allocated uses three columns as a catalog design within that region. Whole-screen partitioning and local card grids each need their own boundaries.
6. Sliders misaligned: look at actual track endpoints
In a narrow Inspector, the original title, slider, and value in one row easily crowd each other. I changed ordinary parameters to two rows: title left and value right on the first, and a full-width track on the second.
Cropping out the three parameter groups makes the relationship between title, value button, and slider clear. Each row must simultaneously handle text alignment and finger operating space.
After the structure change, the title and value box were still slightly misaligned. Checking showed that the native slider's container bounds differ from its visible track endpoints, and the custom thumb and range text also participate in the space.
I then had the UIKit control report the track's actual left and right insets, and used those two values to adjust the SwiftUI title row. The calculation happens when the track refreshes.
let trackRect = trackRect(forBounds: bounds)
let minimumX = thumbCenterX(for: minimumValue, trackRect: trackRect)
let maximumX = thumbCenterX(for: maximumValue, trackRect: trackRect)
let trackMinimumX = min(minimumX, maximumX)
let trackMaximumX = max(minimumX, maximumX)
let insets = UIEdgeInsets(
top: 0,
left: max(0, trackMinimumX - bounds.minX),
bottom: 0,
right: max(0, bounds.maxX - trackMaximumX)
)
if reportedTrackInsets != insets {
reportedTrackInsets = insets
onTrackInsetsChanged?(insets)
}
The code takes the left and right boundaries from the real drawn endpoints, and the title row uses the same insets. The update bridged to SwiftUI is published asynchronously and verifies that the current geometry values are still the same.
Update frequency also needs controlling. Report when track width changes; when merely dragging the value, reuse the existing geometry. Otherwise a fix meant for alignment could spread parameter changes into continuous layout updates.
After alignment, also need to check how far the title is from the track, whether the value button is easy to tap, and whether double-click reset is comfortable. The slider looking neat is only part of it.
7. Catalog jumps during loading: check view and scrolling first
The filter and mask catalogs also had position jumps during loading. The title and upper control area were stable, but the catalog below moved.
The problem is right in this area. The title and dual slots above are stable, but the category catalog below moves during loading, so I traced down the catalog creation and scroll positioning.
There are two chains worth chasing in the code.
One switches to an independent skeleton grid while the source image loads, then rebuilds the real catalog and scroll container once the source is ready. Another executes an initial scrollTo when a single-category vertical catalog mounts.
After adjustment, the catalog and cards stay in place the whole time, and when the image finishes loading, the card content is updated. Also, first positioning, category switch, tap-to-center, and manual scrolling are handled separately, so multiple scroll requests don't pile up.
This is especially important for a photo editor. After a user taps a filter, they may immediately compare neighboring effects. The catalog's position itself is part of the editing context.
The same thinking applies to thumbnails. When the pixel budget grows with display size, keep the last valid image, update after the new result arrives, and verify the photo source and revision. Adaptation expands display space, and also expands image requests; the loading chain needs checking together.
This part now has code and scroll-policy tests; the first-load feel and continuous scrolling feel need further review.
8. Opening in landscape works, but rotating over changes things
The color inspiration detail went through several rounds of adjustment. I first tried using landscape space for photo and description, and later, based on actual viewing experience, converged on a bounded single-column photo detail.
There are two very specific problems.
Header repeatedly takes up photo space
The task card already has a top and close area; inside, another whole-page navigation was nested, so the title and close button moved down as a whole, reducing the available photo height.
Here you need to first distinguish what the whole page and the popover each handle. Where the close button sits, how much height the title takes, and how much space the photo has left all need to be calculated in the current container. Nesting one more layer of navigation finally squeezes the photo.
Same orientation, two paths to open, different results
The other problem is easier to miss in testing. Opening directly in landscape looks correct, but opening from portrait and then rotating keeps the popover height from the previous orientation.
I traced two sources. The initial width participates in container selection, and the detail height reads the Sheet's own measured value. During rotation, these results can carry the old layout into the next calculation.
Finally, this kind of detail uses the system Sheet uniformly, takes the current available height of the external window to arrange content, and the photo displays proportionally in the remaining space. The reading order is also fixed: title, photo, description, and primary button.
These two images show the same "Warm Table Light" detail. The material is provided by Stelios, and the interface includes the corresponding credit.
Later my check path added four combinations: direct landscape open, direct portrait open, landscape-to-portrait, portrait-to-landscape. Also, entering from both home and gallery, and comparing the final same-orientation container, close button, photo ratio, and primary action position.
How I'd arrange changes and testing for this adaptation round
Looking back at this round, first understand the system's spatial model, then gradually land it from the existing project's size sources and lifecycle; this makes the change easier to verify. The relationship among window, safe area, and navigation needs to be considered together with the task each page serves.
For Awaking, a more effective order is: first define each page's task and unfolding benefit, then unify spatial input and layout results, then integrate navigation strategy, and finally handle catalogs, parameters, loading, and popovers one by one. Each round of fixes goes through real entries, covering both direct opening and results after state changes.
Now, for similar adjustments, I check in three layers.
| Verification layer | Questions to answer |
|---|---|
| Pure value and policy regression | Does the region overflow, does the size budget hold, do consecutive inputs converge, how does insufficient space fall back |
| Full integration build | Can the current app, components, and new system APIs compile together, are old-system branches complete |
| Runtime check | Are button positions and taps correct, is state continuous after rotation, do scrolling and photo feel hold |
After the rotation hang was fixed, I re-ran the refine and Light Tracing rotation paths in the same Duo simulator; the feedback loop and navigation constraint conflicts in the logs both went to zero. While writing this article, I also ran an offline test related to occlusion regions: 10 passed, 0 failed.
The inspiration detail was also checked for direct open and after-rotation display. Next I need to continue adding English, large font sizes, and more device combinations; slider feel and catalog loading stability will also be reviewed.
Here is a checklist you can take back to your own project.
- Sizes come from the current window or view and update continuously during changes.
- The root window, post-navigation content area, and local popovers have clear size sources.
- Regular, device type, layout structure, and visual density each express their own meaning.
- Safe area is handled once by the agreed layer; local reserved regions use a consistent coordinate.
- After navigation position changes, button identity, order, uniqueness, and business actions still correspond.
- Consecutive layout inputs can converge; minimum content size and occlusion constraints hold simultaneously.
- Grid width, actual padding, and internal image sizes use the same budget.
- Parameter components each check visual endpoints, touch hot zones, and update frequency.
- Before and after loading, check card identity, scroll position, thumbnail source, and stale results.
- Verify the same orientation by both direct opening and rotating into it, and continue the full flow with a draft.
Final thoughts
Through this round, I spent a lot of time looking back and forth at the same photo in different windows. How home continues the last edit, how the showcase finds works, how parameters are placed for easy see-and-adjust, these details eventually connect.
If you are also developing a canvas, creation tool, or content editor, I hope these real problems help you avoid some detours.
If you also like photo retouching, feel free to try Awaking. Start with a photo you know well, try the reference image's light and color, then refine with filters, color adjustment, and masks. Ideas about adaptation and usage experience are welcome in the comments.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
https://mp.weixin.qq.com/s/FWrrmD_pBBbT3ycS-uT7Lw