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 { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import React, { useEffect, useRef, useState } from 'react'; import { Animated, 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; // Liquid Glass 可用性检查 const isGlassAvailable = isLiquidGlassAvailable(); // 获取日期数据 const days = getMonthDaysZh(currentMonth); const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth); // 判断当前选中的日期是否是今天 const isSelectedDateToday = () => { const today = dayjs(); const selectedDate = days[selectedIndex]?.date; if (!selectedDate) return false; // 检查是否是同一天且在同一个月 return selectedDate.isSame(today, 'day') && currentMonth.isSame(today, 'month'); }; // 滚动相关 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 fadeAnim = useRef(new Animated.Value(0)).current; // 滚动到指定索引 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); } // 淡入动画 Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true, }).start(); }, [scrollWidth, selectedIndex, autoScrollToSelected, fadeAnim]); // 当选中索引变化时,滚动到对应位置 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); } }; const handleGoToday = () => { const today = dayjs(); setCurrentMonth(today); const todayDays = getMonthDaysZh(today); const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date()); if (newSelectedIndex !== -1) { if (externalSelectedIndex === undefined) { setInternalSelectedIndex(newSelectedIndex); } const todayDate = today.toDate(); setPickerDate(todayDate); onDateSelect?.(newSelectedIndex, todayDate); } }; return ( {showMonthTitle && ( {monthTitle} {!isSelectedDateToday() && ( {isGlassAvailable ? ( 回到今天 ) : ( 回到今天 )} )} {showCalendarIcon && ( {isGlassAvailable ? ( ) : ( )} )} )} 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)} disabled={isFutureDate} style={({ pressed }) => [ !isFutureDate && pressed && styles.dayPillPressed ]} > {selected && !isFutureDate ? ( isGlassAvailable ? ( {d.weekdayZh} {d.dayOfMonth} ) : ( {d.weekdayZh} {d.dayOfMonth} ) ) : ( {d.weekdayZh} {d.dayOfMonth} )} ); })} {/* 日历选择弹窗 */} {isGlassAvailable ? ( { 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]}> 确定 )} ) : ( { 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: 'space-between', marginBottom: 8, }, monthActions: { flexDirection: 'row', alignItems: 'center', }, monthTitle: { fontSize: 22, fontWeight: '800', color: '#1a1a1a', letterSpacing: -0.5, }, calendarIconButton: { padding: 4, borderRadius: 6, marginLeft: 4, overflow: 'hidden', }, calendarIconFallback: { backgroundColor: 'rgba(255, 255, 255, 0.9)', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.3)', }, todayButton: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 12, marginRight: 8, overflow: 'hidden', }, todayButtonFallback: { backgroundColor: '#EEF2FF', borderWidth: 1, borderColor: 'rgba(124, 58, 237, 0.2)', }, todayButtonText: { fontSize: 12, fontWeight: '700', color: '#7c3aed', letterSpacing: 0.2, }, daysContainer: { paddingBottom: 8, }, dayItemWrapper: { alignItems: 'center', width: 48, marginRight: 8, }, dayPill: { width: 40, height: 60, borderRadius: 24, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, dayPillNormal: { backgroundColor: 'transparent', }, dayPillPressed: { opacity: 0.8, transform: [{ scale: 0.96 }], }, dayPillSelectedFallback: { backgroundColor: '#FFFFFF', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.5)', }, dayPillDisabled: { backgroundColor: 'transparent', opacity: 0.4, }, dayLabel: { fontSize: 11, fontWeight: '700', color: '#8e8e93', marginBottom: 2, letterSpacing: 0.1, }, dayLabelSelected: { color: '#1a1a1a', fontWeight: '800', }, dayLabelDisabled: { color: '#c7c7cc', }, dayDate: { fontSize: 13, fontWeight: '700', color: '#8e8e93', letterSpacing: -0.2, }, dayDateSelected: { color: '#1a1a1a', fontWeight: '800', }, dayDateDisabled: { color: '#c7c7cc', }, modalBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.3)', }, modalSheet: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 20, borderTopLeftRadius: 20, borderTopRightRadius: 20, shadowColor: '#000', shadowOffset: { width: 0, height: -2 }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 10, }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8, gap: 12, }, modalBtn: { paddingHorizontal: 16, paddingVertical: 10, borderRadius: 12, backgroundColor: '#f8fafc', borderWidth: 1, borderColor: '#e2e8f0', minWidth: 80, alignItems: 'center', }, modalBtnPrimary: { backgroundColor: '#7c3aed', borderWidth: 1, borderColor: '#7c3aed', shadowColor: '#7c3aed', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 3, }, modalBtnText: { color: '#475569', fontWeight: '700', fontSize: 14, letterSpacing: 0.1, }, modalBtnTextPrimary: { color: '#FFFFFF', fontWeight: '700', fontSize: 14, letterSpacing: 0.1, }, });