Skip to content

Instantly share code, notes, and snippets.

@NorseGaud
Created August 28, 2025 13:59
Show Gist options
  • Save NorseGaud/67afe1b7e249a19c7b1537d6d7080677 to your computer and use it in GitHub Desktop.
Save NorseGaud/67afe1b7e249a19c7b1537d6d7080677 to your computer and use it in GitHub Desktop.
React Native Wheel Picker for Android and iOS
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)
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;
<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