如何实现一款 H5 DatePicker 控件

开篇

在 H5 场景下,一个日期选择控件的交互,通常由 Popup 从底部弹出,内容区域展示三列 年、月、日 选项集合列表,通过滑动来选择日期。

效果类似于 ant-design-mobile DatePicker 组件:

要实现这样一款 H5 控件,需要用到哪些知识呢?下面,我们一起分析和动手实现一款 DatePicker 控件,掌握其原理。

下面基于 React 技术栈来实现,思路在不同框架中同样适用。

一、实现分析

分析 ant-design-mobile DatePicker 效果图可以看出:

  1. Popup 组件实现 日期选择器 的 显示与隐藏;
  2. 每一栏数据选项能够支持滑动选择,即实现一个数据选项 PickerView
  3. 提供 年月日 三栏选项列表数据,即提供日期 columns list

其中 Popup 相对简单这里不做过多介绍,接下来我们做的是两件事:

一是实现一个 PickerView 数据选择控件,

二是为 PickerView 提供日期 columns 数据选项集合。

二、实现 PickerView

一个 PickerView 组件包含以下组成部分:

  1. 主体选项列表,这里我们使用 7行 * 3列 来实现;
  2. 辅助蒙层,中间第 4 行为当前选中高亮区域,其余区域以透明形式存在;
  3. 滑动交互,每一列的选项列表,能够支持滑动选择。

其中 1 和 2 我们作为布局来实现,3 作为事件绑定来实现。

1. 元素布局

tsx 复制代码
// 假设临时有一组日期选项列表
const columns = [
  { curIndex: 0, values: [2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025] },
  { curIndex: 0, values: new Array(12).fill(0).map((item, index) => index + 1) },
  { curIndex: 0, values: new Array(30).fill(0).map((item, index) => index + 1) },
];

<div className="mobile-picker">
  <div className="picker-header">
    <button className="header-btn-cancel">取消</button>
    <button className="header-btn-confirm" onClick={handleConfirm}>确定</button>
  </div>

  <div className="picker-body">
    {columns.map((column, index) => {
      return (
        <div className="picker-view-column" key={index}>
          <div className="picker-view-column-list">
            {column.values.map((item, i) => (
              <div key={i} className="picker-view-column-item">{item}</div>
            ))}
          </div>
        </div>
      );
    })}
    <div className="picker-view-mask">
      <div className="picker-view-mask-top"></div>
      <div className="picker-view-mask-middle"></div>
      <div className="picker-view-mask-bottom"></div>
    </div>
  </div>
</div>

这里为了让内容有数据,临时写了一个 columns 先用于渲染列表,column.curIndex 指当前栏选中的值索引,column.values 存储了选项列表。它的类型定义如下:

ts 复制代码
interface IPickerColumn {
  values: number[];
  curIndex: number;
}

CSS 的主要代码如下:

css 复制代码
.mobile-picker {
  height: 300px;
  display: flex;
  flex-direction: column;
  overflow: hidden;

  .picker-header {
    height: 44px;
    ...
  }
  
  .picker-body {
    background: #fff;
    flex: 1 1;
    display: flex;
    position: relative;
    overflow: hidden;
  
    .picker-view-mask {
      position: absolute;
      z-index: 10000;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
      flex-direction: column;
      pointer-events: none;
      &-top {
        flex: auto;
        background: linear-gradient(180deg, #ffffff 0%, rgba(255, 255, 255, 0.65) 100%);
      }
      &-middle {
        height: 34px;
        box-sizing: border-box;
        flex: none;
        border-top: 1px solid #eee;
        border-bottom: 1px solid #eee;
      }
      &-bottom {
        flex: auto;
        background: linear-gradient(360deg, #ffffff 0%, rgba(255, 255, 255, 0.65) 100%);
      }
    }
  
    .picker-view-column {
      flex: 1 1;
      height: 100%;
  
      &-list {
        padding: 9px 0;
        transition: transform 0.1s ease 0s;
        // transform: translate3d(0, 34px, 0);
      }
  
      &-item {
        font-size: 16px;
        padding: 0 6px;
        height: 34px;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    }
  }
}

2. 事件绑定

要实现对列表数据滑动选择,在 H5 需要借助 touch 事件,再结合 CSS transform: translate 位移来实现滑动效果

我们对 column.values 列表元素渲染绑定 touch 有关事件。

tsx 复制代码
const itemHeight: number = 34; // 设定单个选项的高度为 34

{columns.map((column, index) => {
  return (
    <div className="picker-view-column" key={index}>
      <div
        className="picker-view-column-list"
        style={{ transform: `translate3d(0, ${(3 - column.curIndex) * itemHeight}px, 0)` }}
        onTouchStart={event => handleTouchEvent(event, "start", column, index)}
        onTouchMove={event => handleTouchEvent(event, "move", column, index)}
        onTouchEnd={event => handleTouchEvent(event, "end", column, index)}>
        {column.values.map(item => (
          <div key={`${index}-${item}`} className="picker-view-column-item">
            {item}
          </div>
        ))}
      </div>
    </div>
  );
})}

涉及移动的操作都需要记录起始位置,这里使用 useRef 定义一个 positionRef 来记录:

tsx 复制代码
const positionRef = useRef({
  startY: 0, // 视窗起始位置
  itemStartY: 0, // 当前项的起始位置
});

touch 事件处理 滑动选择:

tsx 复制代码
const handleTouchEvent = (event: TouchEvent, eventName: "start" | "move" | "end", column: IPickerColumn, index: number) => {
  switch (eventName) {
    // 1. start:记录初始位置信息
    case "start": {
      positionRef.current.startY = event.touches[0].clientY;
      positionRef.current.itemStartY = (3 - column.curIndex) * itemHeight;
      break;
    }
    // 2. move:计算移动位置,并应用在 column list 元素,实现滑动效果
    case "move": {
      const moveY = event.changedTouches[0].clientY;
      let curY = positionRef.current.itemStartY + (moveY - positionRef.current.startY);

      // 顶部的区间限制
      const maxDistance = (3 + 1) * itemHeight;
      // curY = Math.min(maxDistance, curY); // 往下滑时,第一项的位置限制
      if (curY > maxDistance) {
        curY = maxDistance + (curY - maxDistance) / 5; // 限制滑动距离实现缓冲
      }
      // 底部的区间限制
      const minDistance = -(column.values.length - 3) * itemHeight;
      // curY = Math.max(minDistance, curY); // 往上滑时,最后一项的位置限制
      if (curY < minDistance) {
        curY = minDistance + (curY - minDistance) / 5; // 限制滑动距离实现缓冲
      }

      const listEle = event.currentTarget as HTMLElement;
      listEle.style.setProperty("transform", `translate3d(0, ${curY}px, 0)`);
      break;
    }
    // 3. end:确认出选中的值 curIndex
    case "end": {
      const endY = event.changedTouches[0].clientY;
      // 四舍五入
      let curIndex = Math.round((positionRef.current.startY - endY) / itemHeight) + column.curIndex;
      const length = column.values.length;
      curIndex = Math.max(0, Math.min(curIndex, length - 1));
      column.curIndex = curIndex;
      const listEle = event.currentTarget as HTMLElement;
      listEle.style.setProperty("transform", `translate3d(0, ${(3 - column.curIndex) * itemHeight}px, 0)`);

      ... 后续有 column 之前联动处理(如:月份对应的天数会不同,切换月份,day column values 会随之变化)

      break;
    }
  }
};
  1. touchstart 事件:记录初始位置信息;
  2. touchmove 事件:计算移动位置,并应用在 column list 元素,实现滑动效果;
  3. end 事件:确认出选中的值 curIndex

到这里,一个 PickerView 组件的交互实现完成,下面我们创建日期的数据选项 columns,并提供给 PickerView.

三、日期 columns 选项集合

数据选项列表可以根据外部传入的 minDatemaxDate 规则来生成。所以我们给 DatePicker 组件属性定义如下:

tsx 复制代码
const now = new Date();
const thisYear = now.getFullYear();

export interface IMobileDatePickerProps {
  value?: number;
  min?: Date;
  max?: Date;
  onConfirm?: (value: number) => void;
}

const MobileDatePicker = ({ min, max, value = now.getTime(), onConfirm }: IMobileDatePickerProps) => {
  const minDate = min || (new Date(new Date().setFullYear(thisYear - 10)) as Date);
  const maxDate = max || (new Date(new Date().setFullYear(thisYear + 10)) as Date);
  const itemHeight: number = 34;
  
  const innerValueRef = useRef<number>(value);
  const [columns, setColumns] = useState(generateDatePickerColumns());

  useEffect(() => {
    if (value !== innerValueRef.current) {
      innerValueRef.current = value;
      setColumns(generateDatePickerColumns());
    }
  }, [value]);
  
  ...
  
}

generateDatePickerColumns() 会根据 minDatemaxDate 生成选项列表,根据 value 确定出 column.curIndex

tsx 复制代码
const generateDatePickerColumns = (): IPickerColumn[] => {
  const valueDate = new Date(value);
  const curYear = valueDate.getFullYear();
  const currMonth = valueDate.getMonth() + 1;
  const day = valueDate.getDate();

  const years = [],
    months = getMonths(curYear, minDate, maxDate),
    days = getDays(curYear, currMonth, minDate, maxDate);
  for (let i = minDate.getFullYear(); i <= maxDate.getFullYear(); i++) {
    years.push(i);
  }
  return [
    { values: years, curIndex: years.findIndex(y => y === curYear) },
    { values: months, curIndex: months.findIndex(m => m === currMonth) },
    { values: days, curIndex: days.findIndex(d => d === day) },
  ];
};

getMonthsgetDays 作为工具函数,根据 年 来确定其对应的选项列表。

tsx 复制代码
const getMonths = function (year: number, minDate: Date, maxDate: Date) {
  const months = [];
  // 默认使用 12 个月
  let startMonth = 1,
    endMonth = 12;

  // 最小/最大 区间限制
  const minDateYear = minDate.getFullYear(),
    maxDateYear = maxDate.getFullYear();
  if (year === minDateYear) {
    startMonth = minDate.getMonth() + 1;
  }
  if (year === maxDateYear) {
    endMonth = maxDate.getMonth() + 1;
  }

  for (let i = startMonth; i <= endMonth; i++) {
    months.push(i);
  }
  return months;
};

const getDays = function (year: number, month: number, minDate: Date, maxDate: Date) {
  const result = [];
  let days = 31;
  const smallMonths = [4, 6, 9, 11];
  // 是否是闰年
  const isLeapYear = function (y: number) {
    return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
  };
  year = parseInt(year + "");
  month = parseInt(month + "");
  if (month === 2) {
    isLeapYear(year) ? (days = 29) : (days = 28);
  } else if (smallMonths.indexOf(month) > -1) {
    days = 30;
  }

  // 最小/最大 区间限制
  let startDay = 1,
    endDay = days;
  const minDateYear = minDate.getFullYear(),
    minDateMonth = minDate.getMonth() + 1,
    maxDateYear = maxDate.getFullYear(),
    maxDateMonth = maxDate.getMonth() + 1;
  if (year === minDateYear && month === minDateMonth) {
    startDay = minDate.getDate();
  }
  if (year === maxDateYear && month === maxDateMonth) {
    endDay = maxDate.getDate();
  }

  for (let i = startDay; i <= endDay; i++) {
    result.push(i);
  }
  return result;
};

至此,日期 columns 数据项 创建完成。

接下来我们在 touchend 事件中处理 数据联动。

如:9 月 切换到 10 月后,天数 column values 要由 30 个 变为 31 个(每个月对应的天数不同)。

touchend 中添加如下逻辑:

tsx 复制代码
const handleTouchEvent = (event: TouchEvent, eventName: "start" | "move" | "end", column: IPickerColumn, index: number) => {
  switch (eventName) {
    ...
    case "end": {
      const endY = event.changedTouches[0].clientY;
      // 四舍五入
      let curIndex = Math.round((positionRef.current.startY - endY) / itemHeight) + column.curIndex;
      const length = column.values.length;
      curIndex = Math.max(0, Math.min(curIndex, length - 1));
      column.curIndex = curIndex;
      const listEle = event.currentTarget as HTMLElement;
      listEle.style.setProperty("transform", `translate3d(0, ${(3 - column.curIndex) * itemHeight}px, 0)`);

      // 更新 column 列表(切换了年 || 切换了月)
      if (index === 0 || index === 1) {
        let reRender = false;
        const year = getColumnValByIndex(columns[0]);

        // 更新 month column list
        if (index === 0) {
          const months = getMonths(year, minDate, maxDate);
          if (months.length !== columns[1].values.length) {
            columns[1].values = months;
            reRender = true;
          }
          if (months.indexOf(getColumnValByIndex(columns[1])) === -1) {
            columns[1].curIndex = 0;
            reRender = true;
          }
        }

        // 更新 day column list
        const month = getColumnValByIndex(columns[1]);
        const days = getDays(year, month, minDate, maxDate);
        if (days.length !== columns[2].values.length) {
          columns[2].values = days;
          reRender = true;
        }
        if (days.indexOf(getColumnValByIndex(columns[2])) === -1) {
          columns[2].curIndex = 0;
          reRender = true;
        }

        reRender && setColumns([...columns]);
      }
      break;
    }
  }
};

const getColumnValByIndex = (column: IPickerColumn) => {
  return column.values[column.curIndex];
};

最后,操作确认按钮,可将当前选中的数据暴露出去:

tsx 复制代码
const handleConfirm = () => {
  const year = columns[0].values[columns[0].curIndex],
    month = columns[1].values[columns[1].curIndex],
    day = columns[2].values[columns[2].curIndex];
  const value = new Date([year, month, day] as any).getTime();
  onConfirm && onConfirm(value); // 外部传入的回调
};

参考:

1、h5-PickerView 纯原生Javascript实现的移动端多级选择器插件
2、Ant Design Mobile DatePicker

相关推荐
mldong1 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排2 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60612 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角3 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!3 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰4 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi5 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen6 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马6 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu7 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss