React Native for OpenHarmony:日期范围选择器实现

在这里插入图片描述

🌸你好呀!我是 lbb小魔仙
🌟 感谢陪伴~ 小白博主在线求友
🌿 跟着小白学Linux/Java/Python
📖 专栏汇总:
《Linux》专栏 | 《Java》专栏 | 《Python》专栏

在这里插入图片描述

概述

日期范围选择器是预订系统、数据筛选等场景的核心组件。本文将详细讲解如何在 OpenHarmony 平台上实现一个高效、易用的日期范围选择功能。

核心概念

状态机设计

日期范围选择的核心是状态管理,通过状态机可以清晰地描述用户交互流程:

┌─────────────────┐
│   IDLE (空闲)   │
│  未选择任何日期  │
└────────┬────────┘
         │ 用户选择第一个日期
         ▼
┌─────────────────┐     用户选择第二个日期
│  START_SELECTED  │ ────────────────────────┐
│  已选起始日期    │                          │
└─────────────────┘                          ▼
         │                    ┌─────────────────────┐
         │ 用户重新选择        │  RANGE_SELECTED     │
         └───────────────────►│  已选完整日期范围    │
                              └─────────────────────┘

数据结构定义

// types/dateRange.ts
export type RangeState = 'idle' | 'start_selected' | 'range_selected';

export interface DateRange {
  startDate: string | null;
  endDate: string | null;
}

export interface RangeSelectionState {
  state: RangeState;
  range: DateRange;
  markedDates: MarkedDates;
}

export interface MarkedDates {
  [dateString: string]: DateMarker;
}

export interface DateMarker {
  startingDay?: boolean;
  endingDay?: boolean;
  color?: string;
  textColor?: string;
}

核心实现

范围选择 Hook

// hooks/useDateRangeSelection.ts
import { useState, useCallback, useMemo } from 'react';
import { DateRange, RangeState, RangeSelectionState, MarkedDates } from '../types/dateRange';

interface UseDateRangeSelectionOptions {
  minDate?: Date;
  maxDate?: Date;
  onRangeChange?: (range: DateRange) => void;
}

export const useDateRangeSelection = (options: UseDateRangeSelectionOptions = {}) => {
  const { minDate, maxDate, onRangeChange } = options;

  const [selectionState, setSelectionState] = useState<RangeSelectionState>({
    state: 'idle',
    range: { startDate: null, endDate: null },
    markedDates: {},
  });

  // 计算日期范围内所有日期的标记
  const generateRangeMarkers = useCallback((
    startDate: string,
    endDate: string
  ): MarkedDates => {
    const markers: MarkedDates = {};
    const start = new Date(startDate);
    const end = new Date(endDate);

    // 标记起始日
    markers[startDate] = {
      startingDay: true,
      color: '#FF9800',
      textColor: '#ffffff',
    };

    // 标记结束日
    markers[endDate] = {
      endingDay: true,
      color: '#FF9800',
      textColor: '#ffffff',
    };

    // 标记中间日期
    const current = new Date(start);
    current.setDate(current.getDate() + 1);

    while (current < end) {
      const dateStr = current.toISOString().split('T')[0];
      markers[dateStr] = {
        color: '#FFE0B2',
        textColor: '#FF9800',
      };
      current.setDate(current.getDate() + 1);
    }

    return markers;
  }, []);

  // 选择日期
  const selectDate = useCallback((dateString: string) => {
    setSelectionState((prev) => {
      const newRange: DateRange = { startDate: null, endDate: null };
      const newMarkers: MarkedDates = {};
      let newState: RangeState = 'idle';

      switch (prev.state) {
        case 'idle':
          // 第一次选择,设置为起始日期
          newRange.startDate = dateString;
          newMarkers[dateString] = {
            startingDay: true,
            color: '#FF9800',
            textColor: '#ffffff',
          };
          newState = 'start_selected';
          break;

        case 'start_selected':
          // 已有起始日期,判断是重新选择还是选择结束日期
          const start = new Date(prev.range.startDate!);
          const current = new Date(dateString);

          if (current < start) {
            // 选择了一个更早的日期,重新设置为起始日期
            newRange.startDate = dateString;
            newMarkers[dateString] = {
              startingDay: true,
              color: '#FF9800',
              textColor: '#ffffff',
            };
            newState = 'start_selected';
          } else {
            // 选择结束日期
            newRange.startDate = prev.range.startDate;
            newRange.endDate = dateString;
            Object.assign(newMarkers, generateRangeMarkers(prev.range.startDate!, dateString));
            newState = 'range_selected';
          }
          break;

        case 'range_selected':
          // 已有完整范围,重新开始选择
          newRange.startDate = dateString;
          newMarkers[dateString] = {
            startingDay: true,
            color: '#FF9800',
            textColor: '#ffffff',
          };
          newState = 'start_selected';
          break;
      }

      const result = {
        state: newState,
        range: newRange,
        markedDates: newMarkers,
      };

      onRangeChange?.(newRange);

      return result;
    });
  }, [generateRangeMarkers, onRangeChange]);

  // 重置选择
  const resetSelection = useCallback(() => {
    setSelectionState({
      state: 'idle',
      range: { startDate: null, endDate: null },
      markedDates: {},
    });
    onRangeChange?.({ startDate: null, endDate: null });
  }, [onRangeChange]);

  // 计算范围天数
  const rangeDays = useMemo(() => {
    const { startDate, endDate } = selectionState.range;
    if (!startDate || !endDate) return 0;

    const start = new Date(startDate);
    const end = new Date(endDate);
    const diffTime = end.getTime() - start.getTime();
    return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
  }, [selectionState.range]);

  // 判断日期是否在范围内
  const isDateInRange = useCallback((dateString: string) => {
    const { startDate, endDate } = selectionState.range;
    if (!startDate || !endDate) return false;

    const date = new Date(dateString);
    const start = new Date(startDate);
    const end = new Date(endDate);

    return date >= start && date <= end;
  }, [selectionState.range]);

  // 获取日期的标记状态
  const getDateMarker = useCallback((dateString: string) => {
    return selectionState.markedDates[dateString];
  }, [selectionState.markedDates]);

  return {
    selectionState,
    selectDate,
    resetSelection,
    rangeDays,
    isDateInRange,
    getDateMarker,
  };
};

日期范围选择组件

// components/DateRangePicker.tsx
import React from 'react';
import {
  View,
  Text,
  StyleSheet,
  TouchableOpacity,
  ScrollView,
  Alert,
} from 'react-native';
import { useDateRangeSelection } from '../hooks/useDateRangeSelection';
import { DateUtils } from '../utils/dateUtils';

interface DateRangePickerProps {
  onRangeConfirm?: (startDate: string, endDate: string, days: number) => void;
  minDate?: Date;
  maxDate?: Date;
  themeColor?: string;
}

export const DateRangePicker: React.FC<DateRangePickerProps> = ({
  onRangeConfirm,
  minDate,
  maxDate,
  themeColor = '#FF9800',
}) => {
  const {
    selectionState,
    selectDate,
    resetSelection,
    rangeDays,
    getDateMarker,
  } = useDateRangeSelection({
    minDate,
    maxDate,
  });

  const [currentDate, setCurrentDate] = React.useState(new Date());

  // 获取月份数据
  const monthData = React.useMemo(() => {
    return DateUtils.getMonthData(currentDate);
  }, [currentDate]);

  // 切换月份
  const changeMonth = (offset: number) => {
    setCurrentDate((prev) => {
      const newDate = new Date(prev);
      newDate.setMonth(newDate.getMonth() + offset);
      return newDate;
    });
  };

  // 确认选择
  const handleConfirm = () => {
    const { startDate, endDate } = selectionState.range;

    if (!startDate) {
      Alert.alert('提示', '请选择起始日期');
      return;
    }

    if (!endDate) {
      Alert.alert('提示', '请选择结束日期');
      return;
    }

    onRangeConfirm?.(startDate, endDate, rangeDays);
  };

  // 渲染日期单元格
  const renderDayCell = (day: any) => {
    const marker = getDateMarker(day.dateString);
    const isSelected = marker !== undefined;

    return (
      <TouchableOpacity
        key={day.dateString || day.day}
        style={[
          styles.dayCell,
          !day.isCurrentMonth && styles.dayCellDisabled,
          day.isToday && styles.dayCellToday,
          marker?.startingDay && styles.dayCellStart,
          marker?.endingDay && styles.dayCellEnd,
          marker?.color === '#FFE0B2' && styles.dayCellRange,
        ]}
        onPress={() => day.isCurrentMonth && selectDate(day.dateString)}
        disabled={!day.isCurrentMonth}
        activeOpacity={0.7}
      >
        <Text
          style={[
            styles.dayText,
            !day.isCurrentMonth && styles.dayTextDisabled,
            day.isToday && { color: themeColor },
            (marker?.startingDay || marker?.endingDay) && styles.dayTextSelected,
            marker?.color === '#FFE0B2' && styles.dayTextRange,
          ]}
        >
          {day.day}
        </Text>
      </TouchableOpacity>
    );
  };

  // 渲染日历网格
  const renderCalendar = () => {
    const rows: React.ReactNode[] = [];

    for (let i = 0; i < 6; i++) {
      const rowDays = monthData.days.slice(i * 7, (i + 1) * 7);
      rows.push(
        <View key={i} style={styles.weekRow}>
          {rowDays.map(renderDayCell)}
        </View>
      );
    }

    return rows;
  };

  // 状态提示文本
  const getStatusText = () => {
    const { state, range } = selectionState;

    switch (state) {
      case 'idle':
        return '请选择起始日期';
      case 'start_selected':
        return `已选起始: ${range.startDate},请选择结束日期`;
      case 'range_selected':
        return `${range.startDate}${range.endDate},共 ${rangeDays}`;
      default:
        return '';
    }
  };

  return (
    <ScrollView style={styles.container}>
      {/* 状态提示 */}
      <View style={[styles.statusCard, { borderLeftColor: themeColor }]}>
        <Text style={styles.statusText}>{getStatusText()}</Text>
      </View>

      {/* 日历卡片 */}
      <View style={styles.calendarCard}>
        {/* 月份导航 */}
        <View style={styles.monthNavigation}>
          <TouchableOpacity
            style={styles.navButton}
            onPress={() => changeMonth(-1)}
          >
            <Text style={[styles.navButtonText, { color: themeColor }]}></Text>
          </TouchableOpacity>
          <Text style={styles.monthText}>
            {monthData.year}{DateUtils.getMonthName(monthData.month)}
          </Text>
          <TouchableOpacity
            style={styles.navButton}
            onPress={() => changeMonth(1)}
          >
            <Text style={[styles.navButtonText, { color: themeColor }]}></Text>
          </TouchableOpacity>
        </View>

        {/* 星期标题 */}
        <View style={styles.weekHeader}>
          {DateUtils.getWeekDays().map((day, index) => (
            <View key={index} style={styles.weekDayCell}>
              <Text style={styles.weekDayText}>{day}</Text>
            </View>
          ))}
        </View>

        {/* 日期网格 */}
        <View style={styles.daysContainer}>
          {renderCalendar()}
        </View>
      </View>

      {/* 操作按钮 */}
      <View style={styles.buttonRow}>
        <TouchableOpacity
          style={[styles.button, styles.confirmButton, { backgroundColor: themeColor }]}
          onPress={handleConfirm}
        >
          <Text style={styles.buttonText}>确认选择</Text>
        </TouchableOpacity>
        <TouchableOpacity
          style={[styles.button, styles.resetButton]}
          onPress={resetSelection}
        >
          <Text style={styles.buttonText}>重置</Text>
        </TouchableOpacity>
      </View>

      {/* 范围说明 */}
      <View style={styles.infoCard}>
        <Text style={styles.infoTitle}>选择说明</Text>
        <View style={styles.infoStep}>
          <View style={[styles.stepNumber, { backgroundColor: themeColor }]}>
            <Text style={styles.stepNumberText}>1</Text>
          </View>
          <Text style={styles.infoText}>选择起始日期</Text>
        </View>
        <View style={styles.infoStep}>
          <View style={[styles.stepNumber, { backgroundColor: themeColor }]}>
            <Text style={styles.stepNumberText}>2</Text>
          </View>
          <Text style={styles.infoText}>选择结束日期(需晚于起始日期)</Text>
        </View>
        <View style={styles.infoStep}>
          <View style={[styles.stepNumber, { backgroundColor: themeColor }]}>
            <Text style={styles.stepNumberText}>3</Text>
          </View>
          <Text style={styles.infoText}>确认或重新选择</Text>
        </View>
      </View>

      {/* 标记图例 */}
      <View style={styles.legendCard}>
        <Text style={styles.legendTitle}>标记说明</Text>
        <View style={styles.legendItem}>
          <View style={[styles.legendBox, { backgroundColor: themeColor }]} />
          <Text style={styles.legendText}>起始/结束日期</Text>
        </View>
        <View style={styles.legendItem}>
          <View style={[styles.legendBox, { backgroundColor: '#FFE0B2' }]} />
          <Text style={styles.legendText}>范围中间日期</Text>
        </View>
        <View style={styles.legendItem}>
          <View style={[styles.legendBox, styles.legendBoxDefault]} />
          <Text style={styles.legendText}>未选择日期</Text>
        </View>
      </View>
    </ScrollView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  statusCard: {
    backgroundColor: '#fff',
    margin: 16,
    marginTop: 16,
    padding: 14,
    borderRadius: 10,
    borderLeftWidth: 4,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 2,
  },
  statusText: {
    fontSize: 14,
    color: '#333',
    lineHeight: 20,
  },
  calendarCard: {
    backgroundColor: '#fff',
    borderRadius: 16,
    margin: 16,
    marginTop: 8,
    padding: 16,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 4,
  },
  monthNavigation: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 16,
  },
  monthText: {
    fontSize: 18,
    fontWeight: '700',
    color: '#1a1a1a',
  },
  navButton: {
    width: 36,
    height: 36,
    borderRadius: 18,
    backgroundColor: '#f0f0f0',
    justifyContent: 'center',
    alignItems: 'center',
  },
  navButtonText: {
    fontSize: 22,
    fontWeight: '600',
  },
  weekHeader: {
    flexDirection: 'row',
    marginBottom: 8,
  },
  weekDayCell: {
    flex: 1,
    height: 32,
    justifyContent: 'center',
    alignItems: 'center',
  },
  weekDayText: {
    fontSize: 13,
    fontWeight: '600',
    color: '#666',
  },
  daysContainer: {
    marginTop: 4,
  },
  weekRow: {
    flexDirection: 'row',
    marginBottom: 4,
  },
  dayCell: {
    flex: 1,
    height: 40,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 20,
    margin: 1,
  },
  dayCellDisabled: {
    opacity: 0.3,
  },
  dayCellToday: {
    borderWidth: 1,
    borderColor: '#FF9800',
  },
  dayCellStart: {
    backgroundColor: '#FF9800',
    borderRadius: 20,
  },
  dayCellEnd: {
    backgroundColor: '#FF9800',
    borderRadius: 20,
  },
  dayCellRange: {
    backgroundColor: '#FFE0B2',
    borderRadius: 0,
  },
  dayText: {
    fontSize: 16,
    color: '#333',
    fontWeight: '500',
  },
  dayTextDisabled: {
    color: '#999',
  },
  dayTextSelected: {
    color: '#fff',
    fontWeight: '700',
  },
  dayTextRange: {
    color: '#FF9800',
    fontWeight: '600',
  },
  buttonRow: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    margin: 16,
    marginTop: 8,
  },
  button: {
    paddingHorizontal: 32,
    paddingVertical: 12,
    borderRadius: 10,
    minWidth: 120,
    alignItems: 'center',
  },
  confirmButton: {},
  resetButton: {
    backgroundColor: '#f44336',
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
  infoCard: {
    backgroundColor: '#fff',
    borderRadius: 12,
    margin: 16,
    marginTop: 8,
    padding: 16,
  },
  infoTitle: {
    fontSize: 16,
    fontWeight: '700',
    color: '#1a1a1a',
    marginBottom: 12,
  },
  infoStep: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 10,
  },
  stepNumber: {
    width: 24,
    height: 24,
    borderRadius: 12,
    justifyContent: 'center',
    alignItems: 'center',
    marginRight: 10,
  },
  stepNumberText: {
    color: '#fff',
    fontSize: 12,
    fontWeight: '700',
  },
  infoText: {
    fontSize: 14,
    color: '#555',
    flex: 1,
  },
  legendCard: {
    backgroundColor: '#fff',
    borderRadius: 12,
    margin: 16,
    marginTop: 8,
    marginBottom: 24,
    padding: 16,
  },
  legendTitle: {
    fontSize: 16,
    fontWeight: '700',
    color: '#1a1a1a',
    marginBottom: 12,
  },
  legendItem: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 8,
  },
  legendBox: {
    width: 20,
    height: 20,
    borderRadius: 4,
    marginRight: 10,
  },
  legendBoxDefault: {
    backgroundColor: '#f0f0f0',
    borderWidth: 1,
    borderColor: '#ddd',
  },
  legendText: {
    fontSize: 14,
    color: '#666',
  },
});

使用示例

// Example.tsx
import React from 'react';
import { View, StyleSheet, Alert } from 'react-native';
import { DateRangePicker } from './components/DateRangePicker';

const Example: React.FC = () => {
  const handleRangeConfirm = (startDate: string, endDate: string, days: number) => {
    Alert.alert(
      '选择成功',
      `日期范围: ${startDate}${endDate}\n共 ${days}`
    );
  };

  return (
    <View style={styles.container}>
      <DateRangePicker
        onRangeConfirm={handleRangeConfirm}
        themeColor="#FF9800"
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});

export default Example;

OpenHarmony 适配要点

  1. 日期计算优化

    • 使用 UTC 时间避免时区问题
    • 缓存计算结果减少重复运算
  2. 渲染性能

    • 限制一次性渲染的日期数量
    • 使用 useMemouseCallback 优化
  3. 交互体验

    • 提供清晰的状态反馈
    • 支持触摸取消选择

总结

本文介绍了在 OpenHarmony 平台上实现日期范围选择器的完整方案,包括状态机设计、Hook 封装、组件实现等核心技术点。


相关资源

📕个人领域 :Linux/C++/java/AI
🚀 个人主页有点流鼻涕 · CSDN
💬 座右铭“向光而行,沐光而生。”

在这里插入图片描述

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐