diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt b/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt index bfb777d7..bb3316e5 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt @@ -10,6 +10,7 @@ import com.rive.ViewConfiguration import app.rive.runtime.kotlin.core.Fit as RiveFit import app.rive.runtime.kotlin.core.Alignment as RiveAlignment import app.rive.runtime.kotlin.core.errors.* +import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -268,6 +269,7 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() { val (errorType, errorDescription) = detectErrorType(e) val noteString = note?.let { " $it" } ?: "" val errorMessage = "[RIVE] $tag$noteString $errorDescription" + Log.e(TAG, errorMessage, e) val riveError = RiveError( type = errorType, message = errorMessage diff --git a/android/src/main/java/com/rive/RiveReactNativeView.kt b/android/src/main/java/com/rive/RiveReactNativeView.kt index fcd4ff09..6bd9d89a 100644 --- a/android/src/main/java/com/rive/RiveReactNativeView.kt +++ b/android/src/main/java/com/rive/RiveReactNativeView.kt @@ -77,7 +77,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var eventListeners: MutableList = mutableListOf() private val viewReadyDeferred = CompletableDeferred() private var _activeStateMachineName: String? = null - private var _pendingBindData: BindData? = null private var willDispose = false init { @@ -104,11 +103,17 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { fun configure(config: ViewConfiguration, dataBindingChanged: Boolean, reload: Boolean = false, initialUpdate: Boolean = false) { if (reload) { + val hasDataBinding = when (config.bindData) { + is BindData.None -> false + is BindData.Auto -> config.riveFile.viewModelCount > 0 + is BindData.Instance, is BindData.ByName -> true + } riveAnimationView?.setRiveFile( config.riveFile, artboardName = config.artboardName, stateMachineName = config.stateMachineName, autoplay = config.autoPlay, + autoBind = hasDataBinding, alignment = config.alignment, fit = config.fit ) @@ -121,7 +126,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } if (dataBindingChanged || initialUpdate || reload) { - applyDataBinding(config.bindData, config.autoPlay) + applyDataBinding(config.bindData) } viewReadyDeferred.complete(true) @@ -143,20 +148,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } } - fun applyDataBinding(bindData: BindData, autoPlay: Boolean) { - val stateMachines = riveAnimationView?.controller?.stateMachines - if (stateMachines.isNullOrEmpty()) { - _pendingBindData = bindData - return - } - + fun applyDataBinding(bindData: BindData) { bindToStateMachine(bindData) - - if (autoPlay) { - stateMachines.first().name.let { smName -> - riveAnimationView?.play(smName, isStateMachine = true) - } - } } fun play() { @@ -164,14 +157,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { _activeStateMachineName = getSafeStateMachineName() } riveAnimationView?.play() - applyPendingBindData() - } - - private fun applyPendingBindData() { - _pendingBindData?.let { bindData -> - _pendingBindData = null - bindToStateMachine(bindData) - } } fun pause() = riveAnimationView?.pause() diff --git a/example/__tests__/autoplay-false-trigger.harness.tsx b/example/__tests__/autoplay-false-trigger.harness.tsx new file mode 100644 index 00000000..4e3338df --- /dev/null +++ b/example/__tests__/autoplay-false-trigger.harness.tsx @@ -0,0 +1,168 @@ +import { + describe, + it, + expect, + render, + waitFor, + cleanup, +} from 'react-native-harness'; +import { useEffect } from 'react'; +import { View } from 'react-native'; +import { + RiveView, + RiveFileFactory, + Fit, + type RiveFile, + type RiveViewRef, +} from '@rive-app/react-native'; +import type { ViewModelInstance } from '@rive-app/react-native'; + +// quick_start.riv has: +// - "health" number property (default 50) +// - "gameOver" trigger property +// - state machine named "State Machine 1" +const QUICK_START = require('../assets/rive/quick_start.riv'); +const RATING = require('../assets/rive/rating.riv'); + +function expectDefined(value: T): asserts value is NonNullable { + expect(value).toBeDefined(); +} + +type TestContext = { + ref: RiveViewRef | null; + error: string | null; +}; + +function AutoPlayFalseView({ + file, + instance, + context, +}: { + file: RiveFile; + instance: ViewModelInstance; + context: TestContext; +}) { + useEffect(() => { + return () => { + context.ref = null; + }; + }, [context]); + + return ( + + { + context.ref = ref; + }, + }} + style={{ flex: 1 }} + file={file} + autoPlay={false} + dataBind={instance} + fit={Fit.Contain} + stateMachineName="State Machine 1" + onError={(e) => { + context.error = e.message; + }} + /> + + ); +} + +async function loadQuickStart() { + const file = await RiveFileFactory.fromSource(QUICK_START, undefined); + const vm = file.defaultArtboardViewModel(); + expectDefined(vm); + const instance = vm.createDefaultInstance(); + expectDefined(instance); + return { file, instance }; +} + +function SimpleRiveView({ + file, + context, +}: { + file: RiveFile; + context: TestContext; +}) { + useEffect(() => { + return () => { + context.ref = null; + }; + }, [context]); + + return ( + + { + context.ref = ref; + }, + }} + style={{ flex: 1 }} + file={file} + fit={Fit.Contain} + onError={(e) => { + context.error = e.message; + }} + /> + + ); +} + +describe('autoPlay={false} + dataBind (issue #156)', () => { + it('VMI is bound to state machine without play()', async () => { + const { file, instance } = await loadQuickStart(); + const context: TestContext = { ref: null, error: null }; + + const health = instance.numberProperty('health'); + expectDefined(health); + health.value = 25; + + await render( + + ); + + await waitFor( + () => { + expect(context.ref).not.toBeNull(); + }, + { timeout: 5000 } + ); + + await context.ref!.awaitViewReady(); + + // Without fix: getViewModelInstance() returns null because + // the SDK never created state machines (autoPlay=false, no autoBind) + const boundVmi = context.ref!.getViewModelInstance(); + expect(boundVmi).not.toBeNull(); + + // The health value we set should be readable + const boundHealth = boundVmi!.numberProperty('health'); + expectDefined(boundHealth); + expect(boundHealth.value).toBe(25); + + expect(context.error).toBeNull(); + cleanup(); + }); + + it('file without data binding renders without error', async () => { + const file = await RiveFileFactory.fromSource(RATING, undefined); + const context: TestContext = { ref: null, error: null }; + + await render(); + + await waitFor( + () => { + expect(context.ref).not.toBeNull(); + }, + { timeout: 5000 } + ); + + // Give it time to render and potentially throw + await new Promise((r) => setTimeout(r, 500)); + expect(context.error).toBeNull(); + cleanup(); + }); +}); diff --git a/example/index.js b/example/index.js index 117ddcae..32a69fef 100644 --- a/example/index.js +++ b/example/index.js @@ -1,3 +1,4 @@ +import './src/polyfills'; import { AppRegistry } from 'react-native'; import App from './src/App'; import { name as appName } from './app.json'; diff --git a/example/src/polyfills.js b/example/src/polyfills.js new file mode 100644 index 00000000..38285040 --- /dev/null +++ b/example/src/polyfills.js @@ -0,0 +1,34 @@ +/* global globalThis */ +// Polyfill EventTarget and Event for chai 6.x (used by react-native-harness). +// Hermes on RN 0.79 doesn't have these Web APIs. +// Must load before any react-native-harness import. +if (!globalThis.Event) { + globalThis.Event = class Event { + constructor(type) { + this.type = type; + } + }; +} +if (!globalThis.EventTarget) { + globalThis.EventTarget = class EventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, listener) { + (this._listeners[type] ??= []).push(listener); + } + removeEventListener(type, listener) { + const list = this._listeners[type]; + if (list) { + this._listeners[type] = list.filter((l) => l !== listener); + } + } + dispatchEvent(event) { + const list = this._listeners[event.type]; + if (list) { + list.forEach((l) => l(event)); + } + return true; + } + }; +}