ProgressView did not redraw offscreen: drawing your own thin bar in SwiftUI

The time text moved and the bar did not. A 16 line SwiftUI view replaced the system control, and a standalone test shows why the replacement is easier to trust.

By Arin Prajapati 9 min read
Black cover with the NotchStar logo, the kicker SwiftUI and the headline Drawing your own progress bar

TL;DR

  • In offscreen captures of the NotchStar interface, the elapsed time text on the Now Playing card changed every second while the progress bar beside it stayed put. The bar was the system ProgressView, an AppKit backed control.
  • The fix is two small SwiftUI views, ThinBar and Spinner, that draw with shapes and take their state from values the view already has. They are used in 8 places in the app.
  • A standalone check on 4 November 2026 could not reproduce the freeze on macOS 27.0.1: the system bar did repaint. The replacement stays because it works the same in every host, and the post says exactly what was and was not shown.

The symptom

NotchStar renders its real interface offscreen for two jobs: still images for the website and a recorder that captures frames while a script drives the mouse. Both read the pixels of an NSHostingView with cacheDisplay, in a panel parked at a position no display can reach.

The Now Playing card shows elapsed time, a bar and total time. Its code is a TimelineView that ticks once a second and recomputes both the text and the bar value:

// Sources/NotchStar/Tools/NowPlaying/NowPlayingTool.swift
TimelineView(.periodic(from: .now, by: 1)) { ctx in
    let elapsed = t.elapsed(at: ctx.date) ?? 0
    HStack(spacing: 8) {
        Text(format(elapsed)).font(.caption2)
        ThinBar(value: elapsed / duration, tint: .white.opacity(0.9))
        Text(format(duration)).font(.caption2)
    }
    .monospacedDigit().foregroundStyle(.secondary)
}

The project’s element audit of 6 October 2026 lists a ProgressView in the Now Playing card, and ThinBar first appears in git on 7 October 2026. In the captures, the numbers on both sides counted up and the bar did not move. The project notes record it in one sentence: the time text updated but the bar did not. The code comment that came with the fix gives the working theory:

// Sources/NotchStar/Tools/ToolStyle.swift
/// A thin determinate bar drawn in SwiftUI. (The system ProgressView is an AppKit control that does not redraw in
/// offscreen captures, and this one looks the same everywhere.)

The earlier post about the demo recorder describes the indeterminate ProgressView as appearing frozen too. That is the reason the second view in this post, Spinner, exists.

What was tried first

The first thing to try with a stale capture is to capture again. The recorder already draws every frame from scratch with layoutSubtreeIfNeeded(), bitmapImageRepForCachingDisplay and cacheDisplay, about 30 times a second. Capturing more often did not change anything about the bar, which fits a control that is not told to repaint, not a capture that is too early.

The second thing is to look at what the control is. On macOS, SwiftUI’s ProgressView is backed by AppKit progress drawing, and AppKit controls can take part in animation and invalidation in ways that depend on the window being part of the normal display cycle. That is a plausible explanation and it is the one in the code comment. It is a theory, not a traced result: the cause was not confirmed with the display link or with view debugging, and the next section shows that it is not easy to repeat.

The fix that worked was to stop depending on it. A bar is a rounded rectangle on top of another rounded rectangle whose width is a number. SwiftUI shapes are drawn by SwiftUI from the value it is given each time the body runs, so there is no separate control to invalidate.

The replacement: ThinBar and Spinner

struct ThinBar: View {
    let value: Double
    var tint: Color = .accentColor
    var height: CGFloat = 5
    var body: some View {
        GeometryReader { g in
            ZStack(alignment: .leading) {
                Capsule().fill(Color.white.opacity(0.16))
                Capsule().fill(tint).frame(width: max(height, g.size.width * min(1, max(0, value))))
            }
        }
        .frame(height: height)
        .accessibilityElement(children: .ignore)
        .accessibilityValue("\(Int(min(1, max(0, value)) * 100)) percent")
    }
}

Details that matter:

  • The value is clamped to 0 through 1 before use, so a bad duration cannot draw outside the track.
  • The filled capsule is never narrower than the bar’s height, so an empty bar shows a round dot and not a flat edge.
  • The control ignores its children for accessibility and reports one value, “N percent”, so VoiceOver reads one thing and not two shapes.

The indeterminate version is the other half of the file:

struct Spinner: View {
    var size: CGFloat = 14
    var body: some View {
        TimelineView(.animation) { ctx in
            Circle().trim(from: 0, to: 0.72)
                .stroke(Color.secondary, style: StrokeStyle(lineWidth: size / 7, lineCap: .round))
                .rotationEffect(.degrees(ctx.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 0.9) / 0.9 * 360))
                .frame(width: size, height: size)
        }
        .accessibilityLabel("Working")
    }
}

The rotation angle is a pure function of the current date: one turn every 0.9 seconds. A capture taken at any moment shows the angle for that moment, with no animation state that has to be advanced by a display link. That is the property a recorder needs. A search for the call sites on 4 November 2026 finds 8: ThinBar in Now Playing, the Disk overview and the compress view, and Spinner in the Disk overview, space, find, apps and shared Disk views.

A standalone check, and a result that was not what the story predicts

Because the original failure lives in the app’s test host, the obvious follow up is a small program that shows it. A command line program was compiled with swiftc on 4 November 2026 on macOS 27.0.1 with Swift 6.4. It builds an NSHostingView around a bar, puts it in a borderless NSPanel, waits 0.4 seconds, captures with cacheDisplay, changes the value from 0.1 to 0.9, waits again, captures again, and compares. Each trial ran twice: once with the panel on screen and once with its origin at y = -30000.

let w = NSPanel(contentRect: host.frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
w.contentView = host
w.setFrameOrigin(CGPoint(x: x, y: x < 0 ? -30000 : 200))
w.orderFrontRegardless()
await pump(0.4)                  // Task.sleep, so SwiftUI updates can run
let a = grab(host)               // cacheDisplay into a bitmap, PNG data
m.v = 0.9
await pump(0.4)
let b = grab(host)
ControlPanel positionCaptures identical at 0.1 and 0.9Accent coloured pixels at 0.1 and 0.9
System ProgressViewon screenno0 and 0
System ProgressViewoffscreenno0 and 0
Custom capsule baron screenno580 and 5380
Custom capsule baroffscreenno580 and 5380

In this program the system bar did repaint when the value changed, offscreen as well. The “accent pixels” column is not a measure of the bar: the system control did not draw in the accent colour in a panel that is never the active window, so its count is zero in every row, and only the fact that the two captures differ says it moved. The custom bar’s counts scale as expected for a 300 point track at 2x, about 30 points filled at 0.1 and about 270 at 0.9.

So the plain claim in the code comment, that the system control does not redraw in offscreen captures, did not reproduce on this Mac with this program. What was reproduced is that the custom bar behaves the same in every case. Possible reasons for the difference are the macOS version (this check ran on 27.0.1), the extra work the full interface does around the bar, or the way the test host runs. None was isolated.

How the bar gets its value

A shape view is only as live as the value it is given, so the second half of the fix is where the number comes from. On the Now Playing card the number is computed inside a TimelineView, from the track’s elapsed time at the timeline’s date. The card does not keep a counter that something must increment. Each tick the closure runs again with a new ctx.date, elapsed is recomputed, and ThinBar is drawn with a new width.

That matters for the recorder because the capture does not have to cooperate with any animation state. The recorder calls host.layoutSubtreeIfNeeded() and cacheDisplay for each frame. If the body of a view depends only on the date and on model values, a capture taken a second later is simply a different render. The same idea is why Spinner computes its angle from Date() and does not run a repeating animation.

There is one more consequence, and it is a guard: the card only shows a bar when the player gave a duration and an elapsed time. The comment above the function says it plainly, “No guessing”. A bar that moved on a made up value would be worse than a missing bar.

Why a shape view is the better contract

The choice does not rest on a reproduced bug. It rests on what can be tested and what looks the same:

  • Appearance is the same in a capture, in the real notch and in a different window state. The system control changes with window activity, as the zero accent pixels above show.
  • The only input is a number. A test can render ThinBar(value: 0.5) and sample a pixel.
  • The accessibility value is explicit, and VoiceOver reads “50 percent” whichever way it is drawn.
  • It matches the flat visual design of the app. No default bezel or system tint shows through.

The costs are real. The bar has no system animation when the value jumps; the card only refreshes once a second. It does not follow the user’s accent colour unless a caller passes one in; Disk uses .accentColor as the default tint. And it is more code that has to be right: the clamp, the minimum width and the label are all this app’s responsibility now.

Numbers and method

MeasureValueMethod
Call sites of ThinBar or Spinner8grep -rn "ThinBar(|Spinner(" Sources, struct definitions excluded, 4 November 2026
Remaining system ProgressView uses in code1grep -rn "ProgressView" Sources, the licence settings window, 4 November 2026
Lines in ThinBar16counted in ToolStyle.swift
Tests that reference ThinBar or Spinner0grep -rn "ThinBar|Spinner" Tests, 4 November 2026
Spinner period0.9 s per turndividingBy: 0.9
Now Playing refreshonce per secondTimelineView(.periodic(from: .now, by: 1))
Standalone capture, custom bar, accent pixels580 at 0.1, 5380 at 0.9the program above, 4 November 2026

No claim is made about how much work the spinner costs. It redraws on the display’s animation schedule while it is visible, and the CPU or energy cost of that has not been measured.

What is still bad

  • The original freeze is not reproduced outside the app’s own test host, so the explanation in the code comment is a theory.
  • There is no unit test for ThinBar or Spinner. A pixel sampling test like the one above would be small and has not been added.
  • One system ProgressView remains, next to the Activate button in the licence window. That window is a normal on screen window and is never recorded, so nothing depends on it, but it is the same control.
  • ThinBar rounds the accessibility percentage down with Int(...), so 99.9 percent is read as 99.
  • The elapsed value on the card comes from a snapshot and a timer, and the progress code was marked in the project’s audit as untested against live playback when it was written.

Try it yourself

  1. Put a ProgressView(value:) and a custom capsule bar in a SwiftUI view, host it in an NSHostingView and an NSPanel, and move the panel far offscreen.
  2. After a short await Task.sleep, call cacheDisplay into a bitmap. Change the value. Wait. Capture again.
  3. Compare the two PNGs byte for byte, or count the pixels in the bar’s colour.
  4. Run it on every macOS version you support. If the system bar fails on one of them, you have a repro; if it never fails, the shape version is still free of any dependency.
  5. Add .accessibilityValue to your bar and listen to it with VoiceOver once.

Cheat sheet

NeedUseWhy
A determinate bar that must render the same everywhereTwo capsules and a clamped widthOne input, no host dependency
An indeterminate spinner for capturesTimelineView(.animation) and an angle from the datePure function of time
A bar a screen reader can read.accessibilityElement(children: .ignore) and .accessibilityValueOne element, one value
An empty bar that still looks intentionalmax(height, width * value)A round dot at zero
A bar in a window users see and nobody recordsThe system ProgressView is fineMatches the platform

Close

NotchStar is a Mac app that puts ten tools in the notch, and this bar is the one under the song title in Now Playing. If you want to try it, the details are at notchstar.com.

NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.