Skip to content
macOSDeep Dive Published Updated 6 min readViews unavailable

AppKit Custom Drawing on macOS: Dirty Regions, Layers, and Redraw

Make NSView rendering deterministic and efficient with dirty-region invalidation, opaque coverage, layer policies, coordinate discipline, and tests.

An AppKit custom view should render from current model state whenever AppKit asks it to draw. It should not treat draw(_:) as a one-time setup callback, a place to mutate document data, or a timer that happens to run often enough. AppKit tracks invalid regions and schedules drawing with the event loop. A view that marks the right pixels dirty and paints only the necessary content is easier to reason about than one that forces synchronous window-wide redraws.

Begin with a standard control or layer-backed component when it already expresses the design. A custom NSView is appropriate for a chart, canvas, diagram, specialized editor, or other content whose rendering is naturally model-driven. Keep state changes in the model or controller and make drawing a repeatable projection of that state.

Invalidate the region that actually changed

setNeedsDisplay(_:) marks a rectangle in the view’s coordinate system as needing display. AppKit can coalesce dirty regions and redraw them on a later event-loop pass. When a small object moves, invalidate the union of its old and new bounds; invalidating only its new bounds can leave stale pixels behind. For a full visual change, use the whole-view invalidation intentionally rather than calling display() to synchronously force rendering.

import AppKit

@MainActor
final class MarkerView: NSView {
    private var marker = NSRect(x: 20, y: 20, width: 12, height: 12)

    func moveMarker(to origin: NSPoint) {
        let oldRect = marker
        marker.origin = origin
        setNeedsDisplay(oldRect.union(marker).insetBy(dx: -2, dy: -2))
    }

    override func draw(_ dirtyRect: NSRect) {
        NSColor.windowBackgroundColor.setFill()
        dirtyRect.fill()

        guard marker.intersects(dirtyRect) else { return }
        NSColor.controlAccentColor.setFill()
        NSBezierPath(ovalIn: marker).fill()
    }
}

The example repaints the dirty background before drawing the marker. That is essential because the old marker pixels must be removed. If the view is intentionally transparent, its background policy differs, but the renderer still has to produce the correct pixels for every invalid region and rely on the appropriate ancestor compositing behavior.

Inside draw(_:), use the supplied dirty rectangle as a culling hint. For complex scenes, getRectsBeingDrawn(_:count:) or needsToDraw(_:) can narrow work further. Keep the drawing path fast: do not synchronously fetch network data, decode a large image, rebuild a full document layout, or mutate the model. Precompute immutable render snapshots elsewhere and let the view consume the newest valid snapshot.

Opaque coverage is a promise

Setting isOpaque to true tells AppKit that the view completely fills its bounds with opaque content. It is not merely a performance toggle. If the view leaves a transparent hole, paints only part of dirtyRect, or uses alpha in a supposedly opaque region, lower content can disappear or compositing can produce artifacts. Set opacity according to actual rendering behavior and test edges, resize, and partial invalidation.

If content does not fully cover the invalid region, fill the remaining background explicitly or report the view as nonopaque. Pay attention to antialiased edges and shadows; a view that appears opaque at normal scale may still reveal transparency at fractional backing scales. Use AppKit semantic colors for interface surfaces when appropriate, but keep user-authored canvas colors in the document model rather than changing them as a side effect of system appearance.

Layer-backed drawing has separate policies

wantsLayer gives a view a backing layer, but drawing policy still matters. AppKit-managed layer-backed views and views that host their own layer content have different redraw behavior. A layer redraw policy such as onSetNeedsDisplay means invalidation drives rendering, while other policies govern how layer contents behave during size changes. Do not assume changing a layer property makes the view model or cached bitmap automatically current.

Choose either AppKit drawing through draw(_:) or direct layer-content management through updateLayer() when that is the documented design for the view. Do not update both independently with competing sources of truth. For a frequently animated scene, benchmark layer-backed subviews, custom drawing, and a dedicated rendering framework with realistic content rather than assuming layers are always faster.

Cache expensive immutable render products only when you have a complete invalidation key. A bitmap may depend on model revision, bounds, backing scale, effective appearance, color space, and rendering options. If any of these changes, the cache may be wrong. Store a generation identifier with asynchronous raster work and refuse to publish the result if the model or view generation has moved on.

Coordinate and backing-scale discipline

AppKit views may be flipped or unflipped. Define the coordinate system for your model and convert at the view boundary; do not scatter ad hoc y-axis negations through drawing and hit-testing code. Use the same transform for drawing, pointer input, selection rectangles, and accessibility geometry. A chart that draws a marker at one location and reports its hit target elsewhere has a coordinate ownership bug.

Retina and external displays can change the backing scale. Prefer point-based layout and vector drawing when possible. If you allocate pixel buffers, derive pixel dimensions from the current backing conversion and update them when the view moves between screens. Do not treat one backing scale as a permanent device property. Test fractional point sizes and thin strokes, which can become blurred or uneven when they land between device pixels.

When drawing dynamic colors outside AppKit’s normal drawing callback, resolve them in the view’s effective appearance and invalidate derived resources when appearance changes. This matters for cached images, gradients, or textures; a cache keyed only by model revision can preserve the old theme after the user changes appearance.

Invalidation and state transitions

Every visible model change needs a corresponding invalidation path. Define which model event updates which view region, and ensure deletion invalidates the old bounds. For scrolling content, update the visible model window and let the clip view handle scrolling; avoid redrawing the entire document for every offset change unless measurement supports it.

If drawing depends on asynchronous data, render a placeholder and then invalidate the affected region when the data arrives. The completion must verify that the item still exists and that the request generation is current. A stale result should be discarded, not painted over the latest state. Avoid capturing a view strongly in long-lived work unless the task’s lifetime is intentionally tied to that view.

Do not call display() during ordinary state changes to “make the screen update now.” It can bypass normal coalescing and cause reentrancy or excess work. Mark the view dirty and allow AppKit to service it. Immediate display calls are for narrowly justified synchronization cases, not a substitute for a correct invalidation model.

Diagnostics and acceptance checks

For each interaction, record the model revision, invalidated rectangles, draw duration, and backing scale in a debug build. Compare invalidated area with total view area. If nearly every keystroke dirties the full window, trace which model notification is too broad. Use Instruments or signposts to distinguish time in drawing from image decode, text layout, or compositing.

Test repeated partial invalidations, overlapping dirty rectangles, resize, move between displays, light/dark transitions, scroll, content deletion, transparent edges, and an asynchronous image arriving after the source object has changed. Capture screenshots at the same window size and scale to detect stale pixels, but also assert that the model revision represented by the renderer matches the latest model revision.

Set measurable budgets for median and tail draw duration at realistic object counts. Verify that drawing does not change persistent state and that two calls with the same model snapshot produce the same pixels. A custom view is reliable when its invalidation is complete, its drawing is bounded, and its cache lifetime follows every input that affects the output.

Related:

Sources:

Comments