Taro + React + @nutui/nutui-react-taro 时间选择器重写

最近写小程序时,使用@nutui/nutui-react-taro里面的时间选择器,总是很不流畅,用户体验感不好

鞭策AI,利用Taro中的PickerView 和 PickerViewColumn组件,手写了个时间选择器

滑动比较流畅

下面是源码:

复制代码
import { useState, useEffect } from 'react'
import { View, Text, PickerView, PickerViewColumn } from '@tarojs/components'
import { Image } from '@nutui/nutui-react-taro'
import './index.scss'
import ic_more from '@/public/images/ic_more@2x.png'

// 类型定义
interface DateTimePickerProps {
  defaultValue?: string // 默认值 YYYY-MM-DD HH:mm
  defaultColor?: string
  defaultSize?: string
  valueColor?: string
  valueSize?: string
  minDate?: string // 最小可选日期 YYYY-MM-DD
  maxDate?: string // 最大可选日期 YYYY-MM-DD
  format?: 'YYYY-MM-DD HH:mm' | 'YYYY-MM-DD' // 显示格式
  onChange: (value: string) => void // 选择回调
  placeholder?: string // 占位符
  confirmText?: string
  cancelText?: string
  titleText?: string
  onClick?: () => void
}

// 工具函数:格式化数字为两位数
const formatNum = (num: number): string => num.toString().padStart(2, '0')

// 工具函数:生成年份数组
const generateYears = (minYear: number, maxYear: number): string[] => {
  const years: string[] = []
  for (let i = minYear; i <= maxYear; i++) {
    years.push(i.toString())
  }
  return years
}

// 工具函数:生成月份数组
const generateMonths = (): string[] => {
  const months: string[] = []
  for (let i = 1; i <= 12; i++) {
    months.push(formatNum(i))
  }
  return months
}

// 工具函数:生成日期数组(根据年月)
const generateDays = (year: string, month: string): string[] => {
  const daysInMonth = new Date(+year, +month, 0).getDate()
  const days: string[] = []
  for (let i = 1; i <= daysInMonth; i++) {
    days.push(formatNum(i))
  }
  return days
}

// 工具函数:生成小时数组
const generateHours = (): string[] => {
  const hours: string[] = []
  for (let i = 0; i < 24; i++) {
    hours.push(formatNum(i))
  }
  return hours
}

// 工具函数:生成分钟数组
const generateMinutes = (step = 5): string[] => {
  const minutes: string[] = []
  for (let i = 0; i < 60; i += step) {
    minutes.push(formatNum(i))
  }
  return minutes
}

const DateTimePicker: React.FC<DateTimePickerProps> = ({
  defaultValue = '',
  defaultColor = '#666',
  defaultSize = '15px',
  valueColor = '#333',
  valueSize = '16px',
  minDate = '2000-01-01',
  maxDate = '2099-12-31',
  format = 'YYYY-MM-DD HH:mm',
  onChange,
  placeholder = '请选择日期时间',
  cancelText = '取消',
  confirmText = '保存',
  titleText = '选择日期时间',
  onClick = () => {},
}) => {
  // 核心新增:控制选择器弹窗显隐
  const [pickerVisible, setPickerVisible] = useState<boolean>(false)
  // 原有状态
  const [selectedValue, setSelectedValue] = useState<number[]>([])
  const [years, setYears] = useState<string[]>([])
  const [months, setMonths] = useState<string[]>([])
  const [days, setDays] = useState<string[]>([])
  const [hours, setHours] = useState<string[]>([])
  const [minutes, setMinutes] = useState<string[]>([])
  const [displayValue, setDisplayValue] = useState<string>(defaultValue || '')

  // 查找值在数组中的索引
  const findIndexByValue = (values: string[], targetValue: string): number => {
    let index = values.indexOf(targetValue)
    if (index !== -1) return index

    const normalizedTarget = targetValue.startsWith('0') ? targetValue.slice(1) : targetValue
    index = values.findIndex(v => v === normalizedTarget || v === targetValue)

    return index !== -1 ? index : 0
  }

  // 解析日期字符串为索引数组
  const parseDateTimeToIndexes = (
    dateStr: string,
    years: string[],
    months: string[],
    days: string[],
    hours: string[],
    minutes: string[],
  ): number[] => {
    if (!dateStr || dateStr === '未设置') return []

    const [datePart, timePart = '00:00'] = dateStr.split(' ')
    const [year, month, day] = datePart.split('-')
    const [hour, minute] = timePart.split(':')

    return [
      findIndexByValue(years, year),
      findIndexByValue(months, month),
      findIndexByValue(days, day),
      findIndexByValue(hours, hour),
      findIndexByValue(minutes, minute),
    ]
  }

  // 初始化时间选项
  // 初始化时间选项
  useEffect(() => {
    const minYear = new Date(minDate).getFullYear()
    const maxYear = new Date(maxDate).getFullYear()

    const newYears = generateYears(minYear, maxYear)
    const newMonths = generateMonths()
    const newHours = generateHours()
    const newMinutes = generateMinutes()

    setYears(newYears)
    setMonths(newMonths)
    setHours(newHours)
    setMinutes(newMinutes)

    if (defaultValue && defaultValue !== '未设置') {
      const [defaultYear] = defaultValue.split('-')
      const defaultMonth = defaultValue.split('-')[1]
      const newDays = generateDays(defaultYear, defaultMonth)
      setDays(newDays)

      setTimeout(() => {
        const indexes = parseDateTimeToIndexes(
          defaultValue,
          newYears,
          newMonths,
          newDays,
          newHours,
          newMinutes,
        )
        setSelectedValue(indexes.filter(index => index >= 0))
      }, 0)
    } else {
      // 如果没有默认值,设置默认选中为当前时间
      const now = new Date()
      const currentYear = now.getFullYear().toString()
      const currentMonth = (now.getMonth() + 1).toString().padStart(2, '0')
      const currentDay = now.getDate().toString().padStart(2, '0')
      const currentHour = now.getHours().toString().padStart(2, '0')
      const currentMinute = (Math.floor(now.getMinutes() / 5) * 5).toString().padStart(2, '0')

      // 设置当前月份的天数
      const newDays = generateDays(currentYear, currentMonth)
      setDays(newDays)

      setTimeout(() => {
        const currentIndexes = [
          findIndexByValue(newYears, currentYear),
          findIndexByValue(newMonths, currentMonth),
          findIndexByValue(newDays, currentDay),
        ]

        // 如果是完整格式,加上时间
        if (format === 'YYYY-MM-DD HH:mm') {
          currentIndexes.push(
            findIndexByValue(newHours, currentHour),
            findIndexByValue(newMinutes, currentMinute),
          )
        }

        setSelectedValue(currentIndexes)
      }, 0)
    }
  }, [minDate, maxDate, defaultValue])

  // 处理PickerView值变化(年份/月份变化时更新日期)
  const handlePickerChange = (e: any) => {
    const newSelectedIndexes = e.detail.value || []
    setSelectedValue(newSelectedIndexes)

    // 年份或月份变化时,重新计算日期
    const [yearIndex, monthIndex] = newSelectedIndexes
    if (yearIndex !== undefined && monthIndex !== undefined) {
      const selectedYear = years[yearIndex] || years[0]
      const selectedMonth = months[monthIndex] || months[0]
      const newDays = generateDays(selectedYear, selectedMonth)
      setDays(newDays)

      // 如果当前日期超出范围,重置为最后一天
      if (newSelectedIndexes[2] >= newDays.length) {
        const updatedIndexes = [...newSelectedIndexes]
        updatedIndexes[2] = newDays.length - 1
        setSelectedValue(updatedIndexes)
      }
    }
  }

  // 确认选择
  const handleConfirm = () => {
    if (selectedValue.length === 0) return

    // 映射索引到实际值
    const getValueByIndex = (list: string[], index: number) => list[index] || list[0]
    const year = getValueByIndex(years, selectedValue[0])
    const month = getValueByIndex(months, selectedValue[1])
    const day = getValueByIndex(days, selectedValue[2])
    const hour = format === 'YYYY-MM-DD HH:mm' ? getValueByIndex(hours, selectedValue[3]) : '00'
    const minute = format === 'YYYY-MM-DD HH:mm' ? getValueByIndex(minutes, selectedValue[4]) : '00'

    // 验证日期范围
    // const selectedDate = new Date(`${year}-${month}-${day} ${hour}:${minute}`)
    // const minDateTime = new Date(minDate)
    // const maxDateTime = new Date(maxDate)

    // if (selectedDate < minDateTime || selectedDate > maxDateTime) {
    // Toast({
    //   title: `请选择${minDate}至${maxDate}之间的时间`,
    //   icon: 'none',
    // })
    //   return
    // }

    // 格式化结果
    const result =
      format === 'YYYY-MM-DD HH:mm'
        ? `${year}-${month}-${day} ${hour}:${minute}`
        : `${year}-${month}-${day}`
    console.log(result)

    setDisplayValue(result)
    onChange(result)
    setPickerVisible(false) // 关闭弹窗
  }

  // 取消选择
  const handleCancel = () => {
    setPickerVisible(false)
    onChange('')
  }

  // 构建PickerView的列数据
  const getPickerColumns = () => {
    const columns = [
      <PickerViewColumn key="year" className="picker-column">
        {years.map(item => (
          <View key={`year-${item}`} className="picker-item">
            {item}年
          </View>
        ))}
      </PickerViewColumn>,
      <PickerViewColumn key="month" className="picker-column">
        {months.map(item => (
          <View key={`month-${item}`} className="picker-item">
            {item}月
          </View>
        ))}
      </PickerViewColumn>,
      <PickerViewColumn key="day" className="picker-column">
        {days.map(item => (
          <View key={`day-${item}`} className="picker-item">
            {item}日
          </View>
        ))}
      </PickerViewColumn>,
    ]

    // 根据格式添加时分列
    if (format === 'YYYY-MM-DD HH:mm') {
      columns.push(
        <PickerViewColumn key="hour" className="picker-column">
          {hours.map(item => (
            <View key={`hour-${item}`} className="picker-item">
              {item}时
            </View>
          ))}
        </PickerViewColumn>,
        <PickerViewColumn key="minute" className="picker-column">
          {minutes.map(item => (
            <View key={`minute-${item}`} className="picker-item">
              {item}分
            </View>
          ))}
        </PickerViewColumn>,
      )
    }

    return columns
  }

  return (
    <View className="date-time-picker">
      {/* 触发区域 */}
      <View
        className="picker-display flex items-center justify-between"
        onClick={() => {
          onClick()
          setPickerVisible(true)
        }}
      >
        <Text
          className="mr-[10px]"
          style={{
            color: displayValue ? valueColor : defaultColor,
            fontSize: displayValue ? valueSize : defaultSize,
          }}
        >
          {displayValue || placeholder}
        </Text>
        <Image src={ic_more} width={18} height={18} />
      </View>

      {/* 自定义PickerView弹窗 */}
      {pickerVisible && (
        <View className="picker-modal-mask" onClick={handleCancel}>
          <View className="picker-modal-content" onClick={e => e.stopPropagation()}>
            {/* 顶部操作栏 */}
            <View className="picker-modal-header flex justify-between items-center">
              <Text className="picker-modal-cancel" onClick={handleCancel}>
                {cancelText}
              </Text>
              <Text className="picker-modal-title">{titleText}</Text>
              <Text className="picker-modal-confirm" onClick={handleConfirm}>
                {confirmText}
              </Text>
            </View>

            {/* PickerView主体 */}
            <PickerView
              value={selectedValue}
              onChange={handlePickerChange}
              className="picker-view-container"
            >
              {getPickerColumns()}
            </PickerView>
          </View>
        </View>
      )}
    </View>
  )
}

export default DateTimePicker

Scss代码如下:

xml 复制代码
.date-time-picker {
  .picker-display {
    width: 100%;
    box-sizing: border-box;
    cursor: pointer;
  }

  // 遮罩层
  .picker-modal-mask {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 999;
    display: flex;
    align-items: flex-end;
    justify-content: center;
  }

  // 弹窗内容
  .picker-modal-content {
    width: 100%;
    background-color: #fff;
    border-radius: 12px 12px 0 0;
    overflow: hidden;
  }

  // 弹窗头部
  .picker-modal-header {
    padding: 12px 16px;
    border-bottom: 1px solid #eee;

    .picker-modal-cancel,
    .picker-modal-confirm {
      font-size: 15px;
      color: #666;
      padding: 4px 8px;
    }

    .picker-modal-confirm {
      color: #1677ff; // 确认按钮蓝色
      font-weight: 500;
    }

    .picker-modal-title {
      font-size: 16px;
      font-weight: 500;
      color: #333;
    }
  }

  // PickerView容器
  .picker-view-container {
    height: 300px;
    width: 100%;
  }

  // 每一列样式
  .picker-column {
    .picker-item {
      height: 50px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 16px;
      color: #333;
    }
  }
}

// 适配flex布局
.flex {
  display: flex;
}

.items-center {
  align-items: center;
}

.justify-between {
  justify-content: space-between;
}

样例图:


相关推荐
lxh01138 小时前
2025/12/17总结
前端·webpack
芳草萋萋鹦鹉洲哦8 小时前
【elementUI】form表单rules没生效
前端·javascript·elementui
LYFlied8 小时前
【每日算法】LeetCode 560. 和为 K 的子数组
前端·数据结构·算法·leetcode·职场和发展
howcode8 小时前
年度总结——Git提交量戳破了我的副业窘境
前端·后端·程序员
恋猫de小郭8 小时前
OpenAI :你不需要跨平台框架,只需要在 Android 和 iOS 上使用 Codex
android·前端·openai
2401_860494708 小时前
在React Native中实现鸿蒙跨平台开发中开发一个运动类型管理系统,使用React Navigation设置应用的导航结构,创建一个堆栈导航器
react native·react.js·harmonyos
2301_796512528 小时前
使用状态管理、持久化存储或者利用现有的库来辅助React Native鸿蒙跨平台开发开发一个允许用户撤销删除的操作
javascript·react native·react.js
全马必破三8 小时前
浏览器原理知识点总结
前端·浏览器