@@ -17,8 +17,6 @@ import {
1717} from '@rive-app/react-native' ;
1818import type { ViewModelInstance } from '@rive-app/react-native' ;
1919
20- const isExperimental = RiveFileFactory . getBackend ( ) === 'experimental' ;
21-
2220// Bouncing ball .riv with a "ypos" ViewModel number property that changes during playback
2321// Source: https://rive.app/community/files/25997-48571-demo-for-tracking-rive-property-in-react-native/
2422const BOUNCING_BALL = require ( '../assets/rive/bouncing_ball.riv' ) ;
@@ -36,6 +34,39 @@ function valueChanged(a: number, b: number): boolean {
3634 return Math . abs ( a - b ) > 0.001 ;
3735}
3836
37+ const delay = ( ms : number ) => new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
38+
39+ /**
40+ * Returns true if the property value changes within the timeout. Polls
41+ * prop.value rather than relying on the listener, because state-machine-driven
42+ * changes do not fire addListener on all platforms (e.g. iOS experimental).
43+ */
44+ function pollChangedWithin (
45+ instance : ViewModelInstance ,
46+ propertyName : string ,
47+ timeout = 800
48+ ) : Promise < boolean > {
49+ return new Promise ( ( resolve ) => {
50+ const prop = instance . numberProperty ( propertyName ) ;
51+ if ( ! prop ) {
52+ resolve ( false ) ;
53+ return ;
54+ }
55+ const initial = prop . value ;
56+ const timer = setTimeout ( ( ) => {
57+ clearInterval ( poll ) ;
58+ resolve ( false ) ;
59+ } , timeout ) ;
60+ const poll = setInterval ( ( ) => {
61+ if ( valueChanged ( prop . value , initial ) ) {
62+ clearTimeout ( timer ) ;
63+ clearInterval ( poll ) ;
64+ resolve ( true ) ;
65+ }
66+ } , 50 ) ;
67+ } ) ;
68+ }
69+
3970async function loadBouncingBall ( ) {
4071 const file = await RiveFileFactory . fromSource ( BOUNCING_BALL , undefined ) ;
4172 const vm = file . defaultArtboardViewModel ( ) ;
@@ -96,44 +127,6 @@ function waitForPropertyChange(
96127 } ) ;
97128}
98129
99- /**
100- * Returns true if the property value changes within the timeout, false otherwise.
101- */
102- function didPropertyChange (
103- instance : ViewModelInstance ,
104- propertyName : string ,
105- timeout = 500
106- ) : Promise < boolean > {
107- return new Promise ( ( resolve ) => {
108- const prop = instance . numberProperty ( propertyName ) ;
109- if ( ! prop ) {
110- resolve ( false ) ;
111- return ;
112- }
113-
114- function done ( changed : boolean ) {
115- clearTimeout ( timer ) ;
116- removeListener ( ) ;
117- resolve ( changed ) ;
118- }
119-
120- const timer = setTimeout ( ( ) => done ( false ) , timeout ) ;
121-
122- let firstEmit = true ;
123- let initialValue : number | undefined ;
124- const removeListener = prop . addListener ( ( newValue : number ) => {
125- if ( firstEmit ) {
126- initialValue = newValue ;
127- firstEmit = false ;
128- return ;
129- }
130- if ( newValue !== initialValue ) {
131- done ( true ) ;
132- }
133- } ) ;
134- } ) ;
135- }
136-
137130type TestContext = {
138131 ref : RiveViewRef | null ;
139132 error : string | null ;
@@ -186,9 +179,6 @@ describe('autoPlay prop (issue #138)', () => {
186179 } ) ;
187180
188181 it ( 'autoPlay={false} does not change ypos property' , async ( ) => {
189- if ( isExperimental ) {
190- return ; // experimental SDK has no pause API — always advances
191- }
192182 const { file, instance } = await loadBouncingBall ( ) ;
193183
194184 const context : TestContext = { ref : null , error : null } ;
@@ -208,7 +198,7 @@ describe('autoPlay prop (issue #138)', () => {
208198 { timeout : 5000 }
209199 ) ;
210200
211- const changed = await didPropertyChange ( instance , 'ypos' ) ;
201+ const changed = await pollChangedWithin ( instance , 'ypos' , 800 ) ;
212202 expect ( changed ) . toBe ( false ) ;
213203 expect ( context . error ) . toBeNull ( ) ;
214204
@@ -272,6 +262,121 @@ describe('autoPlay prop (issue #138)', () => {
272262 } ) ;
273263} ) ;
274264
265+ describe ( 'imperative playback control (play/pause/reset)' , ( ) => {
266+ // These observe the ypos ViewModel property to detect whether the state
267+ // machine is advancing, instead of inspecting pixels.
268+
269+ it ( 'pause() stops ypos from advancing' , async ( ) => {
270+ const { file, instance } = await loadBouncingBall ( ) ;
271+
272+ const context : TestContext = { ref : null , error : null } ;
273+ await render (
274+ < RiveTestView
275+ file = { file }
276+ autoPlay = { true }
277+ instance = { instance }
278+ context = { context }
279+ />
280+ ) ;
281+
282+ await waitFor (
283+ ( ) => {
284+ expect ( context . ref ) . not . toBeNull ( ) ;
285+ } ,
286+ { timeout : 5000 }
287+ ) ;
288+
289+ // Confirm it is actually playing before we pause.
290+ await waitForPropertyChange ( instance , 'ypos' ) ;
291+
292+ await context . ref ! . pause ( ) ;
293+ // Let any in-flight frame settle so we don't catch a trailing advance.
294+ await delay ( 100 ) ;
295+
296+ const changedWhilePaused = await pollChangedWithin ( instance , 'ypos' , 800 ) ;
297+ expect ( changedWhilePaused ) . toBe ( false ) ;
298+ expect ( context . error ) . toBeNull ( ) ;
299+
300+ cleanup ( ) ;
301+ } ) ;
302+
303+ it ( 'play() resumes ypos after pause()' , async ( ) => {
304+ const { file, instance } = await loadBouncingBall ( ) ;
305+
306+ const context : TestContext = { ref : null , error : null } ;
307+ await render (
308+ < RiveTestView
309+ file = { file }
310+ autoPlay = { true }
311+ instance = { instance }
312+ context = { context }
313+ />
314+ ) ;
315+
316+ await waitFor (
317+ ( ) => {
318+ expect ( context . ref ) . not . toBeNull ( ) ;
319+ } ,
320+ { timeout : 5000 }
321+ ) ;
322+
323+ await waitForPropertyChange ( instance , 'ypos' ) ;
324+ await context . ref ! . pause ( ) ;
325+ await delay ( 100 ) ;
326+ expect ( await pollChangedWithin ( instance , 'ypos' , 800 ) ) . toBe ( false ) ;
327+
328+ await context . ref ! . play ( ) ;
329+ const resumed = await waitForPropertyChange ( instance , 'ypos' ) ;
330+ expect ( typeof resumed ) . toBe ( 'number' ) ;
331+ expect ( context . error ) . toBeNull ( ) ;
332+
333+ cleanup ( ) ;
334+ } ) ;
335+
336+ // TODO: experimental iOS Rive has no reset primitive — "reset to initial
337+ // state" needs recreating the artboard/state machine. Enable once implemented.
338+ it . skip ( 'reset() returns ypos to its initial state' , async ( ) => {
339+ const { file, instance } = await loadBouncingBall ( ) ;
340+
341+ // Authored default, read before any playback has advanced it.
342+ const ypos = instance . numberProperty ( 'ypos' ) ;
343+ expectDefined ( ypos ) ;
344+ const initial = ypos . value ;
345+
346+ const context : TestContext = { ref : null , error : null } ;
347+ await render (
348+ < RiveTestView
349+ file = { file }
350+ autoPlay = { true }
351+ instance = { instance }
352+ context = { context }
353+ />
354+ ) ;
355+
356+ await waitFor (
357+ ( ) => {
358+ expect ( context . ref ) . not . toBeNull ( ) ;
359+ } ,
360+ { timeout : 5000 }
361+ ) ;
362+
363+ // Let it advance away from the initial value.
364+ const moved = await waitForPropertyChange ( instance , 'ypos' ) ;
365+ expect ( valueChanged ( moved , initial ) ) . toBe ( true ) ;
366+
367+ // Pause first so reset is observed against a frozen state machine rather
368+ // than racing a frame that immediately re-advances ypos.
369+ await context . ref ! . pause ( ) ;
370+ await context . ref ! . reset ( ) ;
371+ await delay ( 100 ) ;
372+
373+ expect ( valueChanged ( ypos . value , initial ) ) . toBe ( false ) ;
374+ expect ( context . error ) . toBeNull ( ) ;
375+
376+ cleanup ( ) ;
377+ } ) ;
378+ } ) ;
379+
275380describe ( 'Auto dataBind with no default ViewModel (issue #189)' , ( ) => {
276381 it ( 'auto-binds default ViewModel when one exists' , async ( ) => {
277382 // getViewModelInstance() returns null on Android experimental — auto-bind
0 commit comments