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
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,22 +208,27 @@ 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
## Reacting to an Animation Finishing

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:
To run app logic when an animation completes — for example navigating away once a splash-screen animation finishes — fire a data-binding trigger from your state machine and listen for it with `useRiveTrigger`:

```js
<RiveView
file={riveFile}
autoPlay={true}
onStop={() => {
// The animation has finished playing
navigation.replace('Home');
}}
/>
```tsx
import {
RiveView,
useRiveTrigger,
useViewModelInstance,
} from '@rive-app/react-native';

const { instance } = useViewModelInstance(riveFile, { async: true });

useRiveTrigger('finished', instance, {
onTrigger: () => navigation.replace('Home'),
});

<RiveView file={riveFile} dataBind={instance} autoPlay={true} />;
```

> **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).
In the Rive editor: add a **Trigger** property (e.g. `finished`) to your artboard's View Model, then on the transition out of your one-shot animation state enable **Exit Time** and set it to **100%** (the value matters — an exit time of `0ms` fires immediately), and add a **Set property value** action targeting the trigger. See [this community file](https://rive.app/community/files/28526-53929-fire-a-trigger-when-timeline-finishes) for a working setup, and the "Finished Trigger" demo in the example app for the app-side wiring.

## Feature Support

Expand Down Expand Up @@ -251,7 +256,6 @@ 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,14 +105,6 @@ 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: 0 additions & 4 deletions android/src/new/java/com/margelo/nitro/rive/HybridRiveView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,6 @@ 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 @@ -122,7 +119,6 @@ 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
7 changes: 1 addition & 6 deletions android/src/new/java/com/rive/RiveReactNativeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,6 @@ 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
Expand Down Expand Up @@ -321,9 +317,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
// compatibility shim. Revisit when 12.0 defines the long-term settling surface.
@Suppress("DEPRECATION")
worker.settledFlow.collect { settledHandle ->
if (settledHandle == handle && !settled) {
if (settledHandle == handle) {
settled = true
onStop?.invoke()
}
}
Comment thread
mfazekas marked this conversation as resolved.
}
Expand Down
4 changes: 0 additions & 4 deletions docs/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,6 @@ 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
143 changes: 143 additions & 0 deletions example/__tests__/finished-trigger.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
describe,
it,
expect,
render,
waitFor,
cleanup,
} from 'react-native-harness';
import { View } from 'react-native';
import {
RiveView,
RiveFileFactory,
Fit,
useRiveTrigger,
type RiveFile,
type RiveViewRef,
type ViewModelInstance,
} from '@rive-app/react-native';

// One-shot animation that fires the 'finished' view-model trigger when it
// completes (state machine exit-time transition action) — the data-binding
// replacement for the removed onStop prop.
const FINISHED_TRIGGER = require('../assets/rive/finished_trigger.riv');

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

function expectDefined<T>(value: T): asserts value is NonNullable<T> {
expect(value).toBeDefined();
}

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

function FinishedView({
file,
instance,
context,
}: {
file: RiveFile;
instance: ViewModelInstance;
context: TestContext;
}) {
useRiveTrigger('finished', instance, {
onTrigger: () => {
context.triggerCount++;
},
});

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}
dataBind={instance}
onError={(e) => {
context.error = e.message;
}}
/>
</View>
);
}

// getPropertiesAsync is not implemented on the legacy backend, so the
// introspection test only runs on the new runtime. The behavioral test below
// runs on every backend.
const isExperimental = RiveFileFactory.getBackend() === 'experimental';

describe('finished-trigger.riv', () => {
(isExperimental ? it : it.skip)(
'loads and exposes a view model with a finished trigger',
async () => {
const file = await RiveFileFactory.fromSource(
FINISHED_TRIGGER,
undefined
);
expectDefined(file);

const artboards = await file.getArtboardNamesAsync();
const vmNames = await file.getViewModelNamesAsync();
console.log(`artboards: ${JSON.stringify(artboards)}`);
console.log(`viewModels: ${JSON.stringify(vmNames)}`);

for (const name of vmNames) {
const vm = await file.viewModelByNameAsync(name);
expectDefined(vm);
const props = await vm.getPropertiesAsync();
console.log(`VM "${name}" properties: ${JSON.stringify(props)}`);
}

const vm = await file.defaultArtboardViewModelAsync();
expectDefined(vm);
const props = await vm.getPropertiesAsync();
const finished = props.find((p) => p.name === 'finished');
expectDefined(finished);
cleanup();
}
);

it('fires the finished trigger exactly once when the animation completes', async () => {
const file = await RiveFileFactory.fromSource(FINISHED_TRIGGER, undefined);
const vm = await file.defaultArtboardViewModelAsync();
expectDefined(vm);
const instance = await vm.createDefaultInstanceAsync();
expectDefined(instance);

const context: TestContext = { ref: null, triggerCount: 0, error: null };
const mountedAt = Date.now();
await render(
<FinishedView file={file} instance={instance} context={context} />
);

await waitFor(() => expect(context.triggerCount).toBeGreaterThan(0), {
timeout: 10000,
});

// The one-shot timeline is ~1s; a fire much earlier means the state
// machine exited the animation state immediately (e.g. exit time not
// set to 100% in the editor) instead of at completion.
const firedAfterMs = Date.now() - mountedAt;
console.log(`finished trigger fired ${firedAfterMs} ms after mount`);
expect(firedAfterMs).toBeGreaterThan(500);

const samples: number[] = [];
for (let i = 0; i < 3; i++) {
await delay(1000);
samples.push(context.triggerCount);
}
console.log(`finished trigger count progression: ${samples.join(', ')}`);
expect(context.error).toBeNull();
expect(context.triggerCount).toBe(1);
cleanup();
});
});
104 changes: 0 additions & 104 deletions example/__tests__/on-stop.harness.tsx

This file was deleted.

Binary file added example/assets/rive/finished_trigger.riv
Binary file not shown.
17 changes: 17 additions & 0 deletions example/assets/rive/finished_trigger.riv.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Generated by rive-gen-types — do not edit manually. @generated
/* eslint-disable */
// Source: finished_trigger.riv
import type { RiveAsset } from '@rive-app/react-native';
declare const asset: RiveAsset<{
artboards: 'Artboard';
defaultArtboard: 'Artboard';
stateMachines: {
Artboard: 'State Machine 1';
};
viewModels: {
ViewModel1: {
finished: 'trigger';
};
};
}>;
export default asset;
Loading
Loading