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

[Events Handling] Make JS handlers mergeable along Worklet handlers

Open szydlovsky opened this issue 7 months ago • 0 comments

Summary

As per a request by @kirillzyusko https://github.com/software-mansion/react-native-reanimated/issues/6204, I decided to take up the feature of using both Worklet and JS handlers at once.

API

It can be done through useComposedEventHandler hook with a following API:

const composedHandler = useComposedEventHandler([ // <- array of either Worklet or JS handlers
    onScrollWorkletHandler1,
    onScrollWorkletHandler2,
    {
      onScroll: (_e) => {
        console.log('[JS] scroll');
      },
    },
    {
      onEndDrag: (_e) => {
        console.log('[JS] drag end');
      },
    },
  ]);

How it works?

The JS handlers passed to useComposedEventHandler are getting packed into a Record object and passed down the stream, through useEvent hook all the way to WorkletEventHandler object. There, they work in a different way depending on the platfrom. On mobile platforms, they are just kept there as a public field and then, the PropFilter sets them on the corrseponding props, voila. On web, WorkletEventHandler merges JS and Worklet handlers into its listeners if they respond to the same event. Then, the PropFilter sets these listeners to the correct props. If the useComposedEventHandler gets no JS handlers as an argument, the old logic is kept.

Limitations

The feature currently supports only scroll-based events for JS handlers (onScroll, onBeginDrag, onEndDrag, onMomentumBegin, onMomentumEnd).

TODOS:

  • [x] Working PoC
  • [x] Make argument a single array of union type (both JS and Worklet handlers)
  • [x] Add proper changes detection in JS handlers
  • [x] Test on Web
  • [x] Test on iOS Paper
  • [x] Test on iOS Fabric
  • [x] Test on Android Paper
  • [x] Test on Android Fabric
  • [ ] Check for regressions

Test plan

Currently, I use a bit altered useComposedEventHandler examples to test it:

Conditional merge code:
import React, { useCallback } from 'react';
import { Text, View, StyleSheet, Button } from 'react-native';
import Animated, {
  useAnimatedScrollHandler,
  useComposedEventHandler,
} from 'react-native-reanimated';

export default function ComposedHandlerConditionalExample() {
  const [toggleFirst, setToggleFirst] = React.useState(true);
  const [toggleSecond, setToggleSecond] = React.useState(true);
  const [toggleThird, setToggleThird] = React.useState(true);

  const handlerFunc = React.useCallback(
    (handlerName: string, eventName: string) => {
      'worklet';
      console.log(`${handlerName} handler: ${eventName}`);
    },
    []
  );

  const firstHandler = useAnimatedScrollHandler({
    onScroll(e) {
      handlerFunc('first', e.eventName);
    },
  });

  const secondHandler = useAnimatedScrollHandler({
    onScroll(e) {
      handlerFunc('second', e.eventName);
    },
  });

  const thirdHandler = useAnimatedScrollHandler({
    onScroll(e) {
      handlerFunc('third', e.eventName);
    },
  });

  const JSHandlerFunc1 = useCallback((event: any) => {
    console.log(`JS1 ${event.nativeEvent.contentOffset.y}`);
  }, []);

  const JSHandlerFunc2 = useCallback((event: any) => {
    console.log(`JS2 ${event.nativeEvent.contentOffset.y}`);
  }, []);

  const JSHandlerFunc3 = useCallback((event: any) => {
    console.log(`JS3 ${event.nativeEvent.contentOffset.y}`);
  }, []);

  const JS1 = {
    onScroll: JSHandlerFunc1,
  };

  const JS2 = {
    onScroll: JSHandlerFunc2,
  };

  const JS3 = {
    onScroll: JSHandlerFunc3,
  };

  const composedHandler = useComposedEventHandler([
    toggleFirst ? JS1 : JS2,
    toggleSecond ? firstHandler : secondHandler,
    toggleThird ? thirdHandler : JS3,
  ]);

  return (
    <View style={styles.container}>
      <Text style={styles.infoText}>Check console logs!</Text>
      <ToggleButton
        name={'first'}
        isToggled={toggleFirst}
        onPressFunc={() => setToggleFirst(!toggleFirst)}
      />
      <ToggleButton
        name={'second'}
        isToggled={toggleSecond}
        onPressFunc={() => setToggleSecond(!toggleSecond)}
      />
      <ToggleButton
        name={'third'}
        isToggled={toggleThird}
        onPressFunc={() => setToggleThird(!toggleThird)}
      />
      <Animated.FlatList
        onScroll={composedHandler}
        style={styles.list}
        data={items}
        renderItem={({ item }) => <Item title={item.title} />}
        keyExtractor={(item) => `A:${item.title}`}
      />
    </View>
  );
}

type ToggleProps = {
  name: string;
  isToggled: boolean;
  onPressFunc: () => void;
};
const ToggleButton = ({ name, isToggled, onPressFunc }: ToggleProps) => (
  <View style={styles.toggleContainer}>
    <View
      style={[
        styles.toggleIcon,
        isToggled ? styles.toggleON : styles.toggleOFF,
      ]}
    />
    <Button
      title={`Toggle ${name} handler ${isToggled ? 'OFF' : 'ON'}`}
      onPress={onPressFunc}
    />
  </View>
);

type ItemValue = { title: string };
const items: ItemValue[] = [...new Array(101)].map((_each, index) => {
  return { title: `${index}` };
});

type ItemProps = { title: string };
const Item = ({ title }: ItemProps) => (
  <View style={styles.item}>
    <Text style={styles.title}>{title}</Text>
  </View>
);

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'column',
  },
  infoText: {
    fontSize: 19,
    alignSelf: 'center',
  },
  list: {
    flex: 1,
  },
  item: {
    backgroundColor: '#883ef0',
    alignItems: 'center',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 20,
  },
  title: {
    fontSize: 32,
  },
  toggleContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
  },
  toggleIcon: {
    width: 20,
    height: 20,
    borderRadius: 10,
    borderWidth: 3,
    borderColor: 'black',
  },
  toggleON: {
    backgroundColor: '#7CFC00',
  },
  toggleOFF: {
    backgroundColor: 'red',
  },
});
Different events merge:
import React from 'react';
import { Text, View, StyleSheet } from 'react-native';
import Animated, {
  useAnimatedScrollHandler,
  useComposedEventHandler,
} from 'react-native-reanimated';

export default function ComposedHandlerDifferentEventsExample() {
  const handlerFunc = React.useCallback(
    (handlerName: string, eventName: string) => {
      'worklet';
      console.log(`${handlerName} handler: ${eventName}`);
    },
    []
  );

  const onScrollHandler = useAnimatedScrollHandler({
    onScroll(e) {
      handlerFunc('scroll', e.eventName);
    },
  });

  const onDragHandler = useAnimatedScrollHandler({
    onBeginDrag(e) {
      handlerFunc('drag', e.eventName);
    },
    onEndDrag(e) {
      handlerFunc('drag', e.eventName);
    },
  });

  const onMomentumHandler = useAnimatedScrollHandler({
    onMomentumBegin(e) {
      handlerFunc('momentum', e.eventName);
    },
    onMomentumEnd(e) {
      handlerFunc('momentum', e.eventName);
    },
  });

  const composedHandler = useComposedEventHandler([
    onScrollHandler,
    onDragHandler,
    onMomentumHandler,
    {
      onScroll: (event) => {
        console.log(`[JS] ${event.nativeEvent.contentOffset.y}`);
      },
    },
  ]);

  return (
    <View style={styles.container}>
      <Text style={styles.infoText}>Check console logs!</Text>
      <Animated.FlatList
        onScroll={composedHandler}
        style={styles.list}
        data={items}
        renderItem={({ item }) => <Item title={item.title} />}
        keyExtractor={(item) => `A:${item.title}`}
      />
    </View>
  );
}

type ItemValue = { title: string };
const items: ItemValue[] = [...new Array(101)].map((_each, index) => {
  return { title: `${index}` };
});

type ItemProps = { title: string };
const Item = ({ title }: ItemProps) => (
  <View style={styles.item}>
    <Text style={styles.title}>{title}</Text>
  </View>
);

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'column',
  },
  infoText: {
    fontSize: 19,
    alignSelf: 'center',
  },
  list: {
    flex: 1,
  },
  item: {
    backgroundColor: '#66e3c0',
    alignItems: 'center',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 20,
  },
  title: {
    fontSize: 32,
  },
});

Component's internal merge:
import React from 'react';
import { Text, View, StyleSheet } from 'react-native';
import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
import type { EventHandlerProcessed } from 'react-native-reanimated';
import Animated, {
  interpolateColor,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useComposedEventHandler,
  useSharedValue,
} from 'react-native-reanimated';

export default function ComposedHandlerInternalMergingExample() {
  const sv = useSharedValue(0);

  const onScrollHandler = useAnimatedScrollHandler({
    onScroll(e) {
      'worklet';
      sv.value = sv.value + 1;
      console.log(`scroll handler: ${e.eventName} ${sv.value}`);
    },
  });

  return (
    <View style={styles.container}>
      <Text style={styles.infoText}>Check console logs!</Text>
      <ColorChangingList additionalHandler={onScrollHandler} />
    </View>
  );
}

const ColorChangingList = ({
  additionalHandler,
}: {
  additionalHandler: EventHandlerProcessed<
    NativeSyntheticEvent<NativeScrollEvent>
  >;
}) => {
  const offsetSv = useSharedValue(0);

  const internalHandler = useAnimatedScrollHandler({
    onScroll(e) {
      const scaledValue = e.contentOffset.y % 600;
      offsetSv.value = scaledValue <= 300 ? scaledValue : 600 - scaledValue;
    },
  });

  const animatedStyle = useAnimatedStyle(() => {
    return {
      backgroundColor: interpolateColor(
        offsetSv.value,
        [0, 300],
        ['blue', 'purple']
      ),
    };
  });

  const composedHandler = useComposedEventHandler([
    internalHandler,
    additionalHandler,
    {
      onScroll: (event) => {
        console.log(`[JS] ${event.nativeEvent.contentSize.height}`);
      },
    },
  ]);

  return (
    <Animated.ScrollView
      onScroll={composedHandler}
      style={[styles.list, animatedStyle]}>
      {[...Array(100)].map((_, num: number) => (
        <View key={`${num}`} style={styles.item}>
          <Text>{`${num}`}</Text>
        </View>
      ))}
    </Animated.ScrollView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'column',
  },
  infoText: {
    fontSize: 19,
    alignSelf: 'center',
  },
  list: {
    flex: 1,
  },
  item: {
    backgroundColor: 'white',
    alignItems: 'center',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 20,
  },
});

szydlovsky avatar Jul 15 '24 14:07 szydlovsky