Skip to content

Commit 8b4219d

Browse files
authored
Fix Fit.LAYOUT artboard oversized on Android (#209)
Fixes #206 ## Problem `Fit.LAYOUT` **intermittently** renders the artboard at pixel dimensions instead of dp on Android (~50% repro rate, requires process restart). `layoutScaleFactorAutomatic` in rive-android defaults to `1.0` and is only corrected to device density in `onMeasure()`. When `configure()` calls `setRiveFile()`, the view hasn't been measured yet — but in bare Android this is harmless because `onMeasure` runs on the next Choreographer frame before the render thread picks up the first frame. In React Native, `configure()` arrives from JS in a separate UI thread message, creating a wider window where the render thread **can** call `resizeArtboard()` with the bad default (`1.0`) before `onMeasure` corrects it. Whether it does depends on thread scheduling at process start — hence the ~50% repro rate and the need for a full process restart to re-roll the dice. iOS doesn't have this issue because [it initializes the scale factor at view init time](https://github.com/rive-app/rive-ios/blob/68513e97/Source/RiveView.swift#L38-L44). ## Fix Set `layoutScaleFactor` to `resources.displayMetrics.density` in `configure()` before `setRiveFile()`, when the user hasn't set an explicit value. This closes the race window. Also filed upstream: rive-app/rive-android#446 / rive-app/rive-android#447 ## Before / After |*before*|*after*| |--------|-------| |<img width="300" alt="image" src="https://github.com/user-attachments/assets/cfb13a75-a744-4c34-b28a-ec8b52faaaac" />|<img width="300" alt="image" src="https://github.com/user-attachments/assets/bf53c9d7-0360-4c9e-a0d0-e21489327f98" />| ## Reproducer Requires navigation (screen transition) to trigger the race — mounting `Fit.Layout` views on the initial screen doesn't reproduce because the timing window is too narrow. Repro rate ~50%, requires process restart. <details> <summary>Expo Router reproducer (4 files)</summary> **app/index.tsx** — home screen with navigation button ```tsx import { router } from 'expo-router'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Background from '../Background'; export default function Home() { return ( <Background> <SafeAreaView style={styles.container}> <View style={styles.content}> <Text style={styles.title}>Rive Border Repro</Text> <Pressable style={styles.button} onPress={() => router.push('/login')}> <Text style={styles.buttonText}>Go to Login</Text> </Pressable> </View> </SafeAreaView> </Background> ); } const styles = StyleSheet.create({ container: { flex: 1 }, content: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, title: { color: '#FFF', fontSize: 28, fontWeight: '600', marginBottom: 32 }, button: { backgroundColor: '#443ABC', paddingVertical: 14, paddingHorizontal: 32, borderRadius: 12 }, buttonText: { color: '#FFF', fontSize: 16, fontWeight: '600' }, }); ``` **app/login.tsx** — mounts Fit.Layout RiveView during screen transition ```tsx import { StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { RiveBorderInput } from '../RiveBorderInput'; export default function Login() { return ( <View style={styles.bg}> <SafeAreaView style={styles.container}> <View style={styles.form}> <Text style={styles.title}>Welcome back</Text> <View style={styles.fields}> <RiveBorderInput placeholder="Email or username" autoCapitalize="none" /> </View> </View> </SafeAreaView> </View> ); } const styles = StyleSheet.create({ bg: { flex: 1, backgroundColor: '#0c1027' }, container: { flex: 1, justifyContent: 'center', paddingHorizontal: 24 }, form: { alignItems: 'center' }, title: { color: '#FFF', fontSize: 32, fontWeight: '600', marginBottom: 24, textAlign: 'center' }, fields: { width: '100%', maxWidth: 400 }, }); ``` **RiveBorderInput.tsx** — the Fit.Layout component that gets oversized ```tsx import { forwardRef, useEffect, useState } from 'react'; import { StyleSheet, TextInput, TextInputProps, View } from 'react-native'; import { Fit, RiveView, useRive, useRiveBoolean, useRiveFile, useViewModelInstance } from '@rive-app/react-native'; import { LinearGradient } from 'expo-linear-gradient'; interface RiveBorderInputProps extends TextInputProps { nextInputRef?: React.RefObject<TextInput | null>; } export const RiveBorderInput = forwardRef<TextInput, RiveBorderInputProps>( function RiveBorderInput({ nextInputRef, ...textInputProps }, ref) { const [isFocused, setIsFocused] = useState(false); const { riveViewRef, setHybridRef } = useRive(); const { riveFile } = useRiveFile(require('./assets/rive/GradientBorder.riv')); const { instance: viewModelInstance } = useViewModelInstance(riveFile); const { setValue: setRiveFocused } = useRiveBoolean('isFocused', viewModelInstance); useEffect(() => { setRiveFocused(isFocused); riveViewRef?.playIfNeeded(); }, [isFocused, setRiveFocused, riveViewRef]); return ( <View style={styles.outerContainer}> <View style={styles.wrapper}> {riveFile && viewModelInstance && ( <RiveView file={riveFile} autoPlay fit={Fit.Layout} style={styles.riveAnimation} dataBind={viewModelInstance} hybridRef={setHybridRef} /> )} <LinearGradient colors={isFocused ? ['#00B78B', '#443ABC'] : ['#354190', '#4250BA']} start={{ x: 0, y: 0 }} end={{ x: 0.8, y: 1 }} style={styles.gradient} > <View style={styles.fieldInner}> <TextInput ref={ref} style={styles.input} placeholderTextColor="#6b7280" returnKeyType={nextInputRef ? 'next' : 'done'} onSubmitEditing={() => nextInputRef?.current?.focus()} {...textInputProps} onFocus={(e) => { setIsFocused(true); textInputProps.onFocus?.(e); }} onBlur={(e) => { setIsFocused(false); textInputProps.onBlur?.(e); }} /> </View> </LinearGradient> </View> </View> ); } ); const styles = StyleSheet.create({ outerContainer: { marginBottom: 8, borderWidth: 1, borderColor: 'transparent', borderRadius: 16 }, wrapper: { borderRadius: 16 }, riveAnimation: { position: 'absolute', top: -25, left: -25, right: -28, bottom: -28 }, gradient: { borderRadius: 16, padding: 1 }, fieldInner: { backgroundColor: 'rgba(3, 7, 18, 0.80)', borderRadius: 15, paddingHorizontal: 12, paddingVertical: 12, flexDirection: 'row', alignItems: 'center', minHeight: 52 }, input: { color: '#ffffff', fontSize: 16, fontWeight: '500', flex: 1, padding: 0 }, }); ``` **Background.tsx** — full-screen Fit.Cover background ```tsx import { Fit, RiveView, useRiveFile, useViewModelInstance } from '@rive-app/react-native'; import { ReactNode } from 'react'; import { StyleSheet, View } from 'react-native'; export default function Background({ children }: { children: ReactNode }) { const { riveFile } = useRiveFile(require('./assets/rive/Background.riv')); const { instance: viewModelInstance } = useViewModelInstance(riveFile); return ( <View style={styles.container}> {riveFile && viewModelInstance && ( <RiveView file={riveFile} autoPlay fit={Fit.Cover} style={StyleSheet.absoluteFill} dataBind={viewModelInstance} /> )} {children} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#0c1027' }, }); ``` </details>
1 parent 950026c commit 8b4219d

1 file changed

Lines changed: 4 additions & 2 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
102102
}
103103

104104
fun configure(config: ViewConfiguration, dataBindingChanged: Boolean, reload: Boolean = false, initialUpdate: Boolean = false) {
105+
// https://github.com/rive-app/rive-nitro-react-native/pull/209
106+
riveAnimationView?.layoutScaleFactor = config.layoutScaleFactor
107+
?: resources.displayMetrics.density
108+
105109
if (reload) {
106110
val hasDataBinding = when (config.bindData) {
107111
is BindData.None -> false
@@ -121,8 +125,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) {
121125
} else {
122126
riveAnimationView?.alignment = config.alignment
123127
riveAnimationView?.fit = config.fit
124-
// TODO: this seems to require a reload for the view to take the new value (bug on Android)
125-
riveAnimationView?.layoutScaleFactor = config.layoutScaleFactor
126128
}
127129

128130
if (dataBindingChanged || initialUpdate || reload) {

0 commit comments

Comments
 (0)