Created
August 28, 2025 13:59
-
-
Save NorseGaud/67afe1b7e249a19c7b1537d6d7080677 to your computer and use it in GitHub Desktop.
React Native Wheel Picker for Android and iOS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { memo } from 'react'; | |
import {Text, Animated, StyleSheet, useColorScheme} from 'react-native'; | |
import { ThemedText } from '../ThemedText'; | |
import { colors } from '@/constants/Colors'; | |
const AnimatedPickerItem = ({ | |
item, | |
index, | |
scrollY, | |
itemHeight, | |
fontSize, | |
fontWeight, | |
textStyle, | |
itemStyle, | |
isSelected, | |
nonSelectedTextColor, | |
}) => { | |
const colorScheme = useColorScheme() | |
const styles = StyleSheet.create({ | |
item: { | |
flex: 1, | |
alignItems: 'center', | |
justifyContent: 'center', | |
}, | |
text: { | |
// fontSize: width < 350 ? scale(15) : scale(18) , | |
textAlign: 'center', | |
}, | |
}) | |
const inputRange = [ | |
(index - 2) * itemHeight, | |
index * itemHeight, | |
(index + 2) * itemHeight, | |
]; | |
const scaleY = scrollY.interpolate({ | |
inputRange, | |
outputRange: [0.5, 1, 0.5], | |
extrapolate: 'clamp', | |
}); | |
return ( | |
<Animated.View style={[ | |
styles.item, | |
{transform: [{ scaleY }]}, | |
{height: itemHeight, ...itemStyle}, | |
isSelected && { | |
backgroundColor: (colorScheme === 'dark') ? colors.appDarkBlue : colors.appGreen, | |
paddingHorizontal: 5, | |
borderRadius: 7, | |
}, | |
]}> | |
<ThemedText style={[ | |
{fontSize, fontWeight}, | |
styles.text, | |
textStyle, | |
!isSelected && { | |
color: nonSelectedTextColor || ((colorScheme === 'dark') ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'), | |
} | |
]}>{item}</ThemedText> | |
</Animated.View> | |
); | |
}; | |
export default memo(AnimatedPickerItem) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, {useEffect, useMemo, useRef} from 'react' | |
import { View, Animated, StyleSheet, Text, ViewStyle, TextStyle, LogBox, useColorScheme } from 'react-native' | |
import AnimatedPickerItem from './AnimatedPickerItem' | |
import { isEqual } from 'lodash' | |
import { useSelector } from 'react-redux' | |
import { colors } from '@/constants/Colors' | |
import { RootState } from '@/redux' | |
import { useState } from 'react' | |
type CustomWheelPickerProps = { | |
selectedValue: string | number | |
pickerData: (string | number)[] | |
itemHeight: number | |
pickerStyle?: ViewStyle | |
itemStyle?: ViewStyle | |
textStyle?: TextStyle | |
nonSelectedTextColor?: string | |
pickerHeight?: number | |
itemPosition?: string | |
onValueChange?: (value: string) => void | |
pickerId?: string | |
scrollY?: number | |
} | |
const keyExtractor = (item: string, index: number) => `picker-item-${index}` | |
const CustomWheelPicker = ({ | |
selectedValue = '', | |
pickerData = [], | |
itemHeight = 30, | |
pickerStyle = {}, | |
itemStyle = {}, | |
textStyle = { | |
fontSize: 20, | |
fontWeight: 'normal', | |
}, | |
nonSelectedTextColor, | |
pickerHeight = 150, | |
itemPosition = '', | |
onValueChange = (value: string) => {}, | |
pickerId='default', | |
}: CustomWheelPickerProps) => { | |
const fontSize = textStyle.fontSize | |
const fontWeight = textStyle.fontWeight | |
const [scrollY] = useState(() => new Animated.Value(0)); | |
const listRef = useRef<Animated.FlatList>(null) | |
const latestScrollY = useRef(0); | |
const canMomentum = React.useRef(false) | |
const isMounted = React.useRef(true) | |
const snapOffsets = pickerData.map((_, index) => index * itemHeight) | |
const prevPickerData = useRef<(string | number)[]>([]) | |
const initialIndex = useMemo(() => pickerData.findIndex(item => item == selectedValue), [selectedValue, pickerData]) | |
const colorScheme = useColorScheme() | |
const styles = StyleSheet.create({ | |
container: { | |
marginHorizontal: 10, | |
// flex: 1, | |
// flexBasis: 63, | |
// height: 150, | |
// overflow: 'hidden', | |
// alignItems: 'center', | |
// justifyContent: 'center', | |
}, | |
selectLine: { | |
position: 'absolute', | |
top: 150/2 - 15, | |
height: 30, | |
width: '100%', | |
borderRadius: 7, | |
backgroundColor: (colorScheme === 'dark') ? colors.appDarkBlue : 'white', | |
}, | |
maskContainer: { | |
...StyleSheet.absoluteFillObject, | |
justifyContent: 'center', | |
}, | |
maskTop: { | |
flex: 1, | |
// backgroundColor: 'rgba(240,240,240,1)', | |
}, | |
maskCenter: { | |
backgroundColor: 'transparent', | |
}, | |
maskBottom: { | |
flex: 1, | |
// backgroundColor: 'rgba(240,240,240,0.6)', | |
top: 1 | |
}, | |
}) | |
useEffect(() => { | |
// console.log('firing useEffect 3') | |
if (listRef.current && pickerData?.length && isMounted.current) { | |
const index = pickerData.findIndex(item => item == selectedValue) | |
// Add try-catch to handle unmounting gracefully | |
try { | |
listRef.current?.scrollToIndex({ animated: false, index: index >= 0 ? index : 0 }); | |
} catch (error) { | |
console.warn('ScrollToIndex error (component unmounting):', error); | |
} | |
} | |
}, [pickerData]) | |
// Cleanup on unmount | |
useEffect(() => { | |
return () => { | |
isMounted.current = false; | |
}; | |
}, []) | |
const onMomentumScrollBegin = () => { | |
// console.log('onMomentumScrollBegin') | |
canMomentum.current = true; | |
}; | |
const onScroll = Animated.event( | |
[{ nativeEvent: { contentOffset: { y: scrollY } } }], | |
{ | |
useNativeDriver: false, | |
listener: (event: any) => { | |
// const newY = event.nativeEvent.contentOffset.y | |
// console.log('onScroll newY', newY) | |
// Don't process scroll events if component is unmounting | |
if (!isMounted.current) return; | |
const y = event.nativeEvent.contentOffset.y | |
latestScrollY.current = y; | |
// Handle the value change directly here if momentum scrolling is active | |
// if (canMomentum.current) { | |
// const newIndex = Math.round(y / itemHeight); | |
// if (!isNaN(newIndex) && newIndex >= -1 && newIndex < pickerData.length) { | |
// onValueChange && onValueChange(pickerData[newIndex] as string); | |
// } | |
// } | |
} | |
} | |
); | |
const onMomentumScrollEnd = () => { | |
// console.log('onMomentumScrollEnd') | |
// console.log('canMomentum.current', canMomentum.current) | |
if (canMomentum.current) { | |
const currentY = latestScrollY.current; | |
const newIndex = Math.round(currentY / itemHeight) | |
if (!isNaN(newIndex) && newIndex >= -1 && newIndex < pickerData.length) { | |
onValueChange && onValueChange(pickerData[newIndex] as string); | |
} | |
} | |
canMomentum.current = false; | |
}; | |
const getItemLayout = (data: ArrayLike<string> | null | undefined, index: number) => ({ | |
length: itemHeight, | |
offset: itemHeight * index, | |
index, | |
}); | |
const renderItem = ({ item, index, isSelected }: { item: string, index: number, isSelected: boolean }) => { | |
return ( | |
<AnimatedPickerItem | |
isSelected={isSelected} | |
item={item} | |
index={index} | |
scrollY={scrollY} | |
itemHeight={itemHeight} | |
fontSize={fontSize} | |
fontWeight={fontWeight} | |
textStyle={textStyle} | |
itemStyle={itemStyle} | |
nonSelectedTextColor={nonSelectedTextColor} | |
/> | |
); | |
}; | |
return ( | |
<View | |
style={ | |
[ | |
styles.container, | |
{ | |
height: pickerHeight, | |
}, | |
pickerStyle, | |
{ | |
// borderColor: (colorScheme === 'dark') ? colors.appDarkBlue4 : colors.appLightBlue3, | |
// borderWidth: 1, | |
}, | |
itemPosition === 'first' && {borderRightWidth: 0}, | |
itemPosition === 'last' && {borderLeftWidth: 0}, | |
!itemPosition && {borderLeftWidth: 0, borderRightWidth: 0}, | |
] | |
} | |
testID={`${pickerId}-${selectedValue}`} | |
> | |
{/* <Text>{`${pickerId}-${selectedValue}`}</Text> */} | |
<View | |
testID={`${pickerId}`} | |
style={[ | |
styles.selectLine, | |
{ | |
height: itemHeight, | |
top: (pickerHeight - itemHeight) / 2, | |
borderColor: colors.appLightBlue4, | |
}, | |
]}></View> | |
<Animated.FlatList | |
nestedScrollEnabled={true} | |
ref={listRef} | |
data={pickerData as string[]} | |
initialScrollIndex={initialIndex < 0 ? 0 : initialIndex} | |
renderItem={({ item, index }) => renderItem({ item, index, isSelected: item == selectedValue })} | |
keyExtractor={keyExtractor} | |
snapToOffsets={snapOffsets} | |
onMomentumScrollBegin={onMomentumScrollBegin} | |
onMomentumScrollEnd={onMomentumScrollEnd} | |
onScroll={onScroll} | |
getItemLayout={getItemLayout} | |
showsVerticalScrollIndicator={false} | |
decelerationRate={'fast'} | |
contentContainerStyle={{ | |
paddingTop: (pickerHeight - itemHeight) / 2, | |
paddingBottom: (pickerHeight - itemHeight) / 2, | |
}} | |
/> | |
<View style={styles.maskContainer} pointerEvents='none'> | |
<View style={[styles.maskTop]} /> | |
<View style={[styles.maskCenter, {height: itemHeight}]} /> | |
<View style={[styles.maskBottom]} /> | |
</View> | |
</View> | |
); | |
}; | |
export default CustomWheelPicker; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<CustomWheelPicker | |
selectedValue={creationDate.day} | |
pickerData={wheelPickerDaysFull} | |
onValueChange={value => onChangeCreationDate("day", value)} | |
itemHeight={30} | |
pickerHeight={100} | |
/> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment