To keep overlays aligned with an aspect-fitted SwiftUI diagram, measure the fitted diagram itself and place markers in that local coordinate space. Attach the measuring overlay before any outer frame that adds empty space.

This tutorial builds a small macOS app with a synthetic diagram and two numbered markers. The task is to keep each marker centered on its diagram feature while the window changes shape. The implementation uses normalized coordinates, so annotation positions remain independent of the window’s dimensions.

Why do overlays drift away from an aspect-fitted diagram?

Aspect fitting preserves a diagram’s proportions while fitting it within the available space. Depending on the container’s proportions, that can leave empty space above and below the diagram, or on its sides. Apple illustrates this behavior in Fitting images into available space.

Drift occurs when the artwork and its markers use different rectangles. For example, the artwork might occupy a centered rectangle measuring 600 by 375 points inside a container measuring 600 by 700 points. A marker positioned using a fraction of the container’s height includes the surrounding empty space in its calculation.

Modifier order determines which rectangle you measure. Apple documents that view modifiers wrap the receiving view, and that a frame positions its child within another rectangle. An overlay attached after an expanding frame therefore measures a different layout boundary from one attached directly to the fitted artwork. See Apple’s View documentation and flexible frame documentation.

For this task, use the following order:

  1. Draw flexible artwork.
  2. Apply its explicit aspect ratio with .fit.
  3. Attach an overlay containing GeometryReader.
  4. Position markers using that reader’s size.
  5. Add the outer frame and surrounding padding.

Apple recommends an overlay when the modified view should dominate layout. That makes the diagram a suitable layout owner, with annotations layered over it. A sibling arrangement in a ZStack has different layout behavior and requires more care about which view determines the size. See overlay(alignment:content:).

Build a complete macOS example

Create a macOS app project in Xcode using the SwiftUI interface. Set its deployment target to macOS 12 or later, because the closure-based overlay API used here is available from macOS 12. Replace the generated app entry-point file with the following complete program, keeping only one @main app declaration in the target. The API’s availability is documented on overlay(alignment:content:).

The synthetic artwork represents an 800-by-500 design space, giving it an aspect ratio of 1.6. Marker coordinates are fractions of that space: horizontal zero is the left edge, vertical zero is the top edge, and one represents the opposite edge. No asset files or external packages are required.

import SwiftUI

@main
struct DiagramAlignmentApp: App {
    var body: some Scene {
        WindowGroup {
            DiagramExample()
                .frame(minWidth: 360, minHeight: 260)
        }
    }
}

struct DiagramMarker: Identifiable {
    let id: Int
    let label: String
    let x: CGFloat
    let y: CGFloat
}

struct DiagramExample: View {
    let markers = [
        DiagramMarker(id: 1, label: "1", x: 0.25, y: 0.40),
        DiagramMarker(id: 2, label: "2", x: 0.75, y: 0.60)
    ]

    var body: some View {
        Rectangle()
            .fill(Color.white)
            .overlay {
                DiagramArtwork()
                    .stroke(Color.gray, lineWidth: 2)
            }
            .aspectRatio(800.0 / 500.0, contentMode: .fit)
            .overlay {
                GeometryReader { geometry in
                    ForEach(markers) { marker in
                        Circle()
                            .fill(Color.blue)
                            .frame(width: 28, height: 28)
                            .overlay {
                                Text(marker.label)
                                    .font(.caption.bold())
                                    .foregroundColor(.white)
                            }
                            .position(
                                x: marker.x * geometry.size.width,
                                y: marker.y * geometry.size.height
                            )
                    }
                }
            }
            .border(Color.gray)
            .frame(
                maxWidth: .infinity,
                maxHeight: .infinity,
                alignment: .center
            )
            .padding(24)
            .background(Color.gray.opacity(0.12))
    }
}

struct DiagramArtwork: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()

        path.addRect(CGRect(
            x: rect.minX + 0.10 * rect.width,
            y: rect.minY + 0.20 * rect.height,
            width: 0.30 * rect.width,
            height: 0.40 * rect.height
        ))

        path.addRect(CGRect(
            x: rect.minX + 0.60 * rect.width,
            y: rect.minY + 0.40 * rect.height,
            width: 0.30 * rect.width,
            height: 0.40 * rect.height
        ))

        path.move(to: CGPoint(
            x: rect.minX + 0.25 * rect.width,
            y: rect.minY + 0.40 * rect.height
        ))
        path.addLine(to: CGPoint(
            x: rect.minX + 0.75 * rect.width,
            y: rect.minY + 0.60 * rect.height
        ))

        return path
    }
}

Run the app and resize the window horizontally and vertically. Marker 1 should remain at the first rectangle’s center, and marker 2 at the second rectangle’s center. The thin border identifies the fitted diagram boundary; the gray surrounding area belongs to the outer layout.

Why this modifier order works

The base rectangle supplies flexible artwork, and .aspectRatio(800.0 / 500.0, contentMode: .fit) constrains its proportions. Apple defines the aspect-ratio argument as width divided by height and specifies that the fitted result applies to resizable content. See aspectRatio(_:contentMode:).

The measuring overlay comes immediately after that constraint. GeometryReader defines its content from its own size and coordinate space, while geometry.size reports the container’s size. Here, that container covers the fitted rectangle. Multiplying normalized coordinates by its width and height therefore produces local marker positions. See GeometryReader and GeometryProxy.size.

The final expanding frame centers the completed diagram-and-marker composition within the available area. Because the markers are already attached to the inner rectangle, they do not need to calculate the outer margins.

Preserve the drawing and positioning contracts

DiagramArtwork conforms to Shape, and its path(in:) method returns a Path expressed within the supplied rectangle. It does not return a view or perform layout mutations. That follows Apple’s documented path(in:) contract.

Each marker’s .position(x:y:) places its center at the calculated point. It does not place its top-left corner there. The example deliberately anchors the center of each numbered circle to a feature, matching Apple’s position(x:y:) definition.

The circles remain 28 points across as the window resizes, while their centers follow the artwork. This is a deliberate presentation choice for readable annotations. If marker dimensions represent distances within the drawing, calculate those dimensions from the fitted size as well.

Check alignment with a worked coordinate calculation

A numerical check helps distinguish a coordinate mistake from a styling problem. For centered aspect fitting, let the positive source dimensions be sourceWidth and sourceHeight, and the finite available dimensions be containerWidth and containerHeight. The following is mathematical pseudocode, not a separate Swift program:

scale = min(containerWidth / sourceWidth,
            containerHeight / sourceHeight)

fittedWidth = sourceWidth * scale
fittedHeight = sourceHeight * scale

originX = (containerWidth - fittedWidth) / 2
originY = (containerHeight - fittedHeight) / 2

containerX = originX + normalizedX * fittedWidth
containerY = originY + normalizedY * fittedHeight

These equations derive a uniform fit and centered placement from the documented fitting behavior. Using the illustrative 800-by-500 source and marker 1 at (0.25, 0.40) gives these results. Container dimensions here refer to the area available after surrounding padding.

Available container Fitted diagram Diagram origin in container Marker center in container
1000 × 700 1000 × 625 (0, 37.5) (250, 287.5)
600 × 700 600 × 375 (0, 162.5) (150, 312.5)
1000 × 400 640 × 400 (180, 0) (340, 160)

In the middle row, multiplying the full container height by 0.40 gives 280, which misses the correct vertical position of 312.5. In the example’s local overlay, the vertical coordinate is simply 0.40 × 375 = 150; the enclosing layout supplies the 162.5-point margin.

The table’s arithmetic was checked with a small deterministic Python calculation. The SwiftUI program was inspected but was not compiled in this environment; verify its rendering in Xcode on the target Mac.

Adapt the pattern to an existing diagram

For an image-backed diagram, make the Image resizable before applying the aspect-fitting and overlay modifiers. Apple’s image-fitting guide explains that an image otherwise renders at its original size, and that .resizable() alone can scale its axes independently. Preserve the image’s actual aspect ratio, then attach the marker overlay before the outer frame. See Fitting images into available space.

When annotations start as source coordinates, normalize them against the same full artwork bounds: divide the source horizontal coordinate by the source width and the source vertical coordinate by the source height. Transparent margins inside an image remain part of those bounds. If coordinates were measured against a cropped region, convert them into the full-image coordinate system before using this pattern.

Keep image-specific modifiers on the Image before general view modifiers wrap it. Similarly, retain var body: some View for the composed view and the Path return type for the shape’s drawing method. Apple documents custom view composition and modifier wrapping in View.

The calculation assumes a uniform fit with no additional crop, rotation, or independent pan-and-zoom transform. If you introduce those operations, apply a shared transformation to the artwork and annotations or explicitly extend the coordinate mapping. Switching to .fill changes the fitting conditions and can render content beyond the frame; Apple documents clipping as a separate operation in Fitting images into available space.

Diagnose remaining alignment problems

Check these conditions in order:

  1. Measurement boundary: Keep the marker overlay before the outer expanding frame. Its border should follow the artwork rather than the surrounding empty area.
  2. Coordinate bounds: Confirm that normalized coordinates use the full source rectangle and the intended top-left origin. For this example, intended in-bounds values run from zero through one; reject unexpected values rather than silently clamping them.
  3. Anchor choice: Remember that .position(x:y:) anchors the marker’s center. A label whose corner should touch a feature needs an explicit additional offset or different anchoring strategy.
  4. Small layouts: A marker can stay correctly centered while overlapping another marker. Choose a separate rule for hiding or rearranging labels at small sizes.
  5. Edge behavior: A centered marker at an artwork edge extends beyond that edge. Decide whether that overhang is acceptable before adding clipping.

Keep positions derived from the current geometry instead of storing the first measured size in .onAppear. Apple defines that callback around appearance, with timing dependent on the view type; it is not a window-resize callback. Reading the geometry directly keeps this example’s positioning calculation tied to the current layout. See onAppear(perform:).

For a fitted diagram in a resizable Mac window, the reliable starting point is one shared rectangle: let the artwork establish its fitted bounds, measure those bounds, and derive every annotation center from them. Add surrounding layout only after that relationship is established.

Sources