I'm working on a React Native project where I want to create a wheel-like picker with items that fade in and out as they scroll into view. I'm using react-native-reanimated for the animations, but the opacity interpolation doesn't seem to be working as expected.
What I Have Tried: Here is my component code:
import { StyleSheet, View, Modal } from "react-native";
import React from "react";
import { LinearGradient } from "expo-linear-gradient";
import Text from "../atoms/Text";
import Animated, {
Extrapolation,
interpolate,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import Button from "../atoms/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { COLORS, DEVICE } from "../../common";
const ITEM_HEIGHT = 60;
const ITEM_WIDTH = 100;
const WHEEL_HEIGHT = ITEM_HEIGHT * 5;
const WheelModal = () => {
const { bottom } = useSafeAreaInsets();
const eventY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler((event) => {
eventY.value = event.contentOffset.y;
});
const animatedStyle = useAnimatedStyle(() => {
const activeNumber = eventY.value / ITEM_HEIGHT;
const inputRange = [
(activeNumber - 2) * ITEM_HEIGHT,
(activeNumber - 1) * ITEM_HEIGHT,
activeNumber * ITEM_HEIGHT,
(activeNumber + 1) * ITEM_HEIGHT,
(activeNumber + 2) * ITEM_HEIGHT,
];
const opacity = interpolate(
eventY.value,
inputRange,
[0.1, 0.1, 1, 0.1, 0.1],
Extrapolation.CLAMP
);
return {
opacity,
};
});
const onSelect = () => {
console.log("selected year", yearGenerator()[eventY.value / ITEM_HEIGHT]);
};
return (
<Modal transparent visible={true} animationType="fade">
<View style={styles.titleBox}>
<Text weight="bold" size="lg" tx="Choose the year" />
</View>
<LinearGradient
locations={[0.1, 0.3, 0.7, 0.9, 1]}
colors={[
"rgba(256,256,256,.6)",
"rgba(256,256,256,.95)",
"rgba(256,256,256,1)",
"rgba(256,256,256,.1)",
"rgba(256,256,256,.2)",
]}
style={styles.gradient}
>
<Animated.FlatList
bounces={false}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
onScroll={scrollHandler}
data={yearGenerator()}
decelerationRate="normal"
scrollEventThrottle={16}
snapToInterval={ITEM_HEIGHT}
style={styles.flatList}
contentContainerStyle={styles.flatListContent}
keyExtractor={(_, index) => index.toString()}
renderItem={({ item }) => (
<Animated.View style={[styles.animatedView, animatedStyle]}>
<Text size="lg" weight="medium" text={item.toString()} />
</Animated.View>
)}
/>
<View style={styles.borderView} />
</LinearGradient>
<Button
onPress={onSelect}
tx="Select"
style={[styles.selectButton, { height: 45 + bottom }]}
/>
</Modal>
);
};
const styles = StyleSheet.create({
gradient: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
titleBox: {
position: "absolute",
top: DEVICE.height / 2 - WHEEL_HEIGHT / 2 - 49,
alignSelf: "center",
zIndex: 999,
},
flatList: {
flexGrow: 0,
height: WHEEL_HEIGHT,
},
flatListContent: {
flexGrow: 1,
paddingVertical: ITEM_HEIGHT * 2,
},
animatedView: {
height: ITEM_HEIGHT,
width: ITEM_WIDTH,
justifyContent: "center",
alignItems: "center",
},
borderView: {
width: ITEM_WIDTH,
height: ITEM_HEIGHT,
position: "absolute",
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: COLORS.primary,
},
selectButton: {
position: "absolute",
bottom: 0,
alignSelf: "center",
width: "100%",
borderRadius: 0,
},
});
const yearGenerator = () => {
const year = new Date().getFullYear();
const years = [];
for (let i = 2015; i <= year; i++) {
years.push(i);
}
return years;
};
export default WheelModal;
What I Expected: I expect the opacity of the items to decrease as they move away from the center item, creating a fading effect.
What Actually Happens: The opacity animation does not work, and all items have the same opacity regardless of their position in the list.
Additional Information: React Native Version: 0.74.3 React Native Reanimated Version: 3.10.1
Question: What am I doing wrong in my animation code? How can I get the opacity interpolation to work correctly so that items fade in and out as they scroll into view?
Any help or suggestions would be greatly appreciated!
well, I found the solution
I just needed to separate the renderItem
component to separate component and use the index instead of activeNumber
That's it
Wheel Component :
import { StyleSheet, View, Modal } from "react-native";
import React from "react";
import { LinearGradient } from "expo-linear-gradient";
import Text, { TextProps } from "../../atoms/Text";
import Animated, {
useAnimatedScrollHandler,
useSharedValue,
} from "react-native-reanimated";
import Button from "../../atoms/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { COLORS, DEVICE } from "../../../common";
import RenderItem from "./RenderItem";
type PropsType = {
data: { lable: string; value: string }[];
onSelect: (value: PropsType["data"][0]) => void;
isVisible: boolean;
title: TextProps["tx"];
};
const SHOW_ITEMS = 5;
const ITEM_HEIGHT = 60;
const WHEEL_HEIGHT = ITEM_HEIGHT * SHOW_ITEMS;
const ITEM_WIDTH = 100;
const WheelModal = ({ onSelect, data, isVisible, title }: PropsType) => {
const { bottom } = useSafeAreaInsets();
const eventY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler((event) => {
eventY.value = event.contentOffset.y;
});
const _onSelect = () => {
const SelectedItem = data[eventY.value / ITEM_HEIGHT];
onSelect(SelectedItem);
};
return (
<Modal transparent visible={isVisible} animationType="fade">
<View style={styles.titleBox}>
<Text weight="bold" size="lg" tx={title} />
</View>
<LinearGradient
locations={[0.1, 0.3, 0.7, 0.9, 1]}
colors={[
"rgba(256,256,256,.6)",
"rgba(256,256,256,.95)",
"rgba(256,256,256,1)",
"rgba(256,256,256,.7)",
"rgba(256,256,256,.2)",
]}
style={styles.gradient}
>
<Animated.FlatList
bounces={false}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
onScroll={scrollHandler}
data={data ?? []}
decelerationRate="normal"
scrollEventThrottle={16}
snapToInterval={ITEM_HEIGHT}
style={styles.flatList}
contentContainerStyle={styles.flatListContent}
keyExtractor={(_, index) => index.toString()}
renderItem={(props) => <RenderItem eventY={eventY} {...props} />}
/>
<View style={styles.borderView} />
</LinearGradient>
<Button
onPress={_onSelect}
tx="Select"
style={[styles.selectButton, { height: 50 + bottom }]}
/>
</Modal>
);
};
const styles = StyleSheet.create({
gradient: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
titleBox: {
position: "absolute",
top: DEVICE.height / 2 - WHEEL_HEIGHT / 2 - 49,
alignSelf: "center",
zIndex: 999,
},
flatList: {
flexGrow: 0,
height: WHEEL_HEIGHT,
},
flatListContent: {
flexGrow: 1,
paddingVertical: ITEM_HEIGHT * Math.floor(SHOW_ITEMS / 2),
},
borderView: {
width: ITEM_WIDTH,
height: ITEM_HEIGHT,
position: "absolute",
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: COLORS.primary,
zIndex: -1,
alignSelf: "center",
top: DEVICE.height / 2 - ITEM_HEIGHT / 2,
},
selectButton: {
position: "absolute",
bottom: 0,
alignSelf: "center",
width: "100%",
borderRadius: 0,
},
});
export default WheelModal;
RenderItem Component
import { StyleSheet, View } from "react-native";
import React from "react";
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
} from "react-native-reanimated";
import Text from "../../atoms/Text";
const ITEM_HEIGHT = 60;
const ITEM_WIDTH = 100;
const RenderItem = ({ item, index, eventY }) => {
const inputRange = [
(index - 2) * ITEM_HEIGHT,
(index - 1) * ITEM_HEIGHT,
index * ITEM_HEIGHT,
(index + 1) * ITEM_HEIGHT,
(index + 2) * ITEM_HEIGHT,
];
const animatedStyle = useAnimatedStyle(() => {
const opacity = interpolate(
eventY.value,
inputRange,
[0.1, 0.3, 1, 0.3, 0.1],
Extrapolation.CLAMP
);
return {
opacity,
};
});
return (
<Animated.View style={[styles.animatedView, animatedStyle]}>
<Text size="lg" weight="medium" text={item.toString()} />
</Animated.View>
);
};
export default RenderItem;
const styles = StyleSheet.create({
animatedView: {
height: ITEM_HEIGHT,
width: ITEM_WIDTH,
justifyContent: "center",
alignItems: "center",
},
});