diff --git a/README.md b/README.md index 209d7ad9..913ae800 100644 --- a/README.md +++ b/README.md @@ -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 + { + // 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. @@ -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 | diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt index d4f2ba4a..f1118170 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveView.kt @@ -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 diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridRiveView.kt b/android/src/new/java/com/margelo/nitro/rive/HybridRiveView.kt index 4d18af5d..d4f7ab0b 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridRiveView.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridRiveView.kt @@ -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 @@ -119,6 +122,7 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() { } } override var onError: (error: RiveError) -> Unit = {} + override var onStop: () -> Unit = {} override fun awaitViewReady(): Promise { return Promise.async { diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index dff96577..dcfdf423 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -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 @@ -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) } @@ -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) @@ -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( @@ -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) { @@ -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) } @@ -430,6 +463,7 @@ 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") } @@ -437,6 +471,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { fun play() { paused = false + settled = false updateFrameRateHint() } @@ -452,6 +487,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { fun playIfNeeded() { paused = false + settled = false updateFrameRateHint() } @@ -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() diff --git a/docs/runtime-backends.md b/docs/runtime-backends.md index 49b567b5..7a45cf60 100644 --- a/docs/runtime-backends.md +++ b/docs/runtime-backends.md @@ -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) diff --git a/example/__tests__/on-stop.harness.tsx b/example/__tests__/on-stop.harness.tsx new file mode 100644 index 00000000..5fbb0049 --- /dev/null +++ b/example/__tests__/on-stop.harness.tsx @@ -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 ( + + { + context.ref = ref; + }, + }} + style={{ flex: 1 }} + file={file} + autoPlay={true} + fit={Fit.Contain} + onStop={() => { + context.stopCount++; + }} + onError={(e) => { + context.error = e.message; + }} + /> + + ); +} + +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(); + 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(); + 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(); + }); +}); diff --git a/example/src/reproducers/DeinitOffMain.tsx b/example/src/reproducers/DeinitOffMain.tsx index 9af8a867..12b3a908 100644 --- a/example/src/reproducers/DeinitOffMain.tsx +++ b/example/src/reproducers/DeinitOffMain.tsx @@ -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') }} /> ); } diff --git a/ios/legacy/HybridRiveView.swift b/ios/legacy/HybridRiveView.swift index de8f80d5..87551008 100644 --- a/ios/legacy/HybridRiveView.swift +++ b/ios/legacy/HybridRiveView.swift @@ -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 { return Promise.async { [self] in diff --git a/ios/new/HybridRiveView.swift b/ios/new/HybridRiveView.swift index 394319b4..c87091d5 100644 --- a/ios/new/HybridRiveView.swift +++ b/ios/new/HybridRiveView.swift @@ -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 { return Promise.async { [self] in @@ -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) diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift index 1f10b9c6..bd2c3baf 100644 --- a/ios/new/RiveReactNativeView.swift +++ b/ios/new/RiveReactNativeView.swift @@ -28,6 +28,8 @@ class RiveReactNativeView: UIView { private var viewReadyContinuations: [CheckedContinuation] = [] private var isViewReady = false private var configTask: Task? + private var settledTask: Task? + private var stopNotifyTask: Task? private var isPaused = false private var semantics: RiveRuntime.Semantics = RiveUIView.Constants.Defaults.semantics { didSet { riveUIView?.semantics = semantics } @@ -40,6 +42,10 @@ class RiveReactNativeView: UIView { /// Configure failures are reported here (wired to the onError prop). var onLoadError: ((String) -> Void)? + /// Fired whenever the state machine settles (reaches rest, e.g. a + /// non-looping animation reaching its end); wired to the onStop prop. + var onSettled: (() -> Void)? + func awaitViewReady() async -> Bool { if isViewReady { return true @@ -212,7 +218,35 @@ class RiveReactNativeView: UIView { // MARK: - Internal + private func observeSettled(of rive: RiveRuntime.Rive) { + settledTask?.cancel() + // A stop scheduled by the previous Rive instance must not fire into the + // new one after a reconfigure. + stopNotifyTask?.cancel() + stopNotifyTask = nil + settledTask = Task { [weak self] in + for await _ in rive.stateMachine.settledStream() { + guard !Task.isCancelled else { return } + self?.scheduleStopNotification() + } + } + } + + // The SDK can emit settle back-to-back right after configure (an initial + // rest, immediately perturbed by setup — e.g. data-bind — and re-settling), + // which would otherwise report two stops for a single, user-visible "came + // to rest". Coalesce a tight burst into one onStop call. + private func scheduleStopNotification() { + stopNotifyTask?.cancel() + stopNotifyTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 150_000_000) + guard !Task.isCancelled else { return } + self?.onSettled?() + } + } + private func setupRiveUIView(with rive: RiveRuntime.Rive) { + observeSettled(of: rive) if let existing = riveUIView { // Reuse the existing view — avoids tearing down the MTKView on every // reconfigure, which previously caused orphaned draw calls ("state machine @@ -241,6 +275,10 @@ class RiveReactNativeView: UIView { dispatchPrecondition(condition: .onQueue(.main)) configTask?.cancel() configTask = nil + settledTask?.cancel() + settledTask = nil + stopNotifyTask?.cancel() + stopNotifyTask = nil riveUIView?.removeFromSuperview() riveUIView = nil riveInstance = nil @@ -261,13 +299,19 @@ class RiveReactNativeView: UIView { // main thread (JS/GC thread); cleanup() must run on main. Capture the // resources, not self. let task = configTask + let settled = settledTask + let stopNotify = stopNotifyTask let uiView = riveUIView if Thread.isMainThread { task?.cancel() + settled?.cancel() + stopNotify?.cancel() uiView?.removeFromSuperview() } else { DispatchQueue.main.async { task?.cancel() + settled?.cancel() + stopNotify?.cancel() uiView?.removeFromSuperview() } } diff --git a/nitrogen/generated/android/c++/JHybridRiveViewSpec.cpp b/nitrogen/generated/android/c++/JHybridRiveViewSpec.cpp index b6827bc7..ee2ed1ce 100644 --- a/nitrogen/generated/android/c++/JHybridRiveViewSpec.cpp +++ b/nitrogen/generated/android/c++/JHybridRiveViewSpec.cpp @@ -61,6 +61,7 @@ namespace margelo::nitro::rive { enum class RiveEventType; } #include "JRiveError.hpp" #include "RiveErrorType.hpp" #include "JRiveErrorType.hpp" +#include "JFunc_void.hpp" #include #include #include @@ -209,6 +210,23 @@ namespace margelo::nitro::rive { static const auto method = _javaPart->javaClassStatic()->getMethod /* onError */)>("setOnError_cxx"); method(_javaPart, JFunc_void_RiveError_cxx::fromCpp(onError)); } + std::function JHybridRiveViewSpec::getOnStop() { + static const auto method = _javaPart->javaClassStatic()->getMethod()>("getOnStop_cxx"); + auto __result = method(_javaPart); + return [&]() -> std::function { + if (__result->isInstanceOf(JFunc_void_cxx::javaClassStatic())) [[likely]] { + auto downcast = jni::static_ref_cast(__result); + return downcast->cthis()->getFunction(); + } else { + auto __resultRef = jni::make_global(__result); + return JNICallable(std::move(__resultRef)); + } + }(); + } + void JHybridRiveViewSpec::setOnStop(const std::function& onStop) { + static const auto method = _javaPart->javaClassStatic()->getMethod /* onStop */)>("setOnStop_cxx"); + method(_javaPart, JFunc_void_cxx::fromCpp(onStop)); + } // Methods std::shared_ptr> JHybridRiveViewSpec::awaitViewReady() { diff --git a/nitrogen/generated/android/c++/JHybridRiveViewSpec.hpp b/nitrogen/generated/android/c++/JHybridRiveViewSpec.hpp index fc8641bc..91ea8d5c 100644 --- a/nitrogen/generated/android/c++/JHybridRiveViewSpec.hpp +++ b/nitrogen/generated/android/c++/JHybridRiveViewSpec.hpp @@ -72,6 +72,8 @@ namespace margelo::nitro::rive { void setDataBind(const std::optional, DataBindMode, DataBindByName>>& dataBind) override; std::function getOnError() override; void setOnError(const std::function& onError) override; + std::function getOnStop() override; + void setOnStop(const std::function& onStop) override; public: // Methods diff --git a/nitrogen/generated/android/c++/views/JHybridRiveViewStateUpdater.cpp b/nitrogen/generated/android/c++/views/JHybridRiveViewStateUpdater.cpp index e003051d..ecfe8860 100644 --- a/nitrogen/generated/android/c++/views/JHybridRiveViewStateUpdater.cpp +++ b/nitrogen/generated/android/c++/views/JHybridRiveViewStateUpdater.cpp @@ -81,6 +81,10 @@ void JHybridRiveViewStateUpdater::updateViewProps(jni::alias_ref /* hybridView->setOnError(props->onError.value); props->onError.isDirty = false; } + if (props->onStop.isDirty) { + hybridView->setOnStop(props->onStop.value); + props->onStop.isDirty = false; + } // Update hybridRef if it changed if (props->hybridRef.isDirty) { diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/rive/HybridRiveViewSpec.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/rive/HybridRiveViewSpec.kt index be540182..a3e782de 100644 --- a/nitrogen/generated/android/kotlin/com/margelo/nitro/rive/HybridRiveViewSpec.kt +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/rive/HybridRiveViewSpec.kt @@ -100,6 +100,20 @@ abstract class HybridRiveViewSpec: HybridView() { set(value) { onError = value } + + abstract var onStop: () -> Unit + + private var onStop_cxx: Func_void + @Keep + @DoNotStrip + get() { + return Func_void_java(onStop) + } + @Keep + @DoNotStrip + set(value) { + onStop = value + } // Methods @DoNotStrip diff --git a/nitrogen/generated/android/riveOnLoad.cpp b/nitrogen/generated/android/riveOnLoad.cpp index e98e5b0c..f8166a32 100644 --- a/nitrogen/generated/android/riveOnLoad.cpp +++ b/nitrogen/generated/android/riveOnLoad.cpp @@ -27,13 +27,13 @@ #include "JHybridRiveRuntimeSpec.hpp" #include "JHybridRiveViewSpec.hpp" #include "JFunc_void_RiveError.hpp" +#include "JFunc_void.hpp" #include "JFunc_void_UnifiedRiveEvent.hpp" #include "views/JHybridRiveViewStateUpdater.hpp" #include "JHybridViewModelSpec.hpp" #include "JHybridViewModelInstanceSpec.hpp" #include "JHybridViewModelPropertySpec.hpp" #include "JHybridViewModelNumberPropertySpec.hpp" -#include "JFunc_void.hpp" #include "JFunc_void_double.hpp" #include "JHybridViewModelStringPropertySpec.hpp" #include "JFunc_void_std__string.hpp" @@ -129,13 +129,13 @@ void registerAllNatives() { margelo::nitro::rive::JHybridRiveRuntimeSpec::CxxPart::registerNatives(); margelo::nitro::rive::JHybridRiveViewSpec::CxxPart::registerNatives(); margelo::nitro::rive::JFunc_void_RiveError_cxx::registerNatives(); + margelo::nitro::rive::JFunc_void_cxx::registerNatives(); margelo::nitro::rive::JFunc_void_UnifiedRiveEvent_cxx::registerNatives(); margelo::nitro::rive::views::JHybridRiveViewStateUpdater::registerNatives(); margelo::nitro::rive::JHybridViewModelSpec::CxxPart::registerNatives(); margelo::nitro::rive::JHybridViewModelInstanceSpec::CxxPart::registerNatives(); margelo::nitro::rive::JHybridViewModelPropertySpec::CxxPart::registerNatives(); margelo::nitro::rive::JHybridViewModelNumberPropertySpec::CxxPart::registerNatives(); - margelo::nitro::rive::JFunc_void_cxx::registerNatives(); margelo::nitro::rive::JFunc_void_double_cxx::registerNatives(); margelo::nitro::rive::JHybridViewModelStringPropertySpec::CxxPart::registerNatives(); margelo::nitro::rive::JFunc_void_std__string_cxx::registerNatives(); diff --git a/nitrogen/generated/ios/c++/HybridRiveViewSpecSwift.hpp b/nitrogen/generated/ios/c++/HybridRiveViewSpecSwift.hpp index b3c0dfe7..167b1ee5 100644 --- a/nitrogen/generated/ios/c++/HybridRiveViewSpecSwift.hpp +++ b/nitrogen/generated/ios/c++/HybridRiveViewSpecSwift.hpp @@ -180,6 +180,13 @@ namespace margelo::nitro::rive { inline void setOnError(const std::function& onError) noexcept override { _swiftPart.setOnError(onError); } + inline std::function getOnStop() noexcept override { + auto __result = _swiftPart.getOnStop(); + return __result; + } + inline void setOnStop(const std::function& onStop) noexcept override { + _swiftPart.setOnStop(onStop); + } public: // Methods diff --git a/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm b/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm index f1669805..03fdc83e 100644 --- a/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm +++ b/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm @@ -127,6 +127,11 @@ - (void) updateProps:(const std::shared_ptr&)props swiftPart.setOnError(newViewProps.onError.value); newViewProps.onError.isDirty = false; } + // onStop: function + if (newViewProps.onStop.isDirty) { + swiftPart.setOnStop(newViewProps.onStop.value); + newViewProps.onStop.isDirty = false; + } swiftPart.afterUpdate(); diff --git a/nitrogen/generated/ios/swift/HybridRiveViewSpec.swift b/nitrogen/generated/ios/swift/HybridRiveViewSpec.swift index d2e38de6..cdb1a81f 100644 --- a/nitrogen/generated/ios/swift/HybridRiveViewSpec.swift +++ b/nitrogen/generated/ios/swift/HybridRiveViewSpec.swift @@ -21,6 +21,7 @@ public protocol HybridRiveViewSpec_protocol: HybridObject, HybridView { var semantics: Semantics? { get set } var dataBind: Variant__any_HybridViewModelInstanceSpec__DataBindMode_DataBindByName? { get set } var onError: (_ error: RiveError) -> Void { get set } + var onStop: () -> Void { get set } // Methods func awaitViewReady() throws -> Promise diff --git a/nitrogen/generated/ios/swift/HybridRiveViewSpec_cxx.swift b/nitrogen/generated/ios/swift/HybridRiveViewSpec_cxx.swift index bb9ead71..bba3dd02 100644 --- a/nitrogen/generated/ios/swift/HybridRiveViewSpec_cxx.swift +++ b/nitrogen/generated/ios/swift/HybridRiveViewSpec_cxx.swift @@ -402,6 +402,25 @@ open class HybridRiveViewSpec_cxx { }() } } + + public final var onStop: bridge.Func_void { + @inline(__always) + get { + return { () -> bridge.Func_void in + let __closureWrapper = Func_void(self.__implementation.onStop) + return bridge.create_Func_void(__closureWrapper.toUnsafe()) + }() + } + @inline(__always) + set { + self.__implementation.onStop = { () -> () -> Void in + let __wrappedFunction = bridge.wrap_Func_void(newValue) + return { () -> Void in + __wrappedFunction.call() + } + }() + } + } // Methods @inline(__always) diff --git a/nitrogen/generated/shared/c++/HybridRiveViewSpec.cpp b/nitrogen/generated/shared/c++/HybridRiveViewSpec.cpp index dadb86eb..25276fca 100644 --- a/nitrogen/generated/shared/c++/HybridRiveViewSpec.cpp +++ b/nitrogen/generated/shared/c++/HybridRiveViewSpec.cpp @@ -36,6 +36,8 @@ namespace margelo::nitro::rive { prototype.registerHybridSetter("dataBind", &HybridRiveViewSpec::setDataBind); prototype.registerHybridGetter("onError", &HybridRiveViewSpec::getOnError); prototype.registerHybridSetter("onError", &HybridRiveViewSpec::setOnError); + prototype.registerHybridGetter("onStop", &HybridRiveViewSpec::getOnStop); + prototype.registerHybridSetter("onStop", &HybridRiveViewSpec::setOnStop); prototype.registerHybridMethod("awaitViewReady", &HybridRiveViewSpec::awaitViewReady); prototype.registerHybridMethod("bindViewModelInstance", &HybridRiveViewSpec::bindViewModelInstance); prototype.registerHybridMethod("getViewModelInstance", &HybridRiveViewSpec::getViewModelInstance); diff --git a/nitrogen/generated/shared/c++/HybridRiveViewSpec.hpp b/nitrogen/generated/shared/c++/HybridRiveViewSpec.hpp index b6bce968..7fb6ab7b 100644 --- a/nitrogen/generated/shared/c++/HybridRiveViewSpec.hpp +++ b/nitrogen/generated/shared/c++/HybridRiveViewSpec.hpp @@ -100,6 +100,8 @@ namespace margelo::nitro::rive { virtual void setDataBind(const std::optional, DataBindMode, DataBindByName>>& dataBind) = 0; virtual std::function getOnError() = 0; virtual void setOnError(const std::function& onError) = 0; + virtual std::function getOnStop() = 0; + virtual void setOnStop(const std::function& onStop) = 0; public: // Methods diff --git a/nitrogen/generated/shared/c++/views/HybridRiveViewComponent.cpp b/nitrogen/generated/shared/c++/views/HybridRiveViewComponent.cpp index 570176df..82757d46 100644 --- a/nitrogen/generated/shared/c++/views/HybridRiveViewComponent.cpp +++ b/nitrogen/generated/shared/c++/views/HybridRiveViewComponent.cpp @@ -145,6 +145,16 @@ namespace margelo::nitro::rive::views { throw std::runtime_error(std::string("RiveView.onError: ") + exc.what()); } }()), + onStop([&]() -> CachedProp> { + try { + const react::RawValue* rawValue = rawProps.at("onStop", nullptr, nullptr); + if (rawValue == nullptr) return sourceProps.onStop; + const auto& [runtime, value] = (std::pair)*rawValue; + return CachedProp>::fromRawValue(*runtime, value.asObject(*runtime).getProperty(*runtime, PropNameIDCache::get(*runtime, "f")), sourceProps.onStop); + } catch (const std::exception& exc) { + throw std::runtime_error(std::string("RiveView.onStop: ") + exc.what()); + } + }()), hybridRef([&]() -> CachedProp& /* ref */)>>> { try { const react::RawValue* rawValue = rawProps.at("hybridRef", nullptr, nullptr); @@ -169,6 +179,7 @@ namespace margelo::nitro::rive::views { case hashString("semantics"): return true; case hashString("dataBind"): return true; case hashString("onError"): return true; + case hashString("onStop"): return true; case hashString("hybridRef"): return true; default: return false; } diff --git a/nitrogen/generated/shared/c++/views/HybridRiveViewComponent.hpp b/nitrogen/generated/shared/c++/views/HybridRiveViewComponent.hpp index 564ffcc9..27d708de 100644 --- a/nitrogen/generated/shared/c++/views/HybridRiveViewComponent.hpp +++ b/nitrogen/generated/shared/c++/views/HybridRiveViewComponent.hpp @@ -63,6 +63,7 @@ namespace margelo::nitro::rive::views { CachedProp> semantics; CachedProp, DataBindMode, DataBindByName>>> dataBind; CachedProp> onError; + CachedProp> onStop; CachedProp& /* ref */)>>> hybridRef; private: diff --git a/nitrogen/generated/shared/json/RiveViewConfig.json b/nitrogen/generated/shared/json/RiveViewConfig.json index 1283b0d9..bed6f54b 100644 --- a/nitrogen/generated/shared/json/RiveViewConfig.json +++ b/nitrogen/generated/shared/json/RiveViewConfig.json @@ -15,6 +15,7 @@ "semantics": true, "dataBind": true, "onError": true, + "onStop": true, "hybridRef": true } } diff --git a/src/core/RiveView.tsx b/src/core/RiveView.tsx index f62f16ae..e4ceeb26 100644 --- a/src/core/RiveView.tsx +++ b/src/core/RiveView.tsx @@ -5,13 +5,22 @@ import { callDispose } from './callDispose'; import type { RiveViewRef } from '../index'; export interface RiveViewProps - extends Omit, 'onError'> { + extends Omit, 'onError' | 'onStop'> { onError?: (error: RiveError) => void; + /** + * Called when the animation/state machine stops playing, e.g. when a + * non-looping animation reaches its end. Not called for pause() — only + * when playback naturally comes to rest. Useful for splash-screen-style + * animations where you want to navigate away once playback finishes. + */ + onStop?: () => void; } const defaultOnError = (error: RiveError) => console.error(`[${RiveErrorType[error.type]}] ${error.message}`); +const defaultOnStop = () => {}; + /** * RiveView is a React Native component that renders Rive graphics. * It provides a seamless way to display and control Rive graphics in your app. @@ -38,14 +47,16 @@ const defaultOnError = (error: RiveError) => * @property {number | FrameRateRange} [frameRate] - Preferred frame rate for the render loop (new runtimes only) * @property {Object} [style] - React Native style object for container customization * @property {(error: RiveError) => void} [onError] - Callback function that is called when an error occurs + * @property {() => void} [onStop] - Callback function that is called when the animation/state machine stops playing (e.g. reaches the end of a non-looping animation) * * The component also exposes methods for controlling playback: * - play(): Starts playing the Rive graphic * - pause(): Pauses the Rive graphic */ export function RiveView(props: RiveViewProps) { - const { onError, hybridRef: userHybridRef, ...rest } = props; + const { onError, onStop, hybridRef: userHybridRef, ...rest } = props; const wrappedOnError = onError ?? defaultOnError; + const wrappedOnStop = onStop ?? defaultOnStop; const viewRef = useRef(null); useEffect(() => { @@ -68,6 +79,7 @@ export function RiveView(props: RiveViewProps) { ); diff --git a/src/specs/RiveView.nitro.ts b/src/specs/RiveView.nitro.ts index f26af654..25d7b3dc 100644 --- a/src/specs/RiveView.nitro.ts +++ b/src/specs/RiveView.nitro.ts @@ -77,6 +77,12 @@ export interface RiveViewProps extends HybridViewProps { dataBind?: ViewModelInstance | DataBindMode | DataBindByName; /** Callback function that is called when an error occurs */ onError: (error: RiveError) => void; + /** + * Callback function that is called when the animation/state machine stops + * playing, e.g. when a non-looping animation reaches its end. Not called + * for pause() — only when playback naturally comes to rest. + */ + onStop: () => void; } /**