Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,23 @@ import { RiveView, RiveErrorType } from '@rive-app/react-native';

> **Note**: If no `onError` handler is provided, errors will be logged to the console by default.

## Animation Lifecycle

The `RiveView` component provides an `onStop` callback prop, called when the animation/state machine stops playing — for example, when a non-looping animation reaches its end. This is useful for splash-screen-style animations where you want to navigate away once playback finishes:

```js
<RiveView
file={riveFile}
autoPlay={true}
onStop={() => {
// The animation has finished playing
navigation.replace('Home');
}}
/>
```

> **Note**: `onStop` is not called by `pause()` — only when playback naturally comes to rest (e.g. a one-shot animation or a state machine reaching a state with no further transitions).

## Feature Support

This section provides a comprehensive overview of feature availability in `@rive-app/react-native`, comparing it with the [previous Rive React Native runtime](https://github.com/rive-app/rive-react-native) and outlining the development roadmap.
Expand All @@ -234,6 +251,7 @@ The following table compares feature availability with the [previous Rive React
| `useRive()` hook | ✅ | Convenient hook to access the Rive View ref after load |
| `useRiveFile()` hook | ✅ | Convenient hook to load a Rive file |
| `RiveView` error handling | ✅ | Error handler for failed view operations |
| `RiveView` `onStop` callback | ✅ | Callback fired when the animation/state machine stops playing |
| `source` .riv file loading | ✅ | Conveniently load .riv files from JS source |
| Accessibility semantics | ⚠️ | Editor-authored semantics → VoiceOver (iOS; Android in progress) |
| Animation selection | ❌ | Animation playback not planned, use state machines |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
}
}
override var onError: (error: RiveError) -> Unit = {}

// Not wired on the legacy runtime: RiveFileController.Listener has no
// signal that reliably maps to "playback came to rest" here — a settling
// state machine reports notifyPause, and notifyStop instead fires
// spuriously from setArtboard()'s internal stopAnimations() on reconfigure.
// The legacy backend is internal-testing-only, so this stays a no-op
// rather than reporting an incorrect stop.
override var onStop: () -> Unit = {}
//endregion

//region View Methods
Expand Down
4 changes: 4 additions & 0 deletions android/src/new/java/com/margelo/nitro/rive/HybridRiveView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
onError = { msg ->
this@HybridRiveView.onError(RiveError(type = RiveErrorType.UNKNOWN, message = msg))
}
onStop = {
this@HybridRiveView.onStop()
}
}
private var needsReload = false
private var dataBindingChanged = false
Expand Down Expand Up @@ -119,6 +122,7 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
}
}
override var onError: (error: RiveError) -> Unit = {}
override var onStop: () -> Unit = {}

override fun awaitViewReady(): Promise<Boolean> {
return Promise.async {
Expand Down
41 changes: 39 additions & 2 deletions android/src/new/java/com/rive/RiveReactNativeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.margelo.nitro.rive.RiveLog
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -72,6 +73,20 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {

var onError: ((String) -> Unit)? = null

// Fired when the state machine settles (reaches rest, e.g. a non-looping
// animation reaching its end); wired to the onStop prop.
var onStop: (() -> Unit)? = null

private var settledJob: Job? = null

// rive-runtime's command server emits a settle signal on every advance
// whose advanceAndApply returns false, not just on the settle edge — so
// once the state machine is at rest we simply stop advancing it (also
// saves CPU). Re-armed by whatever could actually move the state machine
// again: pointer input, resuming playback, or a data-binding change.
@Volatile
private var settled = false

private val errorListener: (String) -> Unit = { msg ->
onError?.invoke(msg)
}
Expand Down Expand Up @@ -191,7 +206,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {

if (worker != null && art != null && sm != null && rs != null) {
try {
if (!paused) {
if (!paused && !settled) {
worker.advanceStateMachine(sm, deltaTime)
}
worker.draw(art, sm, rs, activeFit)
Expand Down Expand Up @@ -271,11 +286,13 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {

riveFile = config.riveFile

stateMachineHandle = if (config.stateMachineName != null) {
val newStateMachineHandle = if (config.stateMachineName != null) {
config.riveWorker.createStateMachineByName(newArtboard.artboardHandle, config.stateMachineName)
} else {
config.riveWorker.createDefaultStateMachine(newArtboard.artboardHandle)
}
stateMachineHandle = newStateMachineHandle
observeSettled(config.riveWorker, newStateMachineHandle)

if (surfaceTexture != null && riveSurface == null) {
riveSurface = config.riveWorker.createRiveSurface(
Expand All @@ -296,6 +313,19 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
}
}

private fun observeSettled(worker: CommandQueue, handle: StateMachineHandle) {
settled = false
settledJob?.cancel()
settledJob = viewScope.launch {
worker.settledFlow.collect { settledHandle ->
if (settledHandle == handle && !settled) {
settled = true
onStop?.invoke()
}
}
}
}

private fun resizeArtboardIfLayout() {
val fit = activeFit
if (fit is Fit.Layout) {
Expand Down Expand Up @@ -350,6 +380,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
worker.pointerExit(smHandle, fit, w, h, id, -1f, -1f)
}
}
// A pointer event may move the state machine off rest; resume advancing
// so the render loop actually applies its effect.
settled = false
} catch (e: Exception) {
Log.e(TAG, "Pointer event failed", e)
}
Expand Down Expand Up @@ -430,13 +463,15 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
if (worker != null && smHandle != null) {
worker.bindViewModelInstance(smHandle, instance.instanceHandle)
needsRedraw = true
settled = false
} else {
Log.w(TAG, "Cannot bind VMI: worker or state machine handle not available")
}
}

fun play() {
paused = false
settled = false
updateFrameRateHint()
}

Expand All @@ -452,6 +487,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {

fun playIfNeeded() {
paused = false
settled = false
updateFrameRateHint()
}

Expand Down Expand Up @@ -490,6 +526,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
if (disposed) return
disposed = true
viewReadyDeferred.complete(false)
settledJob?.cancel()
viewScope.cancel()
RiveErrorLogger.removeListener(errorListener)
stopRenderLoop()
Expand Down
4 changes: 4 additions & 0 deletions docs/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ Behavioral differences on the new runtime:
handles; a bad path surfaces via the `getValueAsync()` rejection or the
`useRive*` hooks' `error` result instead of an `undefined` return.
- `updateReferencedAssets` (runtime asset swapping) is not supported.
- The `onStop` prop is only wired up on the new runtime. The legacy backend
has no signal that reliably maps to "playback came to rest" (a settling
state machine reports `notifyPause`/`pausedWithModel`, not a stop), so
`onStop` is left as a no-op there rather than reporting an incorrect stop.

## Android render backend (new runtime only)

Expand Down
104 changes: 104 additions & 0 deletions example/__tests__/on-stop.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
describe,
it,
expect,
render,
waitFor,
cleanup,
} from 'react-native-harness';
import { View } from 'react-native';
import {
RiveView,
RiveFileFactory,
Fit,
type RiveFile,
type RiveViewRef,
} from '@rive-app/react-native';

// onStop is only wired up on the new (experimental) runtime — see the
// "Animation Lifecycle" section of the README.
const isExperimental = RiveFileFactory.getBackend() === 'experimental';

// Interactive rating file: the state machine idles (settles) unless touched.
const RATING = require('../assets/rive/rating.riv');
// Continuously animating ball: never settles while playing.
const BOUNCING_BALL = require('../assets/rive/bouncing_ball.riv');

const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));

type TestContext = {
ref: RiveViewRef | null;
stopCount: number;
error: string | null;
};

function OnStopView({
file,
context,
}: {
file: RiveFile;
context: TestContext;
}) {
return (
<View style={{ width: 200, height: 200 }}>
<RiveView
hybridRef={{
f: (ref: RiveViewRef | null) => {
context.ref = ref;
},
}}
style={{ flex: 1 }}
file={file}
autoPlay={true}
fit={Fit.Contain}
onStop={() => {
context.stopCount++;
}}
onError={(e) => {
context.error = e.message;
}}
/>
</View>
);
}

describe('RiveView onStop', () => {
(isExperimental ? it : it.skip)(
'fires exactly once when playback comes to rest',
async () => {
const file = await RiveFileFactory.fromSource(RATING, undefined);
const context: TestContext = { ref: null, stopCount: 0, error: null };

await render(<OnStopView file={file} context={context} />);
await waitFor(() => expect(context.stopCount).toBeGreaterThan(0), {
timeout: 10000,
});

// The render loop keeps running after the state machine settles; onStop
// must not fire again while the content stays at rest.
const samples: number[] = [];
for (let i = 0; i < 4; i++) {
await delay(1000);
samples.push(context.stopCount);
}
console.log(`onStop count progression: ${samples.join(', ')}`);
expect(context.error).toBeNull();
expect(context.stopCount).toBe(1);
cleanup();
}
);

(isExperimental ? it : it.skip)('does not fire for pause()', async () => {
const file = await RiveFileFactory.fromSource(BOUNCING_BALL, undefined);
const context: TestContext = { ref: null, stopCount: 0, error: null };

await render(<OnStopView file={file} context={context} />);
await waitFor(() => expect(context.ref).not.toBeNull(), { timeout: 5000 });

await context.ref!.pause();
await delay(1000);
expect(context.error).toBeNull();
expect(context.stopCount).toBe(0);
cleanup();
});
});
1 change: 1 addition & 0 deletions example/src/reproducers/DeinitOffMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function RiveContent({ raw }: { raw: boolean }) {
style={styles.rive}
autoPlay={true}
onError={{ f: (e) => console.log('raw onError', e.message) }}
onStop={{ f: () => console.log('raw onStop') }}
/>
);
}
Expand Down
6 changes: 6 additions & 0 deletions ios/legacy/HybridRiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ class HybridRiveView: HybridRiveViewSpec {
// runtime (the experimental backend).
var semantics: Semantics?
var onError: (RiveError) -> Void = { _ in }
// Not wired on the legacy runtime: RivePlayerDelegate has no signal that
// reliably maps to "playback came to rest" here (stoppedWithModel only
// fires from an explicit stop(), which this wrapper never calls). The
// legacy backend is internal-testing-only, so this stays a no-op rather
// than reporting an incorrect stop.
var onStop: () -> Void = {}

func awaitViewReady() throws -> Promise<Bool> {
return Promise.async { [self] in
Expand Down
4 changes: 4 additions & 0 deletions ios/new/HybridRiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class HybridRiveView: HybridRiveViewSpec {
var frameRate: Variant_Double_FrameRateRange?
var semantics: Semantics?
var onError: (RiveError) -> Void = { _ in }
var onStop: () -> Void = {}

func awaitViewReady() throws -> Promise<Bool> {
return Promise.async { [self] in
Expand Down Expand Up @@ -223,6 +224,9 @@ class HybridRiveView: HybridRiveViewSpec {
riveView.onLoadError = { [weak self] message in
self?.onError(RiveError(message: message, type: .unknown))
}
riveView.onSettled = { [weak self] in
self?.onStop()
}
riveView.configure(
config, dataBindingChanged: dataBindingChanged, reload: needsReload,
initialUpdate: initialUpdate)
Expand Down
Loading
Loading