react-native-reanimated icon indicating copy to clipboard operation
react-native-reanimated copied to clipboard

[iOS][Old Arch] Layout animations fail when component with `useAnimatedScrollHandlers` remounts.

Open tpcstld opened this issue 9 months ago • 0 comments

Description

Wild title. Please bear with me.

In the follow video I have a small black box which expands and shrinks. This is implemented as a layout animation (i.e. the layout prop in an animated view). When the background is red, the box should be small. When the background is blue, the box should be tall.

The layout animation works fine until I add a ScrollView with a useAnimatedScrollHandler and initial non-zero contentOffset. If the ScrollView remounts as the layout animation is playing, it seems to sometimes "pause" or "stop" the layout animation. I must then wait some time before the layout animation is responsive again.

https://github.com/user-attachments/assets/8c192045-5b50-46e7-920b-af9ffda2fd8c

Here are some of the insights that I can gleam:

  1. This issue only happens on Paper. I cannot repro on Fabric.
  2. useAnimatedScrollHandler leverages the private WorkletEventHandler API. If I hack the library to call WorkletEventHandler directly, the same issue occurs.
  3. The remount seems important. Triggering a normal scroll event does seems to repro.
  4. Passing in {x: 0, y: 0} to the ScrollView's contentOffset does not trigger the bug.
  5. The onScroll handler can be empty.
  6. The ScrollView contents can be empty.

Code:

import * as React from 'react';
import {Button, SafeAreaView, ScrollView, View} from 'react-native';
import Reanimated, {
  LayoutAnimation,
  LayoutAnimationsValues,
  useAnimatedScrollHandler,
  withSpring,
} from 'react-native-reanimated';

const AnimatedScrollView = Reanimated.createAnimatedComponent(ScrollView);

const BAR_SPRING_PHYSICS = {
  mass: 10,
  damping: 100,
  stiffness: 200,
};

interface ThingProps {
  selected: boolean;
}

function Thing({selected}: ThingProps) {
  const height = selected ? 40 : 8;
  const style = React.useMemo(
    () => ({
      backgroundColor: 'black',
      width: 50,
      height,
      marginTop: 50 + (height / 2) * -1,
    }),
    [height],
  );
  const layout = React.useCallback(
    (values: LayoutAnimationsValues): LayoutAnimation => {
      'worklet';
      return {
        animations: {
          originY: withSpring(values.targetOriginY, BAR_SPRING_PHYSICS),
          originX: withSpring(values.targetOriginX, BAR_SPRING_PHYSICS),
          height: withSpring(values.targetHeight, BAR_SPRING_PHYSICS),
        },
        initialValues: {
          height: values.currentHeight,
          originY: values.currentOriginY,
          originX: values.currentOriginX,
        },
      };
    },
    [],
  );

  return (
    <View
      style={{
        backgroundColor: selected ? 'blue' : 'red',
        width: '100%',
        height: 100,
      }}>
      <Reanimated.View style={style} layout={layout} />
    </View>
  );
}

function WeirdScrollView({foo}: {foo?: string}) {
  const handlers = useAnimatedScrollHandler({
    onScroll: () => {
      'worklet';
    },
  });

  const ref = React.useRef(null);

  React.useEffect(() => {
    ref.current.scrollTo({
      x: 0,
      y: 234,
    });
  }, [foo]);

  return (
    <AnimatedScrollView
      ref={ref}
      contentOffset={{x: 0, y: 234}}
      onScroll={handlers}>
      <View style={{backgroundColor: 'black', width: 10, height: 2000}} />
    </AnimatedScrollView>
  );
}

const uiManager = global?.nativeFabricUIManager ? 'Fabric' : 'Paper';

export default function HomeScreen() {
  const [selected, setSelected] = React.useState(false);

  function toggle() {
    setSelected(prev => !prev);
  }

  // Pass key to WeirdScrollView: breaks
  // Pass foo to WeirdScrollView: works
  return (
    <SafeAreaView>
      <Button onPress={toggle} title={`Toggle (${uiManager})`} />
      <Thing selected={selected} />
      <WeirdScrollView key={selected ? '1' : '2'} />
    </SafeAreaView>
  );
}

Steps to reproduce

  1. Check out my repo or paste the code somewhere.
  2. Make sure you're on Paper and not Fabric.
  3. Click toggle twice.

You should see the box stuck in a middle transition state.

Snack or a link to a repository

https://github.com/tpcstld/rn-repro/tree/layout-bug

Reanimated version

3.17.5

React Native version

0.79.2

Platforms

iOS

JavaScript runtime

Hermes

Workflow

React Native

Architecture

Paper (Old Architecture)

Build type

Debug app & dev bundle

Device

iOS simulator

Host machine

macOS

Device model

No response

Acknowledgements

Yes

tpcstld avatar May 14 '25 02:05 tpcstld