Реализация вертикального скролла для выбора времени в React Native
Пытаюсь реализовать свой компонет Time picker, и столкнулся с проблемой, что активный элемент после скрола меняет свой цвет с задержкой:
(как видно на скриншоте активный элемент изменен на 55, а цвет остается на предыдущем элементе еще где-то 0.5-0.7 с.)
Есть ли какой-то способ избежать этого?
Вот мой код:
import React, { useState, useRef, useEffect } from 'react';
import {
View,
FlatList,
StyleSheet,
Text,
NativeSyntheticEvent,
NativeScrollEvent
} from 'react-native';
import { CustomModal } from '@shared/ui';
import { COLORS } from '@shared/styles';
const ITEM_HEIGHT = 40;
const VISIBLE_ITEMS = 5;
interface TimePickerModalProps {
visible: boolean;
onClose: () => void;
onConfirm: (hours: string, minutes: string) => void;
}
const generateItems = (repeatCount: number, maxValue: number) => {
const items = Array.from({ length: maxValue }, (_, i) => i.toString().padStart(2, '0'));
return Array(repeatCount).fill(items).flat();
};
const REPEAT_COUNT = 5;
const hoursList = generateItems(REPEAT_COUNT, 24);
const minutesList = generateItems(REPEAT_COUNT, 60);
const TimePickerModal: React.FC<TimePickerModalProps> = ({
visible,
onClose,
onConfirm,
}) => {
const [hours, setHours] = useState('07');
const [minutes, setMinutes] = useState('30');
const hoursRef = useRef<FlatList>(null);
const minutesRef = useRef<FlatList>(null);
const centerIndex = Math.floor(REPEAT_COUNT / 2);
const scrollToInitial = () => {
const hourIndex = centerIndex * 24 + parseInt(hours, 10);
const minuteIndex = centerIndex * 60 + parseInt(minutes, 10);
hoursRef.current?.scrollToIndex({ index: hourIndex, animated: false });
minutesRef.current?.scrollToIndex({ index: minuteIndex, animated: false });
};
useEffect(() => {
if (visible) {
setTimeout(scrollToInitial, 0);
}
}, [visible]);
const handleScroll = (
list: 'hours' | 'minutes',
e: NativeSyntheticEvent<NativeScrollEvent>,
max: number
) => {
const y = e.nativeEvent.contentOffset.y;
const index = Math.round(y / ITEM_HEIGHT) % max;
const value = index.toString().padStart(2, '0');
list === 'hours' ? setHours(value) : setMinutes(value);
};
const renderItem = (
value: string,
currentValue: string
) => {
const isActive = value === currentValue;
return (
<View style={styles.pickerItem}>
<Text style={[styles.pickerItemText, isActive && styles.pickerItemTextSelected]}>
{value}
</Text>
</View>
);
};
const handleConfirm = () => {
onConfirm(hours, minutes);
};
return (
<CustomModal
visible={visible}
onClose={onClose}
onConfirm={handleConfirm}
defaultButtons={true}
>
<View style={styles.timeContainer}>
<View style={styles.staticSelector} />
<View style={styles.pickerContainer}>
<FlatList
ref={hoursRef}
data={hoursList}
keyExtractor={(item, index) => `h-${index}`}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
showsVerticalScrollIndicator={false}
onMomentumScrollEnd={(e) => handleScroll('hours', e, 24)}
snapToInterval={ITEM_HEIGHT}
decelerationRate="fast"
style={styles.picker}
contentContainerStyle={styles.pickerContent}
renderItem={({ item }) => renderItem(item, hours)}
/>
<Text style={styles.timeLabel}>h</Text>
</View>
<View style={styles.pickerContainer}>
<FlatList
ref={minutesRef}
data={minutesList}
keyExtractor={(item, index) => `m-${index}`}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
showsVerticalScrollIndicator={false}
onMomentumScrollEnd={(e) => handleScroll('minutes', e, 60)}
snapToInterval={ITEM_HEIGHT}
decelerationRate="fast"
style={styles.picker}
contentContainerStyle={styles.pickerContent}
renderItem={({ item }) => renderItem(item, minutes)}
/>
<Text style={styles.timeLabel}>m</Text>
</View>
</View>
</CustomModal>
);
};
const styles = StyleSheet.create({
timeContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 15,
height: ITEM_HEIGHT * VISIBLE_ITEMS,
position: 'relative',
},
pickerContainer: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 15,
height: ITEM_HEIGHT * VISIBLE_ITEMS,
overflow: 'hidden',
},
picker: {
height: ITEM_HEIGHT * VISIBLE_ITEMS,
width: 60,
},
pickerContent: {
paddingTop: ITEM_HEIGHT * 2,
paddingBottom: ITEM_HEIGHT * 2,
},
pickerItem: {
height: ITEM_HEIGHT,
justifyContent: 'center',
alignItems: 'center',
},
pickerItemText: {
fontSize: 18,
color: '#8A8BB3',
fontFamily: 'HindSiliguri-400',
},
pickerItemTextSelected: {
fontSize: 20,
fontWeight: 'bold',
color: '#4A4AFF',
fontFamily: 'HindSiliguri-600',
},
timeLabel: {
fontSize: 20,
color: '#4A4AFF',
fontFamily: 'HindSiliguri-700',
},
staticSelector: {
position: 'absolute',
top: ITEM_HEIGHT * Math.floor(VISIBLE_ITEMS / 2),
left: 0,
right: 0,
height: ITEM_HEIGHT,
backgroundColor: '#F0F0F0',
borderRadius: 8,
zIndex: 0,
},
});
export { TimePickerModal };
