Skip to content

feat: add onStop callback for animation lifecycle (iOS + Android)#353

Open
hlus wants to merge 2 commits into
rive-app:mainfrom
hlus:feat/on-stop-callback
Open

feat: add onStop callback for animation lifecycle (iOS + Android)#353
hlus wants to merge 2 commits into
rive-app:mainfrom
hlus:feat/on-stop-callback

Conversation

@hlus

@hlus hlus commented Jul 23, 2026

Copy link
Copy Markdown

feat: onStop callback for RiveView animation lifecycle

RiveView has no way to tell JS when playback stops — a common gap for splash-screen-style usage, where a one-shot animation plays once and the app needs to navigate away exactly when it finishes. This adds an onStop prop, called when the animation/state machine stops playing:

<RiveView
  file={riveFile}
  autoPlay={true}
  onStop={() => navigation.replace('Home')}
/>

onStop is not called by pause() — only when playback naturally comes to rest. RiveViewMethods doesn't expose a stop() today (only play/pause/reset), so in practice this only fires when a non-looping animation reaches its end or a state machine settles into a state with no further transitions — exactly the splash-screen case.

Each backend surfaces this differently, so the implementation is four separate wirings:

  • iOS legacy: conforms RiveReactNativeView to Rive's RivePlayerDelegate and sets riveView.playerDelegate = self (alongside the existing stateMachineDelegate), forwarding player(stoppedWithModel:). Verified against the actual RiveRuntime.xcframework swiftinterface — RivePlayerDelegate requires all five methods (play/pause/loop/stop/advance), so the other four are no-ops.
  • iOS new (experimental) runtime: has no player delegate at all — the only lifecycle-adjacent signal is StateMachine.settledStream() -> AsyncStream<Void>, consumed via a cancellable Task per Rive instance and torn down alongside the existing config/cleanup paths.
  • Android legacy: registers a RiveFileController.Listener on the RiveAnimationView (mirroring how event listeners are already wired), forwarding notifyStop.
  • Android new (experimental) runtime: collects CommandQueue.settledFlow (a SharedFlow<StateMachineHandle>, since the queue is shared across views) filtered to this view's own StateMachineHandle.

Verified the API signatures for both experimental-runtime paths (settledStream/settledFlow) and the legacy RivePlayerDelegate/RiveFileController.Listener directly against the vendored RiveRuntime.xcframework swiftinterface and the decompiled rive-android AAR, rather than from docs alone.

Docs: added an "Animation Lifecycle" section to the README and a row in the runtime feature comparison table.

Testing: yarn typecheck, yarn lint, and yarn test all pass. Built the example app for real on all four backend combinations (USE_RIVE_LEGACY on/off × iOS/Android) via xcodebuild/gradlew, not just the JS layer — this caught one real compile error (a Kotlin smart-cast issue on stateMachineHandle in the Android new-runtime observeSettled call site), now fixed.

@mfazekas

Copy link
Copy Markdown
Collaborator

I reviewed the native signal each backend wires up, and verified the behavior with a react-native-harness test on this branch (renders a settling .riv with autoplay, counts onStop calls, samples once per second — code below):

  • Android new runtime: count over 4s: 61, 120, 182, 239onStop fires ~60×/sec forever once the state machine settles. rive-runtime's command server emits stateMachineSettled on every advance whose advanceAndApply returns false (no transition-edge dedup, see command_server.cpp), and our Choreographer loop keeps calling advanceStateMachine every frame after settle. Needs a latch re-armed on perturbation (pointer/play/binding), or better: stop advancing once settled — that's what the signal is for upstream, and it would save CPU too.
  • iOS new runtime: count: 2, 2, 2, 2 — no frame-storm (the SDK stops advancing when settled), but it fires twice at mount, likely settle → configure/rebind perturbation → re-settle. A navigation.replace in onStop would run twice as-is.
  • iOS legacy: never fires for the natural-end case. player(stoppedWithModel:) is only called from an explicit stop(), which nothing in this wrapper invokes — natural halt fires pausedWithModel instead.
  • Android legacy: a settling state machine gets notifyPause, not notifyStop; notifyStop does fire from setArtboardstopAnimations(), i.e. spuriously on reconfigure.

Since the legacy backend only exists for internal testing at this point, I'd drop the two legacy wirings rather than fix them. The pause() negative case passes on both platforms.

example/__tests__/on-stop.harness.tsx
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';

// 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', () => {
  it('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();
  });

  it('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();
  });
});

@hlus

hlus commented Jul 23, 2026

Copy link
Copy Markdown
Author

I reviewed the native signal each backend wires up, and verified the behavior with a react-native-harness test on this branch (renders a settling .riv with autoplay, counts onStop calls, samples once per second — code below):

  • Android new runtime: count over 4s: 61, 120, 182, 239onStop fires ~60×/sec forever once the state machine settles. rive-runtime's command server emits stateMachineSettled on every advance whose advanceAndApply returns false (no transition-edge dedup, see command_server.cpp), and our Choreographer loop keeps calling advanceStateMachine every frame after settle. Needs a latch re-armed on perturbation (pointer/play/binding), or better: stop advancing once settled — that's what the signal is for upstream, and it would save CPU too.
  • iOS new runtime: count: 2, 2, 2, 2 — no frame-storm (the SDK stops advancing when settled), but it fires twice at mount, likely settle → configure/rebind perturbation → re-settle. A navigation.replace in onStop would run twice as-is.
  • iOS legacy: never fires for the natural-end case. player(stoppedWithModel:) is only called from an explicit stop(), which nothing in this wrapper invokes — natural halt fires pausedWithModel instead.
  • Android legacy: a settling state machine gets notifyPause, not notifyStop; notifyStop does fire from setArtboardstopAnimations(), i.e. spuriously on reconfigure.

Since the legacy backend only exists for internal testing at this point, I'd drop the two legacy wirings rather than fix them. The pause() negative case passes on both platforms.

example/__tests__/on-stop.harness.tsx

thx for review, if I need to fix this let me know I will do my best 🫡

…entation

Removed unnecessary onStop wiring in the legacy runtime as it lacks a reliable signal for playback completion. Updated comments to clarify the behavior of onStop in both legacy and new runtimes, ensuring accurate documentation for users.
@hlus

hlus commented Jul 24, 2026

Copy link
Copy Markdown
Author

@mfazekas
Thanks for the thorough review and the harness repro — fixed all four points.

Android new runtime (frame storm): the render loop now stops calling advanceStateMachine once the state machine settles, instead of relying on dedup alone — that's also what upstream expects the signal to be used for, and it saves the redundant per-frame work. The latch is re-armed only by what can actually move a settled state machine again: pointer input, play()/playIfNeeded(), and data-binding changes. (RiveReactNativeView.kt:88, gating at L209, re-arm sites at L385/L466/L474/L490)

iOS new runtime (double-fire at mount): settle events are now coalesced through a 150ms debounce before invoking onStop (RiveReactNativeView.swift:235), so the settle → perturb → re-settle burst right after configure collapses into a single call instead of two.

iOS legacy: dropped the RivePlayerDelegate wiring entirely (conformance, delegate assignment, stoppedWithModel handler) rather than patch semantics that don't fit — agreed this isn't worth carrying for a backend that's internal-testing-only.

Android legacy: same call — removed the RiveFileController.Listener/notifyStop wiring that was firing spuriously off setArtboard()'s internal stopAnimations().

For both legacy backends onStop stays as a no-op on HybridRiveView (required by the shared Nitro spec) with a comment explaining it's intentionally unwired, and the README now calls out that onStop is new-runtime only.

Also added your harness test as a permanent regression test (example/__tests__/on-stop.harness.tsx), gated to skip on the legacy backend the same way the rest of the suite does.

Verified: ran the full harness suite on iOS (new runtime) locally — 28/28 suites, 208/208 tests passing, including the new onStop cases (fires exactly once on settle, never fires from pause()). Haven't run the Android harness pass yet, but the fix there is the same latch mechanism plus the "stop advancing once settled" change you suggested was the better fix, so it should resolve the storm the same way — happy to run it before merge if you want the number.

iOS harness suite preview:
image

@mfazekas

mfazekas commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

That'd be great, thanks! Here's what we'd like this PR to converge on:

  1. Remove the legacy wirings entirely (iOS RivePlayerDelegate, Android RiveFileController.Listener) — onStop should be a new-runtime-only feature. On the legacy backend just silently ignore it, like frameRate does; legacy only exists for internal testing / comparing against the new implementation at this point, so a "not supported" log isn't really needed. (If you do want one anyway, it has to live in JS — RiveFileFactory.getBackend() === 'legacy' && onStop != null — since the wrapper always sets the native prop to a default no-op.)
  2. Android new runtime: fix the per-frame refire — either latch it (fire once per settle, re-arm when the state machine is perturbed: pointer events, play(), data-binding writes) or stop advancing once settled, which is what upstream does with the signal and also saves CPU.
  3. iOS new runtime: track down the double-fire at mount (count was 2, likely settle → reconfigure/rebind → re-settle) so a fresh mount fires exactly once.
  4. Add the harness test from my comment above as example/__tests__/on-stop.harness.tsx so CI covers all of this; it should pass on both platforms when done.
  5. README: mark onStop new-runtime-only (like frameRate).

@mfazekas

Copy link
Copy Markdown
Collaborator

Re-verified your update on device with the harness test: both platforms now report onStop count progression: 1, 1, 1, 1 and all four tests pass (iOS + Android, including the pause() negatives). Nice work, the Android stop-advancing-when-settled approach is exactly right.

A few asks before merge:

  1. Make onStop optional in the spec (onStop?: () => void in RiveView.nitro.ts) and drop the defaultOnStop injection in RiveView.tsx, so native sees it unset and can skip the settled observation entirely — right now the stream subscription, the 150ms task, and a no-op JS dispatch run per settle even when nobody passed onStop. hybridRef is precedent for an optional function prop, and the optional-prop-clearing nitro bug this would've hit was fixed in fix: don't throw when an optional RiveView prop is cleared back to undefined #326. The native setters should handle the callback arriving or being cleared after configure (subscribe/cancel on transition).
  2. example/ios/RiveExample.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist got deleted — looks like accidental IDE churn, please restore it.
  3. iOS: observeSettled should also cancel a pending stopNotifyTask — after a reconfigure, a stop scheduled by the old Rive instance can still fire ~150ms into the new one.

Optional follow-up (not blocking): while settled, the Android loop stops advancing but still draws identical frames every vsync — an early-out like the paused path would complete the CPU win.

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