Keep rapid rectangle drawing responsive by separating the active drag preview from committed canvas shapes. In a Mac drawing app, let one gesture surface own input, keep temporary geometry local, and append to the shape collection only when the drag finishes successfully.

This guide targets a developer building a simple click-and-drag rectangle tool in their own macOS app. All example shapes live in memory. The goal is to prevent pointer movement from repeatedly changing the entire drawing model, then use profiling to identify any remaining rendering cost.

Why does drawing slow down as committed shapes accumulate?

Start by distinguishing three operations: receiving input, updating state, and rendering geometry. A drag can change temporary geometry frequently. If those changes also replace the committed collection, rebuild every path, or invalidate a broad view hierarchy, the work associated with each movement can grow with the drawing.

Apple explains that SwiftUI tracks dependencies and updates views when those dependencies change. Both slow individual view updates and numerous unnecessary updates can cause missed frame deadlines. Separating frequently changing preview state from committed content is an architectural application of that documented model. Optimize SwiftUI performance with Instruments

Canvas provides immediate-mode drawing through a graphics context. Apple identifies it as a possible performance improvement for complex dynamic drawings, but it does not provide interaction or accessibility for each drawn element. Attach input handling to a deliberate drawing surface; implement separate selection and accessibility support if the app later needs individually operable shapes. Canvas

The useful boundary is therefore explicit: the input surface owns the gesture, temporary state owns the preview, and the parent owns committed rectangles. Splitting these responsibilities reduces avoidable dependencies. It does not guarantee that the system will never redraw the committed canvas.

Separate the preview from the committed drawing

Use this ownership plan before choosing rendering optimizations:

Responsibility Owner When it changes
Current drag rectangle Gesture surface During the active drag
Committed rectangle collection Parent drawing view Once per successful drag
Drawing committed rectangles Background canvas When its inputs require an update
Pointer recognition Gesture surface Throughout the gesture sequence

For temporary geometry, @GestureState is useful because SwiftUI resets it when the gesture becomes inactive. Commit through onEnded, whose callback receives the final gesture value. Apple specifies that this callback runs only when the gesture ends successfully; gesture state handles cleanup regardless of how it ends. GestureState, onEnded(_:)

For rectangles, the final geometry requires only the starting and current locations. There is no need to retain every intermediate pointer location. Apple documents those positions separately from predicted endpoints; use the actual final location when committing the rectangle. DragGesture.Value

Build a complete in-memory rectangle tool

The following two code blocks form one complete Swift source file. In a macOS SwiftUI App project targeting macOS 12 or later, replace the generated app entry file with both blocks and remove any other app entry declaration. Canvas is available from macOS 12. This example was inspected but could not be compiled here because the environment has no Swift toolchain. Canvas

The first block defines the app, the committed collection, and its renderer. The parent receives only completed rectangles; it does not read the active drag state.

import SwiftUI

@main
struct RectangleDrawingApp: App {
    var body: some Scene {
        WindowGroup {
            DrawingBoard()
        }
    }
}

struct DrawingBoard: View {
    @State private var rectangles: [CGRect] = []

    var body: some View {
        VStack(spacing: 8) {
            Text("Drag to draw a rectangle")
            Text("Committed rectangles: \(rectangles.count)")

            ZStack {
                Color.white

                CommittedRectangles(rectangles: rectangles)
                    .allowsHitTesting(false)

                RectangleGestureSurface { rectangle in
                    rectangles.append(rectangle)
                }
            }
            .clipped()
            .frame(minWidth: 480, minHeight: 320)
        }
        .padding()
    }
}

struct CommittedRectangles: View {
    let rectangles: [CGRect]

    var body: some View {
        Canvas { context, _ in
            for rectangle in rectangles {
                context.stroke(
                    Path(rectangle),
                    with: .color(.blue),
                    lineWidth: 2
                )
            }
        }
    }
}

Append the following block to the same file. The transparent surface covers the drawing area, gives hit testing an explicit rectangular shape, and owns the sole drawing gesture. Apple documents contentShape as defining the hit-testing shape and allowsHitTesting(false) as excluding a view from hit testing. contentShape(_:eoFill:), allowsHitTesting(_:)

struct RectangleGestureSurface: View {
    let commit: (CGRect) -> Void
    @GestureState private var preview: CGRect? = nil

    var body: some View {
        ZStack {
            Color.clear

            Canvas { context, _ in
                if let rectangle = preview {
                    context.stroke(
                        Path(rectangle),
                        with: .color(.orange),
                        lineWidth: 2
                    )
                }
            }
            .allowsHitTesting(false)
        }
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0, coordinateSpace: .local)
                .updating($preview) { value, state, _ in
                    state = Self.rectangle(
                        from: value.startLocation,
                        to: value.location
                    )
                }
                .onEnded { value in
                    let rectangle = Self.rectangle(
                        from: value.startLocation,
                        to: value.location
                    )
                    commit(rectangle)
                }
        )
    }

    private static func rectangle(
        from start: CGPoint,
        to end: CGPoint
    ) -> CGRect {
        CGRect(
            x: min(start.x, end.x),
            y: min(start.y, end.y),
            width: abs(end.x - start.x),
            height: abs(end.y - start.y)
        )
    }
}

minimumDistance: 0 removes the minimum travel requirement for recognition. The local coordinate space keeps gesture positions aligned with the equally sized preview canvas. These are documented gesture configuration options, not a guarantee about event frequency or display latency. DragGesture

The rectangle helper normalizes all four drag directions. As an illustrative check, dragging from (90, 80) to (30, 20) produces an origin of (30, 20) and a size of (60, 60). The preview and commit use the same calculation, so releasing the pointer does not change the geometry formula.

This minimal tool commits zero-width and zero-height rectangles too. A click can therefore increment the count without producing a visible rectangle. If the product requires a minimum size, add that rule explicitly at commit time and give the user appropriate feedback. The example keeps every successfully completed gesture rather than silently filtering small results.

The visible area is clipped, while stored geometry remains unchanged. A rectangle extending outside the board can therefore contain off-board coordinates. Decide whether a future resize should reveal that geometry before adding coordinate clamping.

Diagnose the remaining cost before adding a cache

The example removes committed-model mutations from the drag update path. Its committed renderer still loops through the collection whenever that renderer executes. Distinguish reduced state propagation from reduced rendering work: a separate view is not a documented persistent image cache.

Use a controlled comparison to find which cost remains. Choose illustrative in-memory populations such as 0, 500, and 5,000 rectangles. Keep rectangle dimensions, window size, display scale, and build configuration consistent. These counts are test inputs, not capacity limits or measured results.

Record three intervals separately: dragging while the collection stays unchanged, releasing to commit, and resizing the window. A continuous drag slowdown suggests different work from a pause occurring only at commit. Resize behavior can expose geometry or cache rebuilding that ordinary drawing does not.

Apple’s SwiftUI profiling session describes the Long View Body Updates, Long Representable Updates, and Cause & Effect Graph tools, alongside Time Profiler. Use the dependency graph to investigate why committed views update during a drag; use CPU samples to find expensive geometry construction or rendering work. Apple introduced that instrument workflow in Instruments 26. Optimize SwiftUI performance with Instruments

For broader stalls, Apple’s WWDC26 responsiveness session starts with Time Profiler and distinguishes busy execution from time spent waiting. Apply that distinction before moving work between executors or replacing the rendering stack. Profile, fix, and verify: Improve app responsiveness with Instruments

The following decision aid is an engineering inference from those profiling tools and the ownership design above:

Observed behavior Investigate first Candidate change
Parent drawing state changes during pointer movement Gesture update callbacks and shared state Keep preview state inside the gesture surface
Committed drawing dominates samples during movement Repeated traversal and path creation Precompute geometry or introduce a committed-content cache
Release pauses become longer as the collection grows Array mutation and full drawing reconstruction Reduce retained snapshots and rebuild only affected derived content
Rectangle drawing is responsive, long freehand strokes are slow Active path size and reconstruction Profile the growing active stroke separately
Pauses mainly occur during resize Size-dependent rendering and cache invalidation Define explicit resize behavior for derived content

A cache adds another ownership boundary: rectangle geometry remains authoritative, and the cache is replaceable derived content. Include drawing size, display scale, and relevant styling in the invalidation design. For append-only drawing, incremental cache updates are a candidate optimization; edits and removals need a clear rebuilding or regional invalidation strategy. Verify the result with the same input populations before keeping the added complexity.

Check array copying at the commit boundary

Swift arrays have value semantics and use copy-on-write storage. Copies can share storage until mutation requires independent storage; arrays containing class instances still share references to those instances. Neither passing an array to a view nor appending to an array should be described as always copying every element. Array

For this tool, avoid storing a new full drawing snapshot on every pointer update. If profiling identifies copying during commit, inspect which snapshots remain alive and how the drawing model is shared. Replacing value types with shared mutable objects solely to avoid copying changes ownership semantics and requires a separate design decision.

Treat asynchronous presentation as a measured option

Apple defines rendersAsynchronously as allowing the canvas to present its contents to its parent asynchronously. That description does not promise faster gesture delivery, move the gesture handler to a background executor, or eliminate collection traversal. Evaluate it separately after establishing the input and state boundaries. rendersAsynchronously

When should AppKit own the drawing surface?

Use a custom AppKit surface when the task requires direct responder-method handling or explicit invalidation of drawing regions. Apple documents NSView as the infrastructure for drawing and handling events, and supports subclassing it for custom behavior. This is a supported alternative, with its own rendering responsibilities. NSView

For an AppKit implementation, preserve the same sequence: initialize temporary geometry in mouseDown(with:), update it in mouseDragged(with:), and commit it in mouseUp(with:). Convert event.locationInWindow into the receiving view’s coordinates before calculating geometry. Apple’s archived mouse-event guide documents this sequence and coordinate conversion; the current NSView reference documents the supported responder methods. Handling Mouse Events, NSView

Request display by setting needsDisplay = true; Apple specifies that the view is then redisplayed on the next event-loop pass. For a region-based design, invalidate the union of old and new preview bounds, expanded to include the stroke. Regional invalidation only reduces traversal if the drawing implementation also avoids processing unaffected content. needsDisplay, Handling Mouse Events

Embed the surface with NSViewRepresentable: create it in makeNSView, reconcile external state in updateNSView, and use a coordinator to communicate changes back to SwiftUI. Refresh callback routing during updates, and avoid resetting an active draft merely because SwiftUI supplies another representable value. Apple also states that SwiftUI controls the represented view’s layout; do not directly overwrite its managed frame or bounds. NSViewRepresentable

Choose one owner for drawing input. If AppKit handles the drawing sequence, do not also commit that sequence through an overlapping SwiftUI drag gesture. For a direct NSView subclass, Apple instructs implementations that handle the listed mouse events not to call the superclass implementation for those handled events. NSView

Keep the first improvement small and observable: local preview state, a single commit boundary, and a committed renderer with unchanged inputs during movement. Then compare growing synthetic drawings in Instruments. The resulting trace tells you whether the next useful change belongs in dependency management, collection ownership, geometry preparation, or rendering.

Sources