To match side controls to an aspect-ratio SwiftUI preview, calculate the fitted preview size from the available window space, then give the controls that same height. This macOS example keeps a synthetic preview proportional and makes overflowing controls scroll.

The task is a resizable desktop editor with a preview on the left and a fixed-width controls panel on the right. Here, “preview” means content inside your running app. The goal is to align the two panels’ top and bottom layout edges as the window changes size.

Which height should the controls follow?

Start by defining the rectangle you want to match. An aspect-fitted preview can occupy less space than its surrounding container. If the controls follow the container’s height, they can extend below the visible preview even when both views are correctly laid out.

Apple defines the aspect ratio as width divided by height. For resizable content, aspectRatio(_:contentMode:) with .fit preserves that ratio while fitting the content within the proposed space. The documentation’s ellipse example shows why the fitted shape and an enclosing square frame can have different dimensions. Apple’s aspect-ratio documentation.

This implementation makes the fitted rectangle explicit. It reserves a fixed width for the controls and a gap between the panels, then fits the preview into the remaining width and available height. Both panels receive the calculated preview height.

That is a layout policy for this example: the preview determines the controls viewport height. A long controls list scrolls inside that viewport instead of making the preview taller. Apple documents ScrollView as displaying a movable portion of its underlying content within a scrollable region. Apple’s scroll-view documentation.

Calculate the fitted rectangle once

Let W and H be the available content dimensions after outer padding, C the controls width, G the gap, and R the preview’s width-to-height ratio. The calculation is:

remainingWidth = max(0, W - C - G)
previewWidth = min(remainingWidth, H * R)
previewHeight = previewWidth / R
controlsHeight = previewHeight

The first limit prevents the preview from using the controls’ horizontal space. The second prevents a wide window from producing a preview taller than the available area. Calculating height from the selected width preserves the ratio in either case.

For example, suppose the content area before padding is 960 by 600 points. With 16-point padding on every edge, a 220-point controls panel, a 16-point gap, and a 16:9 preview, the available area is 928 by 568 points. The remaining preview width is 692 points, giving a preview height of 389.25 points. The controls viewport therefore becomes 220 by 389.25 points.

These are illustrative inputs, not measured application results. They also show why matching the controls to the full 568-point available height would produce the wrong alignment.

Use CGFloat for the layout dimensions and a positive, finite ratio. In Swift, let ratio: CGFloat = 16.0 / 9.0 makes the intended floating-point calculation explicit. If your app later accepts configurable ratios, validate them before division.

Build the complete macOS example

Create a macOS SwiftUI app in Xcode and set its deployment target to macOS 13 or later. Replace the generated app and content-view declarations with this single file, leaving exactly one @main declaration in the target. The example needs no assets or external packages.

The deployment requirement comes from the window-sizing API used here. The scene uses .contentMinSize, which Apple documents as deriving the window minimum from its content while imposing no maximum size. Apple’s window-resizability documentation, Apple’s content-minimum sizing documentation.

import SwiftUI

@main
struct MatchedPreviewApp: App {
    var body: some Scene {
        WindowGroup {
            PreviewEditor()
                .frame(minWidth: 640, minHeight: 360)
        }
        .windowResizability(.contentMinSize)
    }
}

struct PreviewEditor: View {
    @State private var intensity: Double = 0.5
    @State private var showsLabel = true

    private let ratio: CGFloat = 16.0 / 9.0
    private let controlsWidth: CGFloat = 220
    private let gap: CGFloat = 16

    var body: some View {
        GeometryReader { geometry in
            let availableHeight = max(0, geometry.size.height)
            let remainingWidth = max(
                0,
                geometry.size.width - controlsWidth - gap
            )
            let previewWidth = min(
                remainingWidth,
                availableHeight * ratio
            )
            let previewHeight = previewWidth / ratio

            HStack(alignment: .top, spacing: gap) {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.blue.opacity(0.2 + intensity * 0.7))
                    .aspectRatio(ratio, contentMode: .fit)
                    .overlay {
                        Text("Sample composition")
                            .font(.headline)
                            .opacity(showsLabel ? 1 : 0)
                    }
                    .frame(
                        width: previewWidth,
                        height: previewHeight
                    )

                ScrollView(.vertical) {
                    VStack(alignment: .leading, spacing: 16) {
                        Text("Preview controls")
                            .font(.headline)

                        Toggle("Show label", isOn: $showsLabel)

                        VStack(alignment: .leading, spacing: 8) {
                            Text("Color intensity")
                            Slider(value: $intensity, in: 0...1)
                        }

                        ForEach(1...8, id: \.self) { index in
                            Text("Sample setting \(index)")
                                .font(.caption)
                        }
                    }
                    .padding(16)
                    .frame(maxWidth: .infinity, alignment: .leading)
                }
                .frame(
                    width: controlsWidth,
                    height: previewHeight
                )
                .background(Color.gray.opacity(0.08))
            }
            .frame(
                width: geometry.size.width,
                height: geometry.size.height,
                alignment: .center
            )
        }
        .padding(16)
    }
}

GeometryReader supplies the size of the region allocated to the editor. Apple describes it as defining content from its own size and coordinate space, with a flexible preferred size. Placing the padding outside the reader means the calculation uses the inset content area. Apple’s geometry-reader documentation.

The two fixed frames establish the shared height. Apple documents frame(width:height:alignment:) as creating an invisible frame with the specified dimensions. The controls background comes after that frame, so it covers the controls viewport, including any space below a short list. Apple’s fixed-frame documentation.

The final frame centers the complete row within the reader. It does not enlarge either panel. Consequently, a tall window can contain unused space above and below the row while the controls still match the preview.

Preserve state and lifecycle behavior during resizing

Keep user-editable values separate from calculated geometry. In the example, intensity and showsLabel are state; preview width and height are local values derived from the current available size.

Apple documents state as SwiftUI-managed storage whose changes update dependent parts of the view hierarchy. This supports keeping the slider and toggle values in the editor that owns them. Apple’s state documentation.

The layout calculation contains no state assignments, appearance callbacks, asynchronous work, or measurement feedback. Resizing changes the dimensions used by the same view structure. The label remains present and changes opacity when the toggle changes.

When adapting this to an existing editor, retain its model ownership and binding types. Apply the sizing policy around the preview and controls; do not relocate model creation into the geometry calculation. This example’s arithmetic only requires the available dimensions and the chosen ratio.

Check resizing, overflow, and modifier order

Use the following dimensions as calculation checks. They describe the content area before the example’s outer padding, not the window’s exterior frame. The expected values are rounded to two decimal places.

Content area Preview size Controls viewport
960 × 600 points 692 × 389.25 220 × 389.25
640 × 360 points 372 × 209.25 220 × 209.25
1200 × 400 points 654.22 × 368 220 × 368
700 × 900 points 432 × 243 220 × 243

The arithmetic was checked with a small Python calculation, including assertions that the fitted preview stays within the available height and that both panels plus the gap fit horizontally. Swift and Xcode were unavailable in the research environment, so the complete SwiftUI program was inspected but not compiled or run.

In Xcode, run the app and resize it through wide, short, and tall shapes. Confirm that the panel edges remain aligned, that the controls scroll when their contents exceed the viewport, and that the slider and toggle retain their values during resizing.

If alignment still differs after adapting the example, inspect these specific conditions:

  • Extra panel padding: Padding applied outside only one panel’s fixed frame increases that panel’s total footprint. Keep controls padding inside the scroll view, as shown.
  • A larger preview wrapper: If you add a larger frame around the preview, decide whether controls should match that wrapper or the fitted content. The calculation here matches the fitted rectangle.
  • Overflowing custom content: A frame establishes layout dimensions but does not automatically hide drawing outside them. Apple documents .clipped() for rectangular clipping; use it when intentional preview overflow should be hidden. Keep controls scrollable so all settings remain reachable. Apple’s clipping documentation.
  • Insufficient host space: The example relies on its minimum content size. If embedded in a smaller host, its fixed controls width may leave no useful preview width. Choose a smaller panel, a vertical arrangement, or an explicit minimum for that host.

When should you use a custom layout instead?

This approach suits an editor that receives a bounded window region and uses a known aspect ratio. Its sizing policy is explicit and does not depend on measuring the preview’s internal content.

If your reusable component must determine its preferred size from its children, consider a custom Layout. Apple’s documented contract separates reporting the composite size through sizeThatFits(proposal:subviews:cache:) from positioning children through placeSubviews(in:proposal:subviews:cache:). Subview proxies provide measurement and placement operations. Apple’s layout documentation.

For the bounded desktop editor shown here, a shared fitted rectangle is sufficient: reserve horizontal space for the controls, fit the preview against both available dimensions, and assign its height to the controls viewport. That gives you a clear resizing rule while leaving the controls’ content free to scroll.

Sources