Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Events Handling] Make JS handlers mergeable along Worklet handlers #6268

Open
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

szydlovsky
Copy link
Contributor

@szydlovsky szydlovsky commented Jul 15, 2024

Summary

As per a request by @kirillzyusko #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:

  • Working PoC
  • Make argument a single array of union type (both JS and Worklet handlers)
  • Add proper changes detection in JS handlers
  • Test on Web
  • Test on iOS Paper
  • Test on iOS Fabric
  • Test on Android Paper
  • 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 szydlovsky marked this pull request as ready for review July 18, 2024 15:53
Comment on lines 94 to 110
const JSHandlers: JSHandlersObject<Event>[] = handlers
.filter((h) => h !== null)
.filter((h) => isJSHandler(h)) as JSHandlersObject<Event>[];

// Update/initialize the ref and determine whether JS handlers need rebuild or not
if (JSHandlersRef.current === null) {
JSHandlersRef.current = JSHandlers;
} else {
JSHandlersNeedRebuild = !areJSHandlersEqual(
JSHandlersRef.current,
JSHandlers
);
JSHandlersRef.current = JSHandlers;
}

// Setup the JSHandlersRecord object
if (JSHandlers.length > 0) {
Copy link
Member

Choose a reason for hiding this comment

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

Could you move that whole logic related to JS handler to another function?

Comment on lines 187 to 192
type JSScrollEventsInput =
| 'onScroll'
| 'onBeginDrag'
| 'onEndDrag'
| 'onMomentumBegin'
| 'onMomentumEnd';
Copy link
Member

Choose a reason for hiding this comment

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

Are these all the possible types of JS events? 🤔 Why are we only focusing on those events?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is currently the only use-case needed for the upgrade (as requested). Thanks to this we can anyhow type the argument. Problem also lies in the fact that some events need different names to pass down to proper event handlers (for example, onBeginDrag needs to be translated to onScrollBeginDrag). I am yet to find a way to include all sensible events

JSHandlersRef.current,
JSHandlers
);
JSHandlersRef.current = JSHandlers;
Copy link
Member

Choose a reason for hiding this comment

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

Can we do it here? Is it allowed by the rules of hook?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In general, this hook doesn't comply to rules of hooks in the first place, but I believe it is a colossal task to make it compliant. It needs to calculate some thinks during render, because WorkletEventHandler class applies stuff before it. Making it rules-of-hooks-friendly seems like a good idea as a refactor at some point..

if (JSHandlersRef.current === null) {
JSHandlersRef.current = JSHandlers;
} else {
JSHandlersNeedRebuild = !areJSHandlersEqual(
Copy link
Member

Choose a reason for hiding this comment

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

Is it possible to rely on useEffect and the dependency array instead of implementing it on your own?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was trying to do that and failed with at least 3-4 different approaches. As mentioned earlier, we need its results before the render because of the way our event handling works in general (the logic of PropsFilter and WorkletEventHandler + createAnimatedComponent)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And since it is an advanced hook and not many people are gonna use it, it doesn't need some super-tuned performance upgrades. For now it calculates everything it needs from scratch on every render and I didn't notice any slowdown caused by it

Comment on lines 86 to +87
} else {
props[key] = dummyListener;
if (handler.eventNames.length > 0) {
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure about this part of the code. It seems like if there is any JSHandler, we ignore all worklet event handlers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No we don't! They don't need to be attached anywhere. The only logic concerning worklet event handlers was putting dummyListeners in places for their respected props. Worklet event handlers are registered in native modules and don't need props to work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And if we have JS handlers, we can't always just put dummyListeners in the props because some JS handlers may need to use that. This is why there are 2 different approaches depending on JS handlers presence

const isWebHandler = has('listeners', handler);
const hasJSHandlers = Object.keys(handler.JSHandlers).length > 0;

if (hasJSHandlers) {
Copy link
Member

Choose a reason for hiding this comment

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

Does this mean that a worklet event handler can also be a JS event handler? 🤔

Copy link
Contributor Author

@szydlovsky szydlovsky Jul 22, 2024

Choose a reason for hiding this comment

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

Nope, they are separate

Copy link
Collaborator

@tjzel tjzel left a comment

Choose a reason for hiding this comment

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

We need to rework that API, instead of adding arguments let's allow composed structures as in my suggestion. Let's also stick to the names:

  • workletHandler
  • reactHandler

In all contexts just handler should imply a workletHandler

Comment on lines 53 to 54
rebuild = false,
JSHandlers: Record<string, JSHandler<Event>> = {}
Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems a bit ill structured to have JSHandlers as the last argument. I'd suggest to change the API and relevant names to:

const handlers = {
  onA: () => {}, // worklet handler
  onB: {
    workletHandler: () => {}, // worklet handler
    reactHandler: () => {}, // react handler AKA JS handler
  }
}

This way we'll have necessary backwards compatibility with the old API. This would require a slight change in the Reanimated Babel Plugin, but that's not a problem.

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.

An ability to have JS handler along with animated handler simultaneously
3 participants