react-native-reanimated
react-native-reanimated copied to clipboard
[Events Handling] Make JS handlers mergeable along Worklet handlers
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,
},
});