Skip to content

feat: offscreenBehavior and renderEnabled props to cut offscreen CPU cost - #362

Open
mfazekas wants to merge 2 commits into
mainfrom
feat/offscreen-behavior
Open

feat: offscreenBehavior and renderEnabled props to cut offscreen CPU cost#362
mfazekas wants to merge 2 commits into
mainfrom
feat/offscreen-behavior

Conversation

@mfazekas

@mfazekas mfazekas commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #332. Android keeps rendering Rive views at full rate while they can't be seen — scrolled out of the viewport or covered by a Modal. This adds two opt-in props to the new backends:

  • offscreenBehavior?: 'none' | 'skip-draws' | 'pause' (default 'none'): automatic handling for visibility the view can detect itself (scrolled out, hidden, windowless). 'skip-draws' keeps advancing the state machine — events and data binding stay live — and only skips draws; 'pause' also stops advancing and resumes where it left off. Stays opt-in because data-binding consumers may need the state machine advancing regardless of visibility.
  • renderEnabled?: boolean | 'pause' (default true): manual control for occlusion the view cannot detect (RN Modal, bottom sheet). false skips draws while the state machine keeps advancing; 'pause' stops both — a declarative pause() that composes with the pause()/play() ref state instead of overwriting it. Nitro can't mix string literals into a variant, so the spec types it boolean | string and the public RiveView narrows it to boolean | 'pause'.

Android gates the Choreographer loop on isShown + getGlobalVisibleRect; the skip paths keep the timebase fresh so resuming advances by one frame, not the whole offscreen span. On iOS the upstream runtime couples advancing and drawing behind a single isPaused, so only 'pause' is implementable (a low-frequency visibility poll folded into RiveUIView.isPaused); 'skip-draws' and renderEnabled={false} are documented no-ops there, while renderEnabled='pause' works (same lever); iOS already throttles most offscreen rendering on its own (offscreen ~4% of a core on the simulator, ~0.1% with 'pause'). Legacy backends accept and ignore both props.

Measured with the new "Offscreen behavior" example page (looping rewards.riv, 10 s per-thread /proc samples, % of one core), emulator (Pixel 6 AVD, API 34): offscreen-scrolled 31% with 'none', 5.7% with 'skip-draws', 4.5% with 'pause'; modal-covered 44.7%, or 5.2% with renderEnabled={false}; paused/unmounted reference ~4%; onscreen cost unchanged. On a 120 Hz Pixel device (POC run of the same mechanism): offscreen-scrolled 145% → 33% with skip-draws; modal-covered 155% → 29.5% with renderEnabled=false; onscreen overhead unmeasurable. Resume verified after scroll-back (both modes) and after modal close.

For the 'pause' value (measured in a separate app launch with a noisier baseline — compare within the row): modal-covered 78.3% unmitigated, 12.5% with renderEnabled={false} (state-machine thread still ~2%), 11.5% with 'pause' (state-machine thread at zero — advance fully stopped); rendering resumes when the prop returns to true.

mfazekas added 2 commits July 30, 2026 16:17
…cost

Android keeps rendering Rive views at full rate while they are scrolled
out of the viewport or covered. offscreenBehavior ('none' | 'skip-draws'
| 'pause', default 'none') handles visibility the view can detect
itself: 'skip-draws' keeps advancing the state machine (events and data
binding stay live) and only skips draws, 'pause' stops advance and draw.
renderEnabled (default true) is the manual counterpart for occlusion the
view cannot detect, e.g. a React Native Modal covering it: false skips
draws while the state machine keeps advancing.

Android (new backend) gates the Choreographer loop on isShown +
getGlobalVisibleRect; the skip fast-path keeps the timebase fresh so
resuming advances by one frame. iOS (new backend) combines the user
pause state with a low-frequency visibility poll into RiveUIView
.isPaused; since the upstream runtime couples advancing and drawing,
'skip-draws' and renderEnabled=false degrade to 'none' on iOS ('pause'
is fully supported). Legacy backends accept and ignore both props.

Emulator (Pixel 6, API 34): offscreen-scrolled 31% of a core with
'none', 5.7% with 'skip-draws', 4.5% with 'pause'; modal-covered 44.7%
vs 5.2% with renderEnabled=false; paused/unmounted reference ~4%.
Onscreen cost is unchanged. The 'Offscreen behavior' example page
drives all scenarios.
renderEnabled={false} keeps the state machine advancing by design, so
there was no declarative way to stop rendering and advancing for a view
covered by UI it cannot detect — only the imperative pause(). Accept
'pause' as a third value: it stops draws and state machine advance,
composing with the pause()/play() ref state rather than overwriting it.
Works on both new backends (on iOS it maps onto the same isPaused lever
the offscreen 'pause' uses; the boolean remains a no-op there).

Nitro cannot mix string literals into a variant type, so the spec types
the prop as boolean | string (Variant_Boolean_String) and the public
RiveView component narrows it to boolean | 'pause'; unrecognized strings
render normally.

Emulator, modal-covered looping animation: 78.3% of a core unmitigated,
12.5% with renderEnabled={false} (state machine thread still ~2%), 11.5%
with 'pause' (state machine thread at zero); rendering resumes when the
prop returns to true.
@mfazekas
mfazekas force-pushed the feat/offscreen-behavior branch from cf392a9 to 4466bfd Compare July 31, 2026 06:01
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.

1 participant