import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { Ionicons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; import dayjs from 'dayjs'; import React, { useEffect, useRef, useState } from 'react'; import { Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; export interface DateSelectorProps { /** 当前选中的日期索引 */ selectedIndex?: number; /** 日期选择回调 */ onDateSelect?: (index: number, date: Date) => void; /** 是否显示月份标题 */ showMonthTitle?: boolean; /** 自定义月份标题 */ monthTitle?: string; /** 是否禁用未来日期 */ disableFutureDates?: boolean; /** 自定义样式 */ style?: any; /** 容器样式 */ containerStyle?: any; /** 日期项样式 */ dayItemStyle?: any; /** 是否自动滚动到选中项 */ autoScrollToSelected?: boolean; /** 是否显示日历图标 */ showCalendarIcon?: boolean; } export const DateSelector: React.FC = ({ selectedIndex: externalSelectedIndex, onDateSelect, showMonthTitle = true, monthTitle: externalMonthTitle, disableFutureDates = true, style, containerStyle, dayItemStyle, autoScrollToSelected = true, showCalendarIcon = true, }) => { // 内部状态管理 const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth()); const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份 const selectedIndex = externalSelectedIndex ?? internalSelectedIndex; // 获取日期数据 const days = getMonthDaysZh(currentMonth); const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth); // 滚动相关 const daysScrollRef = useRef(null); const [scrollWidth, setScrollWidth] = useState(0); const DAY_PILL_WIDTH = 48; const DAY_PILL_SPACING = 8; // 日历弹窗相关 const [datePickerVisible, setDatePickerVisible] = useState(false); const [pickerDate, setPickerDate] = useState(new Date()); // 滚动到指定索引 const scrollToIndex = (index: number, animated = true) => { if (!daysScrollRef.current || scrollWidth === 0) return; const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING; const baseOffset = index * itemWidth; const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2)); // 确保不会滚动超出边界 const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth); const finalOffset = Math.min(centerOffset, maxScrollOffset); if (animated) { // 使用原生动画API实现更平滑的滚动 requestAnimationFrame(() => { daysScrollRef.current?.scrollTo({ x: finalOffset, animated: true }); }); } else { // 非动画情况直接跳转 daysScrollRef.current?.scrollTo({ x: finalOffset, animated: false }); } }; // 初始化时滚动到选中项 useEffect(() => { if (scrollWidth > 0 && autoScrollToSelected) { scrollToIndex(selectedIndex, true); } }, [scrollWidth, selectedIndex, autoScrollToSelected]); // 当选中索引变化时,滚动到对应位置 useEffect(() => { if (scrollWidth > 0 && autoScrollToSelected) { // 添加微小延迟以确保动画效果更明显 const timer = setTimeout(() => { scrollToIndex(selectedIndex, true); }, 50); return () => clearTimeout(timer); } }, [selectedIndex, autoScrollToSelected]); // 当月份变化时,重新滚动到选中位置 useEffect(() => { if (scrollWidth > 0 && autoScrollToSelected) { const timer = setTimeout(() => { scrollToIndex(selectedIndex, true); }, 100); return () => clearTimeout(timer); } }, [currentMonth, scrollWidth, autoScrollToSelected]); // 处理日期选择 const handleDateSelect = (index: number) => { const targetDate = days[index]?.date?.toDate(); if (!targetDate) return; // 检查是否为未来日期 if (disableFutureDates && days[index].date.isAfter(dayjs(), 'day')) { return; } // 先滚动到目标位置,再更新状态 if (autoScrollToSelected) { scrollToIndex(index, true); } // 更新内部状态(如果使用外部控制则不更新) if (externalSelectedIndex === undefined) { setInternalSelectedIndex(index); } // 调用回调 onDateSelect?.(index, targetDate); }; // 日历弹窗相关函数 const openDatePicker = () => { const currentSelectedDate = days[selectedIndex]?.date?.toDate() || new Date(); setPickerDate(currentSelectedDate); setDatePickerVisible(true); }; const closeDatePicker = () => setDatePickerVisible(false); const onConfirmDate = (date: Date) => { const today = new Date(); today.setHours(0, 0, 0, 0); const picked = new Date(date); picked.setHours(0, 0, 0, 0); // 如果禁用未来日期,则限制选择 const finalDate = (disableFutureDates && picked > today) ? today : picked; closeDatePicker(); // 更新当前月份为选中日期的月份 const selectedMonth = dayjs(finalDate); setCurrentMonth(selectedMonth); // 计算选中日期在新月份中的索引 const newMonthDays = getMonthDaysZh(selectedMonth); const selectedDay = selectedMonth.date(); const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay); // 更新内部状态(如果使用外部控制则不更新) if (externalSelectedIndex === undefined && newSelectedIndex !== -1) { setInternalSelectedIndex(newSelectedIndex); } // 调用统一的日期选择回调 if (newSelectedIndex !== -1) { onDateSelect?.(newSelectedIndex, finalDate); } }; return ( {showMonthTitle && ( {monthTitle} {showCalendarIcon && ( )} )} setScrollWidth(e.nativeEvent.layout.width)} style={style} > {days.map((d, i) => { const selected = i === selectedIndex; const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day'); return ( !isFutureDate && handleDateSelect(i)} activeOpacity={isFutureDate ? 1 : 0.8} disabled={isFutureDate} > {d.weekdayZh} {d.dayOfMonth} ); })} {/* 日历选择弹窗 */} { if (Platform.OS === 'ios') { if (date) setPickerDate(date); } else { if (event.type === 'set' && date) { onConfirmDate(date); } else { closeDatePicker(); } } }} /> {Platform.OS === 'ios' && ( 取消 { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}> 确定 )} ); }; const styles = StyleSheet.create({ container: { }, monthTitleContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', marginBottom: 8, }, monthTitle: { fontSize: 20, fontWeight: '800', color: '#192126', }, calendarIconButton: { padding: 4, borderRadius: 6, marginLeft: 4 }, daysContainer: { paddingBottom: 8, }, dayItemWrapper: { alignItems: 'center', width: 48, marginRight: 8, }, dayPill: { width: 40, height: 60, borderRadius: 24, alignItems: 'center', justifyContent: 'center', }, dayPillNormal: { backgroundColor: 'transparent', }, dayPillSelected: { backgroundColor: '#FFFFFF', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, }, dayPillDisabled: { backgroundColor: 'transparent', opacity: 0.4, }, dayLabel: { fontSize: 11, fontWeight: '700', color: 'gray', marginBottom: 2, }, dayLabelSelected: { color: '#192126', }, dayLabelDisabled: { color: 'gray', }, dayDate: { fontSize: 12, fontWeight: '600', color: 'gray', }, dayDateSelected: { color: '#192126', }, dayDateDisabled: { color: 'gray', }, modalBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.4)', }, modalSheet: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 16, backgroundColor: '#FFFFFF', borderTopLeftRadius: 16, borderTopRightRadius: 16, }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8, gap: 12, }, modalBtn: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, backgroundColor: '#F1F5F9', }, modalBtnPrimary: { backgroundColor: '#7a5af8', }, modalBtnText: { color: '#334155', fontWeight: '700', }, modalBtnTextPrimary: { color: '#FFFFFF', fontWeight: '700', }, });