Files
digital-pilates/components/DateSelector.tsx

626 lines
18 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 { 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<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;
// 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<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 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 (
<View style={[styles.container, containerStyle]}>
{showMonthTitle && (
<View style={styles.monthTitleContainer}>
<Text style={styles.monthTitle}>{monthTitle}</Text>
<View style={styles.monthActions}>
{!isSelectedDateToday() && (
<TouchableOpacity
onPress={handleGoToday}
activeOpacity={0.7}
>
{isGlassAvailable ? (
<GlassView
style={styles.todayButton}
glassEffectStyle="clear"
tintColor="rgba(124, 58, 237, 0.08)"
isInteractive={true}
>
<Text style={styles.todayButtonText}></Text>
</GlassView>
) : (
<View style={[styles.todayButton, styles.todayButtonFallback]}>
<Text style={styles.todayButtonText}></Text>
</View>
)}
</TouchableOpacity>
)}
{showCalendarIcon && (
<TouchableOpacity
onPress={openDatePicker}
activeOpacity={0.6}
>
{isGlassAvailable ? (
<GlassView
style={styles.calendarIconButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.2)"
isInteractive={true}
>
<Ionicons name="calendar-outline" size={14} color="#666666" />
</GlassView>
) : (
<View style={[styles.calendarIconButton, styles.calendarIconFallback]}>
<Ionicons name="calendar-outline" size={14} color="#666666" />
</View>
)}
</TouchableOpacity>
)}
</View>
</View>
)}
<Animated.View style={{ opacity: fadeAnim }}>
<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]}>
<Pressable
onPress={() => !isFutureDate && handleDateSelect(i)}
disabled={isFutureDate}
style={({ pressed }) => [
!isFutureDate && pressed && styles.dayPillPressed
]}
>
{selected && !isFutureDate ? (
isGlassAvailable ? (
<GlassView
style={styles.dayPill}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Text style={styles.dayLabelSelected}>
{d.weekdayZh}
</Text>
<Text style={styles.dayDateSelected}>
{d.dayOfMonth}
</Text>
</GlassView>
) : (
<View style={[styles.dayPill, styles.dayPillSelectedFallback]}>
<Text style={styles.dayLabelSelected}>
{d.weekdayZh}
</Text>
<Text style={styles.dayDateSelected}>
{d.dayOfMonth}
</Text>
</View>
)
) : (
<View style={[
styles.dayPill,
styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}>
<Text style={[
styles.dayLabel,
isFutureDate && styles.dayLabelDisabled
]}>
{d.weekdayZh}
</Text>
<Text style={[
styles.dayDate,
isFutureDate && styles.dayDateDisabled
]}>
{d.dayOfMonth}
</Text>
</View>
)}
</Pressable>
</View>
);
})}
</ScrollView>
</Animated.View>
{/* 日历选择弹窗 */}
<Modal
visible={datePickerVisible}
transparent
animationType="fade"
onRequestClose={closeDatePicker}
>
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
{isGlassAvailable ? (
<GlassView
style={styles.modalSheet}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.7)"
isInteractive={false}
>
<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}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</TouchableOpacity>
</View>
)}
</GlassView>
) : (
<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}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</TouchableOpacity>
</View>
)}
</View>
)}
</Modal>
</View>
);
};
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,
fontFamily: 'AliBold',
},
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,
fontFamily: 'AliBold',
},
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,
fontFamily: 'AliBold',
},
dayLabelSelected: {
color: '#1a1a1a',
fontWeight: '800',
fontFamily: 'AliBold',
},
dayLabelDisabled: {
color: '#c7c7cc',
},
dayDate: {
fontSize: 13,
fontWeight: '700',
color: '#8e8e93',
letterSpacing: -0.2,
fontFamily: 'AliBold',
},
dayDateSelected: {
color: '#1a1a1a',
fontWeight: '800',
fontFamily: 'AliBold',
},
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,
fontFamily: 'AliBold',
},
modalBtnTextPrimary: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.1,
fontFamily: 'AliBold',
},
});