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;
}

样例图:


相关推荐
大猫会长9 小时前
tailwindcss中,自定义多个背景渐变色
前端·html
xj7573065339 小时前
《python web开发 测试驱动方法》
开发语言·前端·python
IT=>小脑虎9 小时前
2026年 Vue3 零基础小白入门知识点【基础完整版 · 通俗易懂 条理清晰】
前端·vue.js·状态模式
IT_陈寒10 小时前
Python 3.12性能优化实战:5个让你的代码提速30%的新特性
前端·人工智能·后端
赛博切图仔10 小时前
「从零到一」我用 Node BFF 手撸一个 Vue3 SSR 项目(附源码)
前端·javascript·vue.js
爱写程序的小高10 小时前
npm ERR! code ERESOLVE npm ERR! ERESOLVE unable to resolve dependency tree
前端·npm·node.js
loonggg10 小时前
竖屏,其实是程序员的一个集体误解
前端·后端·程序员
程序员爱钓鱼10 小时前
Node.js 编程实战:测试与调试 - 单元测试与集成测试
前端·后端·node.js
码界奇点10 小时前
基于Vue.js与Element UI的后台管理系统设计与实现
前端·vue.js·ui·毕业设计·源代码管理
时光少年10 小时前
Android KeyEvent传递与焦点拦截
前端