跪拜 Guibai
← Back to the summary

iPhone Duo Adaptation Field Notes: 8 Layout Pitfalls from a Shipping Photo App

Awaking iPhone Duo adaptation cover

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.

Awaking Duo landscape home recent work and creation entries

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.

Awaking Duo landscape showcase search, status filter, and four-column work directory

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

Awaking development build in Duo simulator landscape refining workspace

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.

Awaking processing order from window size to page layout

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.

Partial screenshot of Duo parameter panel and system side area

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

Partial image of Duo portrait top navigation and landscape back/action groups

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.

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.

Feedback loop between top navigation and Inspector, and the safe-area selection fix

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)
}

Partial screenshot of Duo filter Inspector three-column catalog

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.

Partial screenshot of Awaking Inspector exposure, contrast, and highlight sliders

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.

Partial screenshot of Awaking filter panel title, dual slots, and catalog area

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.

Awaking color inspiration detail in Duo landscape window

Awaking color inspiration detail in Duo portrait window

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.

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.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

余小二126

https://mp.weixin.qq.com/s/FWrrmD_pBBbT3ycS-uT7Lw