开篇
在 H5 场景下,一个日期选择控件的交互,通常由 Popup
从底部弹出,内容区域展示三列 年、月、日 选项集合列表,通过滑动来选择日期。
效果类似于 ant-design-mobile DatePicker
组件:
要实现这样一款 H5 控件,需要用到哪些知识呢?下面,我们一起分析和动手实现一款 DatePicker
控件,掌握其原理。
下面基于 React 技术栈来实现,思路在不同框架中同样适用。
一、实现分析
分析 ant-design-mobile DatePicker
效果图可以看出:
Popup
组件实现 日期选择器 的 显示与隐藏;- 每一栏数据选项能够支持滑动选择,即实现一个数据选项
PickerView
; - 提供
年月日
三栏选项列表数据,即提供日期columns list
。
其中 Popup
相对简单这里不做过多介绍,接下来我们做的是两件事:
一是实现一个 PickerView
数据选择控件,
二是为 PickerView
提供日期 columns
数据选项集合。
二、实现 PickerView
一个 PickerView
组件包含以下组成部分:
- 主体选项列表,这里我们使用
7行 * 3列
来实现; - 辅助蒙层,中间第 4 行为当前选中高亮区域,其余区域以透明形式存在;
- 滑动交互,每一列的选项列表,能够支持滑动选择。
其中 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;
}
}
};
touchstart
事件:记录初始位置信息;touchmove
事件:计算移动位置,并应用在 column list 元素,实现滑动效果;end
事件:确认出选中的值curIndex
。
到这里,一个 PickerView
组件的交互实现完成,下面我们创建日期的数据选项 columns
,并提供给 PickerView
.
三、日期 columns 选项集合
数据选项列表可以根据外部传入的 minDate
和 maxDate
规则来生成。所以我们给 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()
会根据 minDate
和 maxDate
生成选项列表,根据 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) },
];
};
getMonths
和 getDays
作为工具函数,根据 年 来确定其对应的选项列表。
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