diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index 36d0baf4..18e2301c 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -116,6 +116,19 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { // re-rendering identical frames at full refresh rate. private var needsRedraw = true + private data class LayoutSize( + val artboard: Artboard, + val surface: RiveSurface, + val width: Int, + val height: Int, + val fit: Fit.Layout + ) + + private var appliedLayout: LayoutSize? = null + + // A resize only takes effect on an advance, so a paused or settled view needs one zero advance. + private var layoutAdvancePending = false + @Volatile private var paused = false @@ -151,9 +164,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { 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). + this@RiveReactNativeView.riveSurface?.let { RiveSurfaceResize.resize(it, w, h) } resizeArtboardIfLayout() } @@ -205,7 +216,10 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { try { if (!paused && !settled) { worker.advanceStateMachine(sm, deltaTime) + } else if (layoutAdvancePending) { + worker.advanceStateMachine(sm, Duration.ZERO) } + layoutAdvancePending = false worker.draw(art, sm, rs, activeFit) needsRedraw = false frameCount++ @@ -265,6 +279,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { ) if (reload) { + layoutAdvancePending = false RiveErrorLogger.resetReportedErrors() RiveErrorLogger.addListener(errorListener) stateMachineHandle?.let { old -> @@ -330,7 +345,17 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (fit is Fit.Layout) { val rs = riveSurface ?: return val art = artboard ?: return + val layout = LayoutSize(art, rs, surfaceWidth, surfaceHeight, fit) + if (layout == appliedLayout) return + appliedLayout = layout art.resizeArtboard(rs, fit.scaleFactor) + // A responsive layout may start animating, so resume advancing like upstream's unsettle(). + settled = false + layoutAdvancePending = true + needsRedraw = true + } else { + appliedLayout = null + layoutAdvancePending = false } } diff --git a/android/src/new/java/com/rive/RiveSurfaceResize.kt b/android/src/new/java/com/rive/RiveSurfaceResize.kt new file mode 100644 index 00000000..edf57fe4 --- /dev/null +++ b/android/src/new/java/com/rive/RiveSurfaceResize.kt @@ -0,0 +1,40 @@ +package com.rive + +import android.util.Log +import app.rive.core.RiveSurface +import java.lang.reflect.InvocationTargetException + +/** + * Upstream `RiveView` resizes its surface on every size change through the + * Kotlin-internal `RiveSurface.resize`, so reflection reaches it under its + * mangled JVM name (rive-app/rive-nitro-react-native#418). + */ +internal object RiveSurfaceResize { + private const val TAG = "RiveSurfaceResize" + + private val resizeMethod by lazy { + val signature = arrayOf(Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + val method = RiveSurface::class.java.declaredMethods.firstOrNull { + (it.name == "resize" || it.name.startsWith("resize$")) && + it.parameterTypes.contentEquals(signature) + } + if (method == null) Log.w(TAG, "RiveSurface.resize not found") + method + } + + /** Returns false when the surface keeps its previous size. */ + fun resize(surface: RiveSurface, width: Int, height: Int): Boolean { + if (width <= 0 || height <= 0) return false + val method = resizeMethod ?: return false + return try { + method.invoke(surface, width, height) + true + } catch (e: InvocationTargetException) { + Log.w(TAG, "RiveSurface.resize(${width}x$height) failed", e.targetException) + false + } catch (e: ReflectiveOperationException) { + Log.w(TAG, "RiveSurface.resize(${width}x$height) failed", e) + false + } + } +} diff --git a/example/src/reproducers/Issue418SurfaceResize.tsx b/example/src/reproducers/Issue418SurfaceResize.tsx new file mode 100644 index 00000000..87e2cbe5 --- /dev/null +++ b/example/src/reproducers/Issue418SurfaceResize.tsx @@ -0,0 +1,90 @@ +import { useCallback, useRef, useState } from 'react'; +import { Animated, Pressable, Text, View, StyleSheet } from 'react-native'; +import { Fit, RiveView, useRiveFile } from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +/** + * Issue #418: on the Android new backend, resizing a mounted RiveView kept + * rendering into a surface sized at creation time, so Fit.Layout content + * looked scaled and offset until remount. + * + * Toggle the height, then Remount at the same height: both should look + * identical. + */ + +const SHORT = 200; +const TALL = 400; + +export default function Issue418SurfaceResize() { + const { riveFile, error } = useRiveFile( + require('../../assets/rive/layout_test.riv') + ); + const [height] = useState(() => new Animated.Value(SHORT)); + const tall = useRef(false); + const [isTall, setIsTall] = useState(false); + const [generation, setGeneration] = useState(0); + + const toggle = useCallback(() => { + tall.current = !tall.current; + setIsTall(tall.current); + Animated.timing(height, { + toValue: tall.current ? TALL : SHORT, + duration: 400, + useNativeDriver: false, + }).start(); + }, [height]); + + return ( + + + target {isTall ? TALL : SHORT}pt ยท generation {generation} + + {error ? {String(error)} : null} + + + Toggle height + + setGeneration((g) => g + 1)} + > + Remount + + + + {riveFile ? ( + + ) : null} + + + ); +} + +Issue418SurfaceResize.metadata = { + name: 'Issue #418: Surface resize', + description: + 'Android Fit.Layout content stale after the view resizes until remount', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { flex: 1, padding: 16, backgroundColor: '#fff' }, + subtitle: { fontSize: 14, color: '#666' }, + error: { color: 'red', marginTop: 8 }, + buttons: { flexDirection: 'row', gap: 8 }, + box: { marginTop: 16, backgroundColor: '#1a2a3a' }, + rive: { flex: 1 }, + button: { + backgroundColor: '#007AFF', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + marginTop: 8, + }, + buttonText: { color: '#fff', fontWeight: '600' }, +});