The Searoom dashboard tracks eleven independently invalidated live regions and seven trend graphs. It is one NSView with a draw(_:) method, plus one small transparent overlay per graph used for hover. There is no view per metric, no stack view, and no layout pass.
This is not a general recommendation. It is a set of trade-offs that suit a particular problem, and the reasoning is more transferable than the conclusion.
The constraint that drives the design
Searoom is a menu-bar app whose entire premise is that watching a machine should not measurably load it. The dashboard updates as often as once per second while it is open, and it reports its own CPU and memory cost on screen, which makes any waste immediately visible to the user.
A view-per-metric hierarchy would mean around fifty views, each with its own layer, layout participation, and invalidation. Every sample would touch a large part of that tree. The cost is not catastrophic, but it is real, and it is spent continuously.
Drawing directly turns the update problem into something explicit: given the previous state and the new state, which rectangles actually changed.
Invalidate rectangles, not the view
The naive version of custom drawing is worse than a view hierarchy, because setNeedsDisplay(_:) over the whole bounds on every sample redraws everything, including graphs that did not change.
Searoom builds a value type describing the current presentation, compares it against the previous one field by field, and invalidates only the regions whose contents differ.
if previous.cpu != current.cpu { invalidateVisible(liveMetricRect(for: cpu)) }
if previous.memory != current.memory { invalidateVisible(liveMetricRect(for: memory)) }
if previous.gpu != current.gpu { invalidateVisible(liveMetricRect(for: gpu)) }Two properties make this work. The comparison is on formatted presentation values rather than raw samples, so CPU moving from 3.114% to 3.116% produces no invalidation at all when both render as 3%. And the invalidation is clipped to what is on screen:
private func invalidateVisible(_ rect: NSRect) {
let visible = visibleRect
guard rect.intersects(visible) else { return }
setNeedsDisplay(rect.intersection(visible))
}The dashboard scrolls, so most of the content is usually outside the viewport. visibleRect is what makes the guard cheap: scheduling a redraw for a card the user cannot see is pure waste, and the check is one line.
Two cadences, not one
Live numbers and trend graphs have different natural update rates. A number should change as soon as the value does. A graph of the last sixty minutes does not become meaningfully different one second later, and reprojecting its points every second is the single most expensive thing the view could do.
These are separated by an explicit policy object:
struct DashboardTrendRefreshPolicy: Sendable {
static let interval: Duration = .seconds(5)
mutating func shouldRefresh(at now: ContinuousClock.Instant, force: Bool = false) -> Bool
}Live regions invalidate at the sample rate. Graph regions invalidate at most every five seconds, with a force path so that opening the popover always presents fresh curves rather than whatever the cadence happened to leave behind.
Pulling this out into a value type with no AppKit dependency has a side benefit: the cadence logic is directly testable, and it is covered by both XCTest and the framework-independent self-test. Timing behaviour buried inside draw(_:) would not be.
The only subviews, and why they are subviews
Hovering a graph shows a vertical marker and a readout for the sample under the cursor. Drawing that into the main view would mean invalidating and redrawing the graph beneath it on every mouse-moved event, which is exactly the expensive work the five-second cadence exists to avoid.
So the hover marker is a separate transparent view positioned over the graph, one per trend metric, seven in total. Each moves without the chart underneath knowing anything happened. They are the entire subview count of the dashboard.
override func hitTest(_ point: NSPoint) -> NSView? { nil }Returning nil from hitTest(_:) keeps the overlay out of event routing entirely. It is painted, never interacted with, and clicks pass through to the view below as though it were not there.
Hover also needs to snap to a real sample rather than interpolating a position, so the readout always corresponds to a timestamp that actually exists. That is a binary search over the retained samples, again factored out as a pure function:
enum DashboardTrendSampleLocator {
static func nearestIndex(to target: Date, count: Int, timestampAt: (Int) -> Date) -> Int?
}Dithering as a cached fill
The visual system uses ordered dithering rather than gradients or alpha blending. The pattern comes from a 4x4 Bayer matrix, thresholded by density:
private static let matrix: [[Int]] = [
[0, 8, 2, 10],
[12, 4, 14, 6],
[3, 11, 1, 9],
[15, 7, 13, 5]
]The important property is that this is deterministic. The same colour and the same density always produce the same 8x8 tile, which means the tile can be built once and reused as an NSColor pattern image, and filling any shape with it becomes an ordinary fill.
let key = CacheKey(red: ..., green: ..., blue: ..., alpha: ..., threshold: threshold)
if let cached = cache[key] { return cached }The cache key quantises the colour to 8-bit components and the density to one of sixteen thresholds, so the space of distinct patterns is small and bounded. In practice a handful of tiles cover the entire interface.
Determinism is also a correctness requirement, not only a performance one. A randomised or animated dither would make every redraw produce different pixels, which would defeat partial invalidation: a region would visibly change even when its value had not. The aesthetic choice and the performance strategy reinforce each other here, which is the pleasant case.
What this costs
The trade-offs are real and worth stating plainly.
Layout is manual, and for a while it was duplicated. Card rectangles are computed from constants in code, and the same handful of y values were originally typed out in four separate places: the draw path, two invalidation routines, and the unit-region builder. Nothing could move without them drifting apart. A view hierarchy would have handled this automatically.
That bill came due when the dashboard gained draggable card reordering. Once a card's position depends on user preference rather than on a constant, four copies of the geometry is not a maintenance annoyance, it is a correctness bug waiting for someone to reorder two cards. The fix was to give the geometry one owner:
/// Resolves an ordered list of sections into concrete rectangles.
///
/// Deliberately free of AppKit drawing so the geometry can be tested directly.
struct DashboardLayout {
struct Slot {
let section: DashboardSection
let rect: NSRect
}
let slots: [Slot]
}The view rebuilds one of these when the order or the width changes, and every draw and invalidation path reads it instead of repeating coordinates. Being free of AppKit is what makes it testable: resolving an order into rectangles is a pure function over a list and a width.
The general lesson is not that manual layout is fine. It is that manual layout has a cost which stays hidden while the layout is fixed, and arrives all at once the moment it stops being fixed.
Accessibility is manual. A hierarchy of views gives you an accessibility tree for free. Drawing everything means constructing that description yourself, which Searoom does by maintaining a summary as the presentation changes.
Text metrics are manual. Measuring strings and aligning them is code rather than constraints.
None of these would be acceptable overhead in a large application with many screens. They are acceptable in one dense panel that updates continuously and must stay cheap while doing it. If your view updates on user action rather than on a timer, almost none of this reasoning applies to you, and you should use a view hierarchy.
The refresh gate above all of it
The largest saving is not in the drawing code at all. The sampling pipeline runs continuously to keep history complete, but the dashboard is only refreshed while it is actually visible:
// AppDelegate always updates the status item, but refreshes the
// dashboard only while popover.isShown is true.A closed popover costs nothing to draw. The best-optimised redraw is still more expensive than the redraw you do not perform, and that check outperforms every technique described above.
The full implementation is in Sources/Searoom/UI/DashboardView.swift, Sources/Searoom/UI/DashboardLayout.swift and Sources/Searoom/UI/DashboardRefreshPolicy.swift in the Searoom repository, under the MIT licence.