Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
override var fit: Fit? = null
override var layoutScaleFactor: Double? = null

// Accepted for API parity; frame-rate control is only implemented on the
// experimental backends.
override var frameRate: Variant_Double_FrameRateRange? = null

// Accepted for API parity; semantics are only available in the new Rive
// runtime, and Android support is pending upstream (iOS-only for now).
override var semantics: Semantics? = null
Expand Down
11 changes: 11 additions & 0 deletions android/src/new/java/com/margelo/nitro/rive/HybridRiveView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
override var fit: Fit? = null
override var layoutScaleFactor: Double? = null

// The render loop can only skip frames, so a range is honored best-effort
// as a cap at preferred ?? maximum.
override var frameRate: Variant_Double_FrameRateRange? = null
set(value) {
field = value
view.frameRate = value?.match(
first = { it },
second = { range -> range.preferred ?: range.maximum }
)
}

// Accepted for API parity; semantics support is pending in the upstream
// rive-android runtime (iOS-only for now).
override var semantics: Semantics? = null
Expand Down
62 changes: 62 additions & 0 deletions android/src/new/java/com/rive/RiveReactNativeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package com.rive

import android.annotation.SuppressLint
import android.graphics.SurfaceTexture
import android.os.Build
import android.util.Log
import android.view.Choreographer
import android.view.MotionEvent
import android.view.TextureView
import android.view.View
import android.widget.FrameLayout
import com.facebook.react.bridge.UiThreadUtil
import app.rive.Artboard
import app.rive.Fit
import app.rive.RiveFile
Expand Down Expand Up @@ -53,8 +56,20 @@ data class ViewConfiguration(
class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
companion object {
private const val TAG = "RiveReactNativeView"

// Half of a 120Hz vsync: lets a cap land on the nearest vsync multiple
// (e.g. every 4th frame at 120Hz for a 30fps cap) instead of drifting past
// it and halving the effective rate.
private const val CAP_TOLERANCE_NS = 4_000_000L
}

// Render at most this many frames per second; null = every vsync.
var frameRate: Double? = null
set(value) {
field = value
updateFrameRateHint()
}

var onError: ((String) -> Unit)? = null

private val errorListener: (String) -> Unit = { msg ->
Expand Down Expand Up @@ -85,6 +100,11 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
private var lastFrameTimeNs = 0L
private var frameCount = 0L

// While paused the loop still ticks, but only draws when something changed
// (initial content, resize, rebinding) — otherwise a paused view would keep
// re-rendering identical frames at full refresh rate.
private var needsRedraw = true

@Volatile
private var paused = false

Expand Down Expand Up @@ -118,6 +138,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {
this@RiveReactNativeView.surfaceWidth = w
this@RiveReactNativeView.surfaceHeight = h
this@RiveReactNativeView.needsRedraw = true
// Since 11.7.x the render target keeps its creation-time size and
// RiveSurface.resize() is internal to the SDK, so only the artboard
// is resized here (same behavior as before the 11.7.2 bump).
Expand All @@ -136,6 +157,26 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
override fun doFrame(frameTimeNanos: Long) {
if (!renderLoopRunning || disposed) return

if (paused && !needsRedraw) {
// Keep the timebase fresh so resuming advances by one frame, not by
// the whole pause span.
lastFrameTimeNs = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
return
}

val capPeriodNs = frameRate
?.takeIf { it > 0 }
?.let { (1_000_000_000.0 / it).toLong() }
if (capPeriodNs != null && lastFrameTimeNs != 0L &&
frameTimeNanos - lastFrameTimeNs < capPeriodNs - CAP_TOLERANCE_NS
) {
// Skip without touching lastFrameTimeNs: the eventual advance must
// cover the full elapsed time so capped playback keeps wall-clock speed.
Choreographer.getInstance().postFrameCallback(this)
return
}

val deltaTime = if (lastFrameTimeNs == 0L) {
Duration.ZERO
} else {
Expand All @@ -154,6 +195,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
worker.advanceStateMachine(sm, deltaTime)
}
worker.draw(art, sm, rs, activeFit)
needsRedraw = false
frameCount++
val isFirstFrame = frameCount == 1L
if (isFirstFrame) {
Expand All @@ -174,21 +216,37 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
if (renderLoopRunning) return
renderLoopRunning = true
lastFrameTimeNs = 0L
updateFrameRateHint()
Choreographer.getInstance().postFrameCallback(renderCallback)
}

private fun stopRenderLoop() {
renderLoopRunning = false
updateFrameRateHint()
Choreographer.getInstance().removeFrameCallback(renderCallback)
}

// Advisory platform hint (upstream applies the same one inside its Compose
// loop): on capable displays a capped, actively-drawing view lets the
// system lower the refresh rate, saving power beyond the skipped draws.
// Callers may be off-main (play()/pause() run on a coroutine), so hop.
private fun updateFrameRateHint() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return
UiThreadUtil.runOnUiThread {
val fps = frameRate?.takeIf { it > 0 && renderLoopRunning && !paused }
textureView.requestedFrameRate =
fps?.toFloat() ?: View.REQUESTED_FRAME_RATE_CATEGORY_NO_PREFERENCE
}
}

suspend fun awaitViewReady(): Boolean {
return viewReadyDeferred.await()
}

fun configure(config: ViewConfiguration, dataBindingChanged: Boolean, reload: Boolean = false, initialUpdate: Boolean = false) {
riveWorker = config.riveWorker
activeFit = config.fit
needsRedraw = true
Log.d(
TAG,
"configure: reload=$reload initialUpdate=$initialUpdate fit=$activeFit surfaceTexture=${surfaceTexture != null} surfaceW=$surfaceWidth surfaceH=$surfaceHeight"
Expand Down Expand Up @@ -371,17 +429,20 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
val smHandle = stateMachineHandle
if (worker != null && smHandle != null) {
worker.bindViewModelInstance(smHandle, instance.instanceHandle)
needsRedraw = true
} else {
Log.w(TAG, "Cannot bind VMI: worker or state machine handle not available")
}
}

fun play() {
paused = false
updateFrameRateHint()
}

fun pause() {
paused = true
updateFrameRateHint()
}

// Deprecated: the experimental Rive runtime has no reset primitive.
Expand All @@ -391,6 +452,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {

fun playIfNeeded() {
paused = false
updateFrameRateHint()
}

fun setNumberInputValue(name: String, value: Double, path: String?) {
Expand Down
183 changes: 183 additions & 0 deletions example/src/reproducers/FrameRateCap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { useRef, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import {
RiveView,
useRiveFile,
Fit,
type FrameRateRange,
type RiveViewRef,
} from '@rive-app/react-native';
import { type Metadata } from '../shared/metadata';

/**
* Manual verifier for the frameRate prop (issue #332).
*
* A looping animation renders at the display refresh rate by default; picking
* a cap should make it visibly step at that rate while keeping wall-clock
* playback speed (the loop takes the same time to complete). Pause should
* stop per-frame rendering entirely.
*
* Only the experimental (default) backends honor the prop.
*/

type FrameRateOption = {
label: string;
value: number | FrameRateRange | undefined;
};

const OPTIONS: FrameRateOption[] = [
{ label: 'Uncapped', value: undefined },
{ label: '30 fps', value: 30 },
{ label: '15 fps', value: 15 },
{ label: '5 fps', value: 5 },
{
label: 'Range 10–20 (pref 15)',
value: { minimum: 10, maximum: 20, preferred: 15 },
},
];

export default function FrameRateCap() {
const [optionIndex, setOptionIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [mounted, setMounted] = useState(true);
const viewRef = useRef<RiveViewRef | null>(null);
const { riveFile } = useRiveFile(require('../../assets/rive/rewards.riv'));

const selected = OPTIONS[optionIndex]!;

const togglePaused = () => {
if (paused) {
viewRef.current?.play();
} else {
viewRef.current?.pause();
}
setPaused(!paused);
};

return (
<View style={styles.container}>
<Text style={styles.title}>Frame Rate Cap</Text>
<Text style={styles.subtitle}>
Lower caps should visibly step but finish the loop in the same time.
</Text>
<View style={styles.buttonRow}>
{OPTIONS.map((option, index) => (
<Pressable
key={option.label}
testID={`framerate-${option.label}`}
style={[
styles.button,
index === optionIndex && styles.buttonActive,
]}
onPress={() => setOptionIndex(index)}
>
<Text
style={[
styles.buttonText,
index === optionIndex && styles.buttonTextActive,
]}
>
{option.label}
</Text>
</Pressable>
))}
<Pressable
testID="framerate-pause"
style={styles.button}
onPress={togglePaused}
>
<Text style={styles.buttonText}>{paused ? 'Play' : 'Pause'}</Text>
</Pressable>
<Pressable
testID="framerate-remount"
style={styles.button}
onPress={() => {
setPaused(false);
setMounted((m) => !m);
}}
>
<Text style={styles.buttonText}>{mounted ? 'Unmount' : 'Mount'}</Text>
</Pressable>
</View>
<View style={styles.riveContainer}>
{mounted && riveFile ? (
<RiveView
file={riveFile}
fit={Fit.Contain}
autoPlay={true}
frameRate={selected.value}
hybridRef={{ f: (ref) => (viewRef.current = ref) }}
style={styles.rive}
/>
) : (
<Text style={styles.loading}>
{mounted ? 'Loading…' : 'Unmounted'}
</Text>
)}
</View>
</View>
);
}

FrameRateCap.metadata = {
name: 'Frame Rate Cap',
description:
'frameRate prop: cap the render loop fps (experimental backends only)',
} satisfies Metadata;

const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: '#fff',
},
title: {
fontSize: 20,
fontWeight: 'bold',
textAlign: 'center',
},
subtitle: {
fontSize: 14,
color: '#666',
textAlign: 'center',
marginBottom: 16,
},
buttonRow: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 8,
marginBottom: 16,
},
button: {
paddingHorizontal: 14,
paddingVertical: 10,
backgroundColor: '#eee',
borderRadius: 8,
},
buttonActive: {
backgroundColor: '#007AFF',
},
buttonText: {
color: '#333',
fontWeight: '600',
fontSize: 13,
},
buttonTextActive: {
color: '#fff',
},
riveContainer: {
flex: 1,
backgroundColor: '#f5f5f5',
borderRadius: 8,
overflow: 'hidden',
justifyContent: 'center',
},
rive: {
flex: 1,
},
loading: {
textAlign: 'center',
color: '#666',
},
});
3 changes: 3 additions & 0 deletions ios/legacy/HybridRiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ class HybridRiveView: HybridRiveViewSpec {
var alignment: Alignment?
var fit: Fit?
var layoutScaleFactor: Double?
// Accepted for API parity; frame-rate control is only implemented on the
// experimental backends.
var frameRate: Variant_Double_FrameRateRange?
// Accepted for API parity; semantics are only available in the new Rive
// runtime (the experimental backend).
var semantics: Semantics?
Expand Down
Loading
Loading