Skip to content

Commit 9c8f57c

Browse files
authored
fix: make play/pause control the experimental render loop (iOS + Android) (#285)
## Problem On the experimental backend, `play()`/`pause()`/`reset()` were no-ops — animations couldn't be paused/resumed and `autoPlay={false}` was ignored. - **iOS**: only mutated a private `isPaused` flag never forwarded to `RiveUIView`. - **Android**: empty bodies; the render loop advanced unconditionally and `autoPlay` was never consumed. Additionally, `useRiveTrigger` never fired on Android experimental — three separate issues: 1. `awaitViewReady()` resolved at end of `configure()`, before the TextureView surface existed. Triggers fired before the surface was up were lost. 2. `getTriggerFlow` has `replay=0` (no initial value on subscription); `drop=1` was silently eating the first real trigger. 3. The listener coroutine was launched with `DEFAULT` start, so it was merely queued on `Dispatchers.Default` and could miss a trigger fired before it reached `collect()`. Also, `dataBind={{ byName }}` on Android always bound from `vmNames.first()` (first VM in file) instead of the artboard's default ViewModel. ## Fix - **iOS** (`ios/new/RiveReactNativeView.swift`): forward `isPaused` to `RiveUIView` in play/pause/playIfNeeded, pass it into `RiveUIView(rive:isPaused:)`, set it from `autoPlay`. - **Android** (`android/src/new/.../RiveReactNativeView.kt`): gate `advanceStateMachine` on a `paused` flag toggled by play/pause, honor `autoPlay`. Signal `awaitViewReady()` on first rendered frame (not end of configure). - **Android triggers**: use `drop=0` for trigger flow (no initial value to skip); launch listener coroutine with `CoroutineStart.UNDISPATCHED`. - **Android byName**: use `ViewModelSource.DefaultForArtboard(art)` instead of `Named(vmNames.first())`, matching iOS and the Auto path. - **reset()**: deprecated. Neither experimental backend has a reset primitive — marked `@deprecated` in the spec, experimental impl logs an error and does nothing. ## Verification - `autoplay.harness.tsx`: pause freezes `ypos`, play resumes it, `autoPlay={false}` holds. Green on both platforms. - `use-rive-trigger.harness.tsx`: both stable and unstable-callback tests now pass on Android. - `viewmodel-instance-lookup.harness.tsx`: byName regression test verifies `artboard2 + byName("vmi1") → _id="vm2.vmi1.id"` (not vm1). - CI: legacy iOS `pod install` step clears `Podfile.lock` to avoid external `fast_float` checksum rejection.
1 parent b8bca90 commit 9c8f57c

11 files changed

Lines changed: 261 additions & 86 deletions

File tree

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ jobs:
120120
if: env.turbo_cache_hit != 1
121121
run: |
122122
/bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null"
123+
for i in 1 2 3; do
124+
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.1.12297006" && break
125+
echo "NDK install attempt $i failed, retrying..."
126+
sleep 10
127+
done
123128
124129
- name: Cache Gradle
125130
if: env.turbo_cache_hit != 1
@@ -387,6 +392,10 @@ jobs:
387392
- name: Debug - Check for console logs
388393
if: failure() || cancelled()
389394
run: |
395+
echo "=== Metro health ==="
396+
curl -s --max-time 5 http://localhost:8081/status || echo "Metro not responding"
397+
echo "=== Metro process ==="
398+
lsof -i:8081 | head -5 || echo "Nothing on port 8081"
390399
echo "=== Simulator logs (last 5m) ==="
391400
xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "RiveExample"' --last 5m --style compact 2>&1 | tail -200 || echo "No logs found"
392401
echo "=== System log for Metro/Node ==="
@@ -413,6 +422,11 @@ jobs:
413422
- name: Finalize Android SDK
414423
run: |
415424
/bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null"
425+
for i in 1 2 3; do
426+
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.1.12297006" && break
427+
echo "NDK install attempt $i failed, retrying..."
428+
sleep 10
429+
done
416430
417431
- name: Cache Gradle
418432
uses: actions/cache@v4
@@ -582,6 +596,10 @@ jobs:
582596
- name: Debug - Check for console logs
583597
if: failure()
584598
run: |
599+
echo "=== Metro health ==="
600+
curl -s --max-time 5 http://localhost:8081/status || echo "Metro not responding"
601+
echo "=== Metro process ==="
602+
lsof -i:8081 | head -5 || echo "Nothing on port 8081"
585603
echo "=== Checking simulator logs for errors ==="
586604
xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "RiveExample"' --last 5m --style compact 2>&1 | tail -200 || echo "No logs found"
587605
@@ -606,6 +624,11 @@ jobs:
606624
- name: Finalize Android SDK
607625
run: |
608626
/bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null"
627+
for i in 1 2 3; do
628+
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.1.12297006" && break
629+
echo "NDK install attempt $i failed, retrying..."
630+
sleep 10
631+
done
609632
610633
- name: Enable legacy Rive backend
611634
run: |

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,6 @@ example/src/reproducers/local/*
9393

9494
# Test coverage
9595
coverage/
96+
97+
# Kotlin build artifacts
98+
**/.kotlin/

android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,13 @@ import kotlinx.coroutines.flow.Flow
1212
import kotlinx.coroutines.flow.drop
1313
import java.lang.ref.WeakReference
1414
import java.util.UUID
15-
import java.util.concurrent.ConcurrentHashMap
1615

1716
@Keep
1817
@DoNotStrip
1918
class BaseHybridViewModelPropertyImpl<T> : BaseHybridViewModelProperty<T> {
2019
override var scope: CoroutineScope? = null
2120
override var job: Job? = null
22-
override val listeners = ConcurrentHashMap<String, (T) -> Unit>()
21+
override val listeners = mutableMapOf<String, (T) -> Unit>()
2322

2423
override fun ensureValueListenerJob(valueFlow: Flow<T>, drop: Int) {
2524
if (scope == null) {

android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ class HybridViewModelTriggerProperty(
1818

1919
override fun addListener(onChanged: () -> Unit): () -> Unit {
2020
val remover = addListenerInternal { _ -> onChanged() }
21-
// Triggers use drop=0: getTriggerFlow has replay=0 and emits nothing on
22-
// subscription, so there is no initial value to skip.
21+
// drop=0: getTriggerFlow (replay=0) emits nothing on subscription, unlike number/boolean flows.
2322
ensureValueListenerJob(instance.getTriggerFlow(path), 0)
2423
return remover
2524
}

android/src/new/java/com/rive/RiveReactNativeView.kt

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import app.rive.core.RiveSurface
1818
import app.rive.core.StateMachineHandle
1919
import com.facebook.react.uimanager.ThemedReactContext
2020
import com.margelo.nitro.rive.RiveErrorLogger
21+
import com.margelo.nitro.rive.RiveLog
2122
import kotlinx.coroutines.CompletableDeferred
2223
import kotlinx.coroutines.CoroutineScope
2324
import kotlinx.coroutines.Dispatchers
@@ -77,6 +78,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
7778
private var lastFrameTimeNs = 0L
7879
private var frameCount = 0L
7980

81+
@Volatile
82+
private var paused = false
83+
8084
private val textureView = TextureView(context).apply {
8185
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
8286
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
@@ -129,13 +133,13 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
129133

130134
if (worker != null && art != null && sm != null && rs != null) {
131135
try {
132-
worker.advanceStateMachine(sm, deltaTime)
136+
if (!paused) {
137+
worker.advanceStateMachine(sm, deltaTime)
138+
}
133139
worker.draw(art, sm, rs, activeFit)
134140
frameCount++
135-
// Signal readiness only once the state machine is actually running
136-
// (surface created + first frame drawn), so callers awaiting
137-
// awaitViewReady() before firing triggers don't fire too early.
138-
if (frameCount == 1L) {
141+
val isFirstFrame = frameCount == 1L
142+
if (isFirstFrame) {
139143
viewReadyDeferred.complete(true)
140144
}
141145
} catch (e: Exception) {
@@ -200,6 +204,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
200204

201205
Log.d(TAG, "configure: artboard=${artboardHandle != null} sm=${stateMachineHandle != null} surface=${riveSurface != null}")
202206

207+
paused = !config.autoPlay
203208
startRenderLoop()
204209
}
205210

@@ -208,7 +213,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
208213
if (dataBindingChanged || initialUpdate) {
209214
applyDataBinding(config.bindData, config.riveFile)
210215
}
211-
212216
}
213217

214218
private fun resizeArtboardIfLayout() {
@@ -312,14 +316,11 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
312316
}
313317
is BindData.ByName -> {
314318
try {
315-
val vmNames = kotlinx.coroutines.runBlocking { riveFile.getViewModelNames() }
316-
if (vmNames.isNotEmpty()) {
317-
val vmSource = ViewModelSource.Named(vmNames.first())
318-
val source = vmSource.namedInstance(bindData.name)
319-
val instance = ViewModelInstance.fromFile(riveFile, source)
320-
boundInstance = instance
321-
bindInstanceToStateMachine(instance)
322-
}
319+
val art = artboard ?: return
320+
val source = ViewModelSource.DefaultForArtboard(art).namedInstance(bindData.name)
321+
val instance = ViewModelInstance.fromFile(riveFile, source)
322+
boundInstance = instance
323+
bindInstanceToStateMachine(instance)
323324
} catch (e: Exception) {
324325
Log.e(TAG, "Failed to create named instance", e)
325326
}
@@ -337,13 +338,22 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
337338
}
338339
}
339340

340-
fun play() { /* controlled by render loop */ }
341+
fun play() {
342+
paused = false
343+
}
341344

342-
fun pause() { /* controlled by render loop */ }
345+
fun pause() {
346+
paused = true
347+
}
343348

344-
fun reset() { /* controlled by render loop */ }
349+
// Deprecated: the experimental Rive runtime has no reset primitive.
350+
fun reset() {
351+
RiveLog.e(TAG, "reset() is deprecated and not supported on the experimental backend")
352+
}
345353

346-
fun playIfNeeded() { /* controlled by render loop */ }
354+
fun playIfNeeded() {
355+
paused = false
356+
}
347357

348358
fun setNumberInputValue(name: String, value: Double, path: String?) {
349359
throw UnsupportedOperationException("SMI inputs not supported in experimental API")

0 commit comments

Comments
 (0)