412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
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<DateSelectorProps> = ({
|
|
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<ScrollView | null>(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<Date>(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 (
|
|
<View style={[styles.container, containerStyle]}>
|
|
{showMonthTitle && (
|
|
<View style={styles.monthTitleContainer}>
|
|
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
|
{showCalendarIcon && (
|
|
<TouchableOpacity
|
|
onPress={openDatePicker}
|
|
style={styles.calendarIconButton}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.daysContainer}
|
|
ref={daysScrollRef}
|
|
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
|
style={style}
|
|
>
|
|
{days.map((d, i) => {
|
|
const selected = i === selectedIndex;
|
|
const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day');
|
|
|
|
return (
|
|
<View key={`${d.dayOfMonth}`} style={[styles.dayItemWrapper, dayItemStyle]}>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.dayPill,
|
|
selected ? styles.dayPillSelected : styles.dayPillNormal,
|
|
isFutureDate && styles.dayPillDisabled
|
|
]}
|
|
onPress={() => !isFutureDate && handleDateSelect(i)}
|
|
activeOpacity={isFutureDate ? 1 : 0.8}
|
|
disabled={isFutureDate}
|
|
>
|
|
<Text style={[
|
|
styles.dayLabel,
|
|
selected && styles.dayLabelSelected,
|
|
isFutureDate && styles.dayLabelDisabled
|
|
]}>
|
|
{d.weekdayZh}
|
|
</Text>
|
|
<Text style={[
|
|
styles.dayDate,
|
|
selected && styles.dayDateSelected,
|
|
isFutureDate && styles.dayDateDisabled
|
|
]}>
|
|
{d.dayOfMonth}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
|
|
{/* 日历选择弹窗 */}
|
|
<Modal
|
|
visible={datePickerVisible}
|
|
transparent
|
|
animationType="fade"
|
|
onRequestClose={closeDatePicker}
|
|
>
|
|
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
|
<View style={styles.modalSheet}>
|
|
<DateTimePicker
|
|
value={pickerDate}
|
|
mode="date"
|
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
|
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
|
maximumDate={disableFutureDates ? new Date() : undefined}
|
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
|
onChange={(event, date) => {
|
|
if (Platform.OS === 'ios') {
|
|
if (date) setPickerDate(date);
|
|
} else {
|
|
if (event.type === 'set' && date) {
|
|
onConfirmDate(date);
|
|
} else {
|
|
closeDatePicker();
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{Platform.OS === 'ios' && (
|
|
<View style={styles.modalActions}>
|
|
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
|
<Text style={styles.modalBtnText}>取消</Text>
|
|
</Pressable>
|
|
<Pressable onPress={() => {
|
|
onConfirmDate(pickerDate);
|
|
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
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',
|
|
},
|
|
});
|