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

相关推荐
zhangjin12226 分钟前
kettle从入门到精通 第八十五课 ETL之kettle kettle中javascript步骤调用外部javascript/js文件
javascript·数据仓库·etl·kettle调用外部js
CaptainDrake8 分钟前
Vue:指令
前端·javascript·vue.js
软件技术NINI12 分钟前
HTML——基本标签
前端·javascript·html
卡兰芙的微笑36 分钟前
get_property --Cmakelist之中
前端·数据库·编辑器
覆水难收呀38 分钟前
三、(JS)JS中常见的表单事件
开发语言·前端·javascript
猿来如此呀1 小时前
运行npm install 时,卡在sill idealTree buildDeps没有反应
前端·npm·node.js
hw_happy1 小时前
解决 npm ERR! node-sass 和 gyp ERR! node-gyp 报错问题
前端·npm·sass
FHKHH1 小时前
计算机网络第二章:作业 1: Web 服务器
服务器·前端·计算机网络
视觉小鸟1 小时前
【JVM安装MinIO】
前端·jvm·chrome
二川bro2 小时前
【已解决】Uncaught RangeError: Maximum depth reached
前端