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

WorkletEventHandler revamp #5845

Merged
merged 17 commits into from
Apr 17, 2024
Merged

Conversation

szydlovsky
Copy link
Contributor

@szydlovsky szydlovsky commented Mar 27, 2024

Summary

Our WorkletEventHandler class (used in useScrollViewOffset and useEvent hooks as well in internal logic of AnimatedComponent) was made for handling only one component at a time. I made it so multiple of them can work at the same time. This way, anything based on useEvent (so for example useAnimatedScrollHandler) can be used for multiple components without needlessly copying the logic. This fixes #5345 and #5488 as well.

Test plan

Effectively, this needs testing on useAnimatedScrollHandler and useScrollViewOffset as well as generally checking for any regression in AnimatedComponents.

Testing code for useAnimatedScrollHandler on multiple components:

Code
import * as React from 'react';
import { Text, View, Button, StyleSheet } from 'react-native';
import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated';

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>
);

export default function EmptyExample() {
  const [attachBoth, setAttachBoth] = React.useState(false);
  const [onlyOnScroll, setOnlyOnScroll] = React.useState(true);

  // props to pass into children render functions
  const onScroll = useAnimatedScrollHandler(
    onlyOnScroll
      ? {
          onScroll(e) {
            'worklet';
            console.log(e.eventName);
          },
        }
      : {
          onScroll(e) {
            'worklet';
            console.log(e.eventName);
          },
          onBeginDrag(e) {
            'worklet';
            console.log(e.eventName);
          },
          onEndDrag(e) {
            'worklet';
            console.log(e.eventName);
          },
          onMomentumBegin(e) {
            'worklet';
            console.log(e.eventName);
          },
          onMomentumEnd(e) {
            'worklet';
            console.log(e.eventName);
          },
        }
  );

  return (
    <View style={styles.container}>
      <Button
        title={`Swtich to attach ${attachBoth ? 'first' : 'both'}`}
        onPress={() => {
          setAttachBoth(!attachBoth);
        }}
      />
      <Button
        title={`Swtich events to ${
          onlyOnScroll ? 'all events' : 'only onScroll'
        }`}
        onPress={() => {
          setOnlyOnScroll(!onlyOnScroll);
        }}
      />
      <Animated.FlatList
        onScroll={onScroll}
        style={styles.listA}
        data={items}
        renderItem={({ item }) => <Item title={item.title} />}
        keyExtractor={(item) => `A:${item.title}`}
      />
      <Animated.FlatList
        onScroll={attachBoth ? onScroll : null}
        style={styles.listB}
        data={items}
        renderItem={({ item }) => <Item title={item.title} />}
        keyExtractor={(item) => `B:${item.title}`}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'column',
  },
  listA: {
    flex: 1,
    backgroundColor: '#5F9EA0',
  },
  listB: {
    flex: 1,
    backgroundColor: '#7FFFD4',
  },
  item: {
    backgroundColor: '#F0FFFF',
    alignItems: 'center',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 20,
  },
  title: {
    fontSize: 32,
  },
});

Testing code for useScrollViewOffset switching between components:

Code
import React, { useState } from 'react';
import Animated, {
  useAnimatedRef,
  useDerivedValue,
  useSharedValue,
  useScrollViewOffset,
} from 'react-native-reanimated';
import { Button, StyleSheet, Text, View } from 'react-native';

export default function ScrollViewOffsetExample() {
  const aref = useAnimatedRef<Animated.ScrollView>();
  const bref = useAnimatedRef<Animated.ScrollView>();
  const scrollHandler = useSharedValue(0);
  const [scrollAMounted, setScrollAMounted] = useState(true);
  const [scrollBMounted, setScrollBMounted] = useState(true);
  const [scrollAPassed, setScrollAPassed] = useState(true);

  useDerivedValue(() => {
    console.log(scrollHandler.value);
  });

  const onAMountPress = () => {
    setScrollAMounted(!scrollAMounted);
  };
  const onBMountPress = () => {
    setScrollBMounted(!scrollBMounted);
  };
  const onPassTogglePress = () => {
    setScrollAPassed(!scrollAPassed);
  };

  useScrollViewOffset(scrollAPassed ? aref : bref, scrollHandler);

  return (
    <>
      <View style={styles.positionView}>
        <Text>Test</Text>
      </View>
      <View style={styles.divider} />
      <Button
        title={`${scrollAMounted ? 'Dismount' : 'Mount'} scroll A`}
        onPress={onAMountPress}
      />
      <Button
        title={`${scrollBMounted ? 'Dismount' : 'Mount'} scroll B`}
        onPress={onBMountPress}
      />
      <Button
        title={`Toggle the ref, currently passed to ${
          scrollAPassed ? 'scroll A' : 'scroll B'
        }`}
        onPress={onPassTogglePress}
      />
      {scrollAMounted ? (
        <Animated.ScrollView
          ref={aref}
          style={[styles.scrollView, { backgroundColor: 'purple' }]}>
          {[...Array(100)].map((_, i) => (
            <Text key={i} style={styles.text}>
              A: {i}
            </Text>
          ))}
        </Animated.ScrollView>
      ) : null}
      {scrollBMounted ? (
        <Animated.ScrollView
          ref={bref}
          style={[styles.scrollView, { backgroundColor: 'lime' }]}>
          {[...Array(100)].map((_, i) => (
            <Text key={i} style={styles.text}>
              B: {i}
            </Text>
          ))}
        </Animated.ScrollView>
      ) : null}
    </>
  );
}

const styles = StyleSheet.create({
  positionView: {
    margin: 20,
    alignItems: 'center',
  },
  scrollView: {
    flex: 1,
    width: '100%',
  },
  text: {
    fontSize: 50,
    textAlign: 'center',
  },
  divider: {
    backgroundColor: 'black',
    height: 1,
  },
});

Testing code for multiple useAnimatedScrollHandlers and changing them on one component:

Code
import * as React from 'react';
import { Text, View, Button, StyleSheet } from 'react-native';
import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated';

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>
);

export default function ScrollViewExample() {
  const [attachScroll, setAttachScroll] = React.useState(true);
  const [attachMomentum, setAttachMomentum] = React.useState(false);
  const [changeScroll, setChangeScroll] = React.useState(false);
  const [changeMomentum, setChangeMomentum] = React.useState(false);

  const onScrollHandler = useAnimatedScrollHandler({
    onScroll(e) {
      console.log(e.eventName);
    },
  });

  const onMomentumHandler = useAnimatedScrollHandler({
    onMomentumBegin(e) {
      console.log(e.eventName);
    },
  });

  function reverseString(str: string): string {
    'worklet';
    return str.split('').reduce((reversed, char) => char + reversed, '');
  }

  const onScrollReversedHandler = useAnimatedScrollHandler({
    onScroll(e) {
      console.log(reverseString(e.eventName));
    },
  });

  const onMomentumReversedHandler = useAnimatedScrollHandler({
    onMomentumBegin(e) {
      console.log(reverseString(e.eventName));
    },
  });

  return (
    <View style={styles.container}>
      <Button
        title={`${attachScroll ? 'Detach' : 'Attach'} scroll handler`}
        onPress={() => {
          setAttachScroll(!attachScroll);
        }}
      />
      <Button
        title={`${attachMomentum ? 'Detach' : 'Attach'} momentum handler`}
        onPress={() => {
          setAttachMomentum(!attachMomentum);
        }}
      />
      <Button
        title={`${changeScroll ? 'Un-reverse' : 'Reverse'} scroll handler`}
        onPress={() => {
          setChangeScroll(!changeScroll);
        }}
      />
      <Button
        title={`${changeMomentum ? 'Un-reverse' : 'Reverse'} momentum handler`}
        onPress={() => {
          setChangeMomentum(!changeMomentum);
        }}
      />
      <Animated.FlatList
        onScroll={
          attachScroll
            ? changeScroll
              ? onScrollReversedHandler
              : onScrollHandler
            : null
        }
        onMomentumScrollBegin={
          attachMomentum
            ? changeMomentum
              ? onMomentumReversedHandler
              : onMomentumHandler
            : null
        }
        style={styles.list}
        data={items}
        renderItem={({ item }) => <Item title={item.title} />}
        keyExtractor={(item) => `A:${item.title}`}
      />
    </View>
  );
}

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

Regarding Animated Components, @Latropos currently works on some tests to make it easier.

Showcase of the last example:

scrollExample.mov

@tjzel
Copy link
Collaborator

tjzel commented Mar 27, 2024

Please don't merge until #5790 is merged.

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.

First part of the review is here - we definitely need to address components with multiple handlers.

src/reanimated2/hook/useScrollViewOffset.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/createAnimatedComponent/createAnimatedComponent.tsx Outdated Show resolved Hide resolved
src/createAnimatedComponent/createAnimatedComponent.tsx Outdated Show resolved Hide resolved
src/createAnimatedComponent/createAnimatedComponent.tsx Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
@szydlovsky szydlovsky requested a review from tjzel March 28, 2024 17:06
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
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.

Looks good now, once we handle the blocker I think it'll be mergeleable.

src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
@szydlovsky szydlovsky requested a review from tjzel April 3, 2024 16:14
src/reanimated2/hook/useScrollViewOffset.ts Outdated Show resolved Hide resolved
@szydlovsky szydlovsky requested a review from tjzel April 4, 2024 09:20
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.

Let's wait for @tomekzaw's review (one day)

Copy link
Member

@tomekzaw tomekzaw left a comment

Choose a reason for hiding this comment

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

LGTM, left a couple of suggestions but all of them are optional

src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
src/reanimated2/WorkletEventHandler.ts Outdated Show resolved Hide resolved
@szydlovsky szydlovsky added this pull request to the merge queue Apr 17, 2024
Merged via the queue into main with commit 5c7bcd1 Apr 17, 2024
7 checks passed
@szydlovsky szydlovsky deleted the @szydlovsky/WorkletEventHandler-revamp branch April 17, 2024 11:48
github-merge-queue bot pushed a commit that referenced this pull request Apr 17, 2024
## Summary

`registerEventHandler` and `unregisterEventHandler` methods in JS
Reanimated were empty so far, but used in `WorkletEventHandler`. Since
#5845
makes sure they are not used anymore, we can throw errors instead of
keeping them empty.

Note: to be merged after
#5845
gets merged.

## Test plan

:shipit:
return () => {
eventHandler.workletEventHandler.unregisterFromEvents();
if (scrollRefTag.current !== null) {
Copy link

Choose a reason for hiding this comment

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

Note generally reading a mutable reference in effect cleanup is risky because it could've changed by the time you got to cleanup (e.g. it could've been ... cleaned up). Or replaced by a newer reference. So it's a good idea to do

useEffect(() => {
  const instance = someRef.current
  doStuff(instance)
  return () => {
    undoStuff(instance)
  })
})

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, thanks for pointing out - luckily newer versions have already made it this way 😄

github-merge-queue bot pushed a commit that referenced this pull request May 14, 2024
## Summary

There have already been some requests for a way to merge our event
handlers. Inspired by
#5854, I
created a hook that can work with any handlers made using `useEvent`. I
will happily accept any comments and suggestions regarding typing since
I am not so sure about it.


https://github.com/software-mansion/react-native-reanimated/assets/77503811/9c295325-28ce-4ec2-8490-1a7431e8fafd

The PR can be merged only ater
#5845
(it is also based on it).

## Test plan

Open `useComposedEventHandler` example from Example app and watch event
callbacks in console.
github-merge-queue bot pushed a commit that referenced this pull request Jun 11, 2024
## Summary
Turns out (thanks to @j-piasecki for noticing) that animated component's
event-emitting viewTag can change through re-rendering. The following
fix takes care of that.

## Test plan

You can go through all `ComposedEventHandler` examples form Example app,
as well as code snippets in
#5845 (comment).

Also, this should work perfectly regardless of re-renders:
<details><summary>Code</summary>

``` TYPESCRIPT
import React, { useState } from 'react';
import { Button, StyleSheet, View } from 'react-native';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

function Ball(props) {
  const isPressed = useSharedValue(false);
  const offset = useSharedValue({ x: 0, y: 0 });

  const animatedStyles = useAnimatedStyle(() => {
    return {
      transform: [
        { translateX: offset.value.x },
        { translateY: offset.value.y },
        { scale: withSpring(isPressed.value ? 1.2 : 1) },
      ],
      backgroundColor: isPressed.value ? 'yellow' : 'blue',
    };
  });

  const gesture = Gesture.Pan()
    .onBegin(() => {
      'worklet';
      isPressed.value = true;
    })
    .onChange((e) => {
      'worklet';
      offset.value = {
        x: e.changeX + offset.value.x,
        y: e.changeY + offset.value.y,
      };
    })
    .onFinalize(() => {
      'worklet';
      isPressed.value = false;
    });

  return (
    <GestureDetector gesture={gesture}>
      <Animated.View style={[styles.ball, animatedStyles]} key={props.counter} />
    </GestureDetector>
  );
}

export default function Example() {
  const [counter, setCounter] = useState(0);

  return (
    <View style={styles.container}>
      <Button title="Remount" onPress={() => setCounter((prev) => prev + 1)} />
      <Ball counter={counter} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  ball: {
    width: 100,
    height: 100,
    borderRadius: 100,
    backgroundColor: 'blue',
    alignSelf: 'center',
  },
});
```
</details>
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.

Passing the same useAnimatedScrollHandler to different scroll views breaks
5 participants