feat: offscreenBehavior and renderEnabled props to cut offscreen CPU cost - #362
Open
mfazekas wants to merge 2 commits into
Open
feat: offscreenBehavior and renderEnabled props to cut offscreen CPU cost#362mfazekas wants to merge 2 commits into
mfazekas wants to merge 2 commits into
Conversation
…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
force-pushed
the
feat/offscreen-behavior
branch
from
July 31, 2026 06:01
cf392a9 to
4466bfd
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'(defaulttrue): manual control for occlusion the view cannot detect (RN Modal, bottom sheet).falseskips 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 itboolean | stringand the publicRiveViewnarrows it toboolean | '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 singleisPaused, so only'pause'is implementable (a low-frequency visibility poll folded intoRiveUIView.isPaused);'skip-draws'andrenderEnabled={false}are documented no-ops there, whilerenderEnabled='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% withrenderEnabled={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% withrenderEnabled={false}(state-machine thread still ~2%), 11.5% with'pause'(state-machine thread at zero — advance fully stopped); rendering resumes when the prop returns totrue.