Commit 8b4219d
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
105 | 109 | | |
106 | 110 | | |
107 | 111 | | |
| |||
121 | 125 | | |
122 | 126 | | |
123 | 127 | | |
124 | | - | |
125 | | - | |
126 | 128 | | |
127 | 129 | | |
128 | 130 | | |
| |||
0 commit comments