import React, { useEffect, useMemo, useRef, useState, memo } from 'react'; import { StyleProp, TextStyle, NativeSyntheticEvent, NativeScrollEvent, Animated, ViewStyle, View, ViewProps, FlatListProps, FlatList, Platform, } from 'react-native'; import styles from './wheel-picker.style'; import WheelPickerItem from './wheel-picker-item'; import { PickerOption } from '../../../types'; interface Props { value: number | string; options: PickerOption[]; onChange: (index: number | string) => void; selectedIndicatorStyle?: StyleProp; itemTextStyle?: TextStyle; itemTextClassName?: string; itemStyle?: ViewStyle; selectedIndicatorClassName?: string; itemHeight?: number; containerStyle?: ViewStyle; containerProps?: Omit; scaleFunction?: (x: number) => number; rotationFunction?: (x: number) => number; opacityFunction?: (x: number) => number; visibleRest?: number; decelerationRate?: 'normal' | 'fast' | number; flatListProps?: Omit, 'data' | 'renderItem'>; } const WheelPicker: React.FC = ({ value, options, onChange, selectedIndicatorStyle = {}, containerStyle = {}, itemStyle = {}, itemTextStyle = {}, selectedIndicatorClassName = '', itemTextClassName = '', itemHeight = 40, scaleFunction = (x: number) => 1.0 ** x, rotationFunction = (x: number) => 1 - Math.pow(1 / 2, x), opacityFunction = (x: number) => Math.pow(1 / 3, x), visibleRest = 2, decelerationRate = 'normal', containerProps = {}, flatListProps = {}, }) => { const hasMountedRef = useRef(false); const isScrollingProgrammaticallyRef = useRef(false); const momentumStarted = useRef(false); const selectedIndex = options.findIndex((item) => item.value === value); // If value not found in options, default to first option const safeSelectedIndex = selectedIndex >= 0 ? selectedIndex : 0; const flatListRef = useRef(null); const [scrollY] = useState(new Animated.Value(safeSelectedIndex * itemHeight)); const containerHeight = (1 + visibleRest * 2) * itemHeight; const paddedOptions = useMemo(() => { const array: (PickerOption | null)[] = [...options]; for (let i = 0; i < visibleRest; i++) { array.unshift(null); array.push(null); } return array; }, [options, visibleRest]); const offsets = useMemo( () => [...Array(paddedOptions.length)].map((_, i) => i * itemHeight), [paddedOptions, itemHeight] ); const currentScrollIndex = useMemo( () => Animated.add(Animated.divide(scrollY, itemHeight), visibleRest), [visibleRest, scrollY, itemHeight] ); const handleScrollEnd = (event: NativeSyntheticEvent) => { // Ignore scroll events from programmatic scrollToIndex calls if (isScrollingProgrammaticallyRef.current) { return; } const offsetY = Math.min( itemHeight * (options.length - 1), Math.max(event.nativeEvent.contentOffset.y, 0) ); let index = Math.floor(offsetY / itemHeight); const remainder = offsetY % itemHeight; if (remainder > itemHeight / 2) { index++; } // Fire onChange when index changes if (index !== safeSelectedIndex) { onChange(options[index]?.value ?? 0); } }; const handleMomentumScrollBegin = () => { momentumStarted.current = true; }; const handleMomentumScrollEnd = ( event: NativeSyntheticEvent ) => { momentumStarted.current = false; handleScrollEnd(event); }; const handleScrollEndDrag = ( event: NativeSyntheticEvent ) => { // Capture the offset value immediately const offsetY = event.nativeEvent.contentOffset?.y; // We'll start a short timer to see if momentum scroll begins setTimeout(() => { // If momentum scroll hasn't started within the timeout, // then it was a slow scroll that won't trigger momentum if (!momentumStarted.current && offsetY !== undefined) { // Create a synthetic event with just the data we need const syntheticEvent = { nativeEvent: { contentOffset: { y: offsetY }, }, }; handleScrollEnd(syntheticEvent as any); } }, 50); }; useEffect(() => { if (options.length === 0) { return; // No options available, skip validation } if (safeSelectedIndex < 0 || safeSelectedIndex >= options.length) { throw new Error( `Selected index ${safeSelectedIndex} is out of bounds [0, ${ options.length - 1 }]` ); } }, [safeSelectedIndex, options]); /** * If selectedIndex is changed from outside (not via onChange) we need to scroll to the specified index. * This ensures that what the user sees as selected in the picker always corresponds to the value state. */ useEffect(() => { if (!hasMountedRef.current) { hasMountedRef.current = true; } // Skip if no options available if (options.length === 0) { return; } // Set flag to ignore scroll events from this programmatic scroll isScrollingProgrammaticallyRef.current = true; flatListRef.current?.scrollToIndex({ index: safeSelectedIndex, animated: Platform.OS === 'ios', }); // Clear the flag after a delay to allow the scroll to complete // Use a longer timeout for Android since animations might be different const timeout = Platform.OS === 'ios' ? 100 : 300; setTimeout(() => { isScrollingProgrammaticallyRef.current = false; }, timeout); }, [safeSelectedIndex, itemHeight, options.length]); return ( ({ length: itemHeight, offset: itemHeight * index, index, })} data={paddedOptions} keyExtractor={(item, index) => item ? `${item.value}-${item.text}-${index}` : `null-${index}` } renderItem={({ item: option, index }) => ( )} /> ); }; export default memo(WheelPicker);