Skip to content

perf: don't animate what isn't visible - #4

Merged
cloudstudio merged 2 commits into
cloudstudio:mainfrom
victor-gv:perf/pause-idle-animations
Jul 28, 2026
Merged

cloudstudio merged 2 commits into
cloudstudio:mainfrom
victor-gv:perf/pause-idle-animations

Conversation

@victor-gv

@victor-gv victor-gv commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Two animation timelines run unconditionally while the notch is idle. Both are argued from the code below rather than from a benchmark — see the note on measurement at the end.

The halo

HaloView drives TimelineView(.animation), so it re-evaluates at the display's native refresh rate (120Hz on ProMotion) for the entire lifetime of the app. But the ring is only visible for 0.6s after pingAt:

let elapsed = pingAt.map { timeline.date.timeIntervalSince($0) } ?? .infinity
let alive = elapsed >= 0 && elapsed < 0.6
...
.opacity(alive ? decay : 0)

Outside that window it computes elapsed = .infinity, alive = false, and draws at opacity(0). With pingAt == nil it has never been visible at all, and still runs.

This commit swaps the schedule for a finite .explicit one derived from pingAt (0.6s at 60Hz). An explicit schedule stops firing once exhausted, and because pingAt is a let on a value-type view, a new ping rebuilds the view and re-arms it. pingAt == nil now installs no timeline at all. Same bloom, same decay curve, same colours.

The eyes

MoriEyesView had its frame budget written in by hand:

TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: false))

paused is pinned to false, so the dot-matrix face redraws 60 times a second regardless of what it's showing. This one multiplies: the face renders once in the top bar and once per session row, so the cost scales with how many sessions are on screen — worst exactly when the app is most useful.

Now: 1/30 at rest, 1/60 while an expression morph, celebration or wink is in flight, 1/15 under Low Power Mode. .speaking, .alert and .listening stay at 60fps — their oscillations (0.42s lid sine, 5.5Hz jitter, 0.9s ring pulse) don't resolve at 30. Breathing and gaze drift keep running throughout; nothing freezes.

The transient window constants were hoisted out of the drawing code (morphDuration 0.34s, celebrationDuration 1.1s, winkDuration 0.36s) so the frame budget and the drawing can't drift apart.

On measurement

I originally opened this quoting a CPU improvement. I withdrew it: my before and after weren't taken under comparable load, so the comparison wasn't valid. When I measured both builds back to back under the same conditions, the difference was inside the sampling noise — with the panel collapsed and one session, only a single face is animating, so halving its frame rate moves very little.

I'd rather give you no number than one I can't stand behind. If you want a figure, the condition where this should show up is the panel expanded with several sessions, where the per-row cost multiplies. I don't have a rigorous measurement of that.

The argument I am making is the one above: a view that is invisible shouldn't be redrawn 120 times a second, and a frame rate shouldn't be pinned when the API takes a parameter for it.

Two things I'd flag myself

  1. The 30fps resting rate is a judgement about your design. If you chose 60 deliberately for how the face feels, drop the second commit — the halo one stands alone.
  2. MoriEyesView gains a @State property that is mutated but never read, purely to invalidate the view when a transient window expires so the rate can step back down. It works and it's commented at the declaration and the mutation site, but it's the piece I'd most want a second opinion on.

Scope

Two files, HaloView.swift and MoriEyesView.swift. No overlap with #1 or #3 — I dropped an occlusion-pausing commit that touched NotchApp.swift, AppModel.swift and NotchIslandView.swift, both because it collided with your other open PRs and because I couldn't verify it actually fires (the panel is .canJoinAllSpaces + .fullScreenAuxiliary, so it stays visible over fullscreen apps). Happy to bring it back separately if you want it.

Testing

swift build clean. I could not run swift test — only Command Line Tools here, so XCTest is unavailable, and that fails identically on unmodified main. Verified by hand that a cold launch animates immediately and the face keeps moving. Please lean on your CI.

🤖 Generated with Claude Code

@victor-gv
victor-gv force-pushed the perf/pause-idle-animations branch from fd38671 to 6e2728c Compare July 27, 2026 16:29
@victor-gv victor-gv changed the title perf: stop drawing what nobody can see perf: don't animate what isn't visible Jul 27, 2026
@victor-gv
victor-gv marked this pull request as ready for review July 27, 2026 16:31
cloudstudio pushed a commit that referenced this pull request Jul 28, 2026
PR #4 stepped the budget back down by bumping a @State integer that
nothing reads — it works only because SwiftUI invalidates on any state
write, which is a detail, not a contract. Hold the interval itself in
state instead: the task takes it up when motion starts and puts it back
down when the window expires, and the code says what it means.

Low Power Mode also only counted at the next re-render, since
isLowPowerModeEnabled was read inline with nothing observing it. It now
follows NSProcessInfoPowerStateDidChange.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudstudio
cloudstudio merged commit 3590659 into cloudstudio:main Jul 28, 2026
@cloudstudio

Copy link
Copy Markdown
Owner

Merged as 3590659, with one follow-up in 2c7d61a.

Verified locally: builds clean, 83 tests green with the other three PRs merged alongside it, SwiftLint unchanged from main's baseline.

The halo change is right, and the reasoning holds up under inspection. HaloView is used as .background { } on the island (NotchIslandView.swift:33), so the Color.clear branch can't disturb layout — worth stating explicitly, because that's the one thing an if/else swap in a view body could have broken. Trading an unbounded .animation for a schedule that runs out is exactly the shape this wanted.

On the @State you flagged. Your instinct was right: it works, but only because SwiftUI invalidates on any @State write rather than tracking reads. That's an implementation detail, not a contract, and a property that is written and never read reads as dead code to everyone who meets it later. I changed it to hold the interval itself:

@State private var frameInterval: TimeInterval = MoriEyesView.restingInterval

The task takes it up when motion starts and puts it back down when the window expires. Same behaviour, and the code now says what it means.

One thing the review turned up that wasn't in scope: isLowPowerModeEnabled was read inline with nothing observing it, so toggling Low Power Mode had no effect until something else happened to re-render the view. It now follows NSProcessInfoPowerStateDidChange.

On the measurement. Withdrawing the CPU number rather than shipping one you couldn't stand behind was the right call, and it made the PR easier to trust, not harder. The argument stands on its own — a view drawing at opacity(0) 120 times a second is wrong whether or not it shows up in a sample.

The 30fps resting rate stays. Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants