如何实现一款 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

相关推荐
WeiXiao_Hyy6 分钟前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡23 分钟前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone29 分钟前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09011 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农1 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king1 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳2 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵2 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星2 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_3 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js