Skip to content

Fix Fit.LAYOUT artboard oversized on Android#209

Merged
mfazekas merged 1 commit intomainfrom
fix/android-fit-layout-scale-factor
Apr 13, 2026
Merged

Fix Fit.LAYOUT artboard oversized on Android#209
mfazekas merged 1 commit intomainfrom
fix/android-fit-layout-scale-factor

Conversation

@mfazekas
Copy link
Copy Markdown
Collaborator

@mfazekas mfazekas commented Apr 10, 2026

BEGIN_COMMIT_OVERRIDE
fix: Fit.LAYOUT artboard oversized on Android (#209)
END_COMMIT_OVERRIDE

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.

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
image image

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.

Expo Router reproducer (4 files)

app/index.tsx — home screen with navigation button

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

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

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

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' },
});

Set layoutScaleFactor to device density before setRiveFile when the user
hasn't set an explicit value. layoutScaleFactorAutomatic defaults to 1.0
and onMeasure may not fire before the render thread uses it.

Fixes #206
@mfazekas mfazekas force-pushed the fix/android-fit-layout-scale-factor branch from 5567aa0 to 0bf8e21 Compare April 10, 2026 14:25
@mfazekas mfazekas requested a review from HayesGordon April 10, 2026 15:20
@mfazekas mfazekas marked this pull request as ready for review April 10, 2026 15:20
Copy link
Copy Markdown
Contributor

@HayesGordon HayesGordon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@mfazekas mfazekas merged commit 8b4219d into main Apr 13, 2026
9 checks passed
@mfazekas mfazekas deleted the fix/android-fit-layout-scale-factor branch April 13, 2026 18:56
mfazekas pushed a commit that referenced this pull request Apr 13, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.4.2](v0.4.1...v0.4.2)
(2026-04-13)


### Bug Fixes

* Fit.LAYOUT artboard oversized on Android
([#209](#209))
([8b4219d](8b4219d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
mfazekas added a commit that referenced this pull request Apr 14, 2026
…nd (#214)

Port of #209 fix to the experimental backend. The legacy backend
defaulted `layoutScaleFactor` to `resources.displayMetrics.density` when
unset, but the experimental backend defaulted to `1f`, causing the
artboard to render at pixel dimensions instead of dp.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Layout animation is too big on Android

2 participants