Skip to content

Commit 975b01a

Browse files
committed
fix(ios): make play/pause control the experimental render loop
Forward isPaused to RiveUIView and pass it into the initializer so pause()/play() actually stop and resume playback, and autoPlay={false} starts paused. Adds harness tests for pause, resume, and autoPlay=false (reset left as a follow-up — experimental Rive has no reset primitive).
1 parent 42e7d76 commit 975b01a

2 files changed

Lines changed: 157 additions & 49 deletions

File tree

example/__tests__/autoplay.harness.tsx

Lines changed: 149 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import {
1717
} from '@rive-app/react-native';
1818
import 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/
2422
const 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+
3970
async 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-
137130
type 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+
275380
describe('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

ios/new/RiveReactNativeView.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,9 @@ class RiveReactNativeView: UIView {
8787
guard !Task.isCancelled else { return }
8888

8989
self.riveInstance = rive
90+
self.isPaused = !config.autoPlay
9091
self.setupRiveUIView(with: rive)
9192

92-
if config.autoPlay {
93-
self.isPaused = false
94-
}
95-
9693
if !self.isViewReady {
9794
self.isViewReady = true
9895
for continuation in self.viewReadyContinuations {
@@ -119,19 +116,25 @@ class RiveReactNativeView: UIView {
119116

120117
func play() {
121118
isPaused = false
119+
riveUIView?.isPaused = false
122120
}
123121

124122
func pause() {
125123
isPaused = true
124+
riveUIView?.isPaused = true
126125
}
127126

128127
func reset() {
128+
// TODO: experimental Rive has no reset primitive; "reset to initial state"
129+
// requires recreating the artboard/state machine. Tracked as a follow-up.
129130
isPaused = true
131+
riveUIView?.isPaused = true
130132
}
131133

132134
func playIfNeeded() {
133135
if isPaused {
134136
isPaused = false
137+
riveUIView?.isPaused = false
135138
}
136139
}
137140

@@ -180,7 +183,7 @@ class RiveReactNativeView: UIView {
180183
// This is a limitation of the experimental API - RiveUIView.rive is not publicly settable.
181184
riveUIView?.removeFromSuperview()
182185

183-
let uiView = RiveUIView(rive: rive)
186+
let uiView = RiveUIView(rive: rive, isPaused: isPaused)
184187
uiView.translatesAutoresizingMaskIntoConstraints = false
185188
addSubview(uiView)
186189
NSLayoutConstraint.activate([

0 commit comments

Comments
 (0)