背景
需要做一个只包含年月的日期选择器,但目前大多数的日期选择器中,手指滑动使用了better-scroll插件作为移动端滚动插件,需要先安装依赖better-scroll,这无疑会增加项目的体积,所以决定自己实现一个不使用插件的日期选择器。
效果图
先附上效果图
实现
实现思路
- 不使用滚动插件
- 利用 touchmove 实现监听手指滑动
- 用两个列表分别展示年份和月份
- 列表固定三行展示上一年,当前年,下一年的数值
- 滑动的时候,通过判断手指滑动的方向改变数值,实现滑动选择日期
核心代码
布局展示
利用两个<ul>列表分别展示年份和月份;
<ul>中再用三个<li>分别展示过去、现在、未来;
布局代码如下,css样式代码会在后面完整代码中贴出。
<div className="date-picker-warpper">
<div className="date-picker-title">选择日期</div>
<div className="date-selected">{this.state.years.active}年{this.handleDateSelected(this.state.months.active)}月</div>
<div className="change-date">
<ul className="date-year" id="yearBar">
<li>{this.state.years.top}</li>
<li className="active">
{this.state.years.active}
<span className="year">年</span>
</li>
<li>{this.state.years.bottom}</li>
</ul>
<ul className="date-month" id="monthBar">
<li>{this.state.months.top}</li>
<li className="active">
{this.state.months.active}
<span className="month">月</span>
</li>
<li>{this.state.months.bottom}</li>
</ul>
</div>
<div className="date-picker-button">
<button className="cancel" onClick={(e) => { this.props.handleDatePickerClickCancle(); }} >取消</button>
<button className="determine" onClick={(e) => { this.props.handleDetermineClick(dateString); }} >确定</button>
</div>
</div>
布局完成后,效果如下图
监听滑动
- 给年份列表、月份列表添加touchmove监听滑动
- 通过滑动前后screenY的变化,判断手指滑动方向
- 根据滑动方向,对相应数值进行增减操作
- 滑动距离小于20px不操作
yearBar.addEventListener("touchmove", event =>
this.handleDateTouchMove(event, "year")
);
monthBar.addEventListener("touchmove", event =>
this.handleDateTouchMove(event, "month")
);
handleDateTouchMove(event, touchType) {
event.stopPropagation();
event.preventDefault();
let screenY = event.changedTouches[0].screenY;
let stateObj = this.state[`${touchType}s`];
let targets = Object.assign({}, stateObj),
targetChangeY = this[`${touchType}ChangeY`];
// 滑动距离小于20px不操作
if (Math.abs(screenY - targetChangeY) <= 20) {
return;
}
// 当前控件的变化
// 判断手指滑动方向
if (screenY - targetChangeY > 0) {
// 手指下滑
targets.top = this.datePickerSubtraction(touchType, targets.top, 'top');
targets.active = this.datePickerSubtraction(touchType, targets.active, 'active');
targets.bottom = this.datePickerSubtraction(touchType, targets.bottom, 'bottom');
} else {
// 手指上滑
targets.top = this.datePickerAddition(touchType, targets.top, 'top');
targets.active = this.datePickerAddition(touchType, targets.active, 'active');
targets.bottom = this.datePickerAddition(touchType, targets.bottom, 'bottom');
}
if (touchType === "month") { // 年份关联变化
let direction = screenY - targetChangeY > 0 ? "down" : "up"
let updateYear = this.yearUpdate({
currYear: this.state.years.active,
currMonth: this.state.months.active,
direction: direction
});
if (updateYear) {
this.setState({
years: updateYear
});
targets = this.initMonths(updateYear.active, direction);
}
}
this.setState({
[`${touchType}s`]: targets
});
// 将当前滑动后的 screenY 赋值给对应的 ChangeY
this[`${touchType}ChangeY`] = screenY;
}
计算应该展示的值
根据所传入的参数,计算出应该展示的数值
- 统一数值格式,对<10的添0
- 判断临界值
// 计算时间控件下滑时,应该展示的值
datePickerSubtraction(type, val, dateType) {
val = parseInt(val);
if (type == 'year') {
if (val == this.nowYear - this.props.yearLimit) {
val = this.nowYear;
} else {
val--;
}
if (dateType == "active") this.initMonths(val);
} else if (type == 'month') {
if (val == 1) {
val = this.maxMonth;
} else if (val <= 10) {
val = `0${val - 1}`;
} else {
val--;
}
}
return `${val}`;
}
// 计算时间控件上滑时,应该展示的值
datePickerAddition(type, val, dateType) {
val = parseInt(val);
if (type == 'year') {
if (val == this.nowYear) {
val = val - this.props.yearLimit;
} else {
val++;
}
if (dateType == "active") this.initMonths(val);
} else if (type == 'month') {
if (val >= this.maxMonth) {
val = "01";
} else if (val < 9) {
val = `0${val + 1}`;
} else {
val = val + 1;
}
}
return `${val}`;
}
滑动效果如下图所示:
处理年月联动
- 若当前为1月&&手指下滑,则需减年
- 若当前为最大月&&手指上滑,则需减年
// 日期选择器年的关联变化
yearUpdate({
currYear,
currMonth,
direction
}) {
// 年份关联变化
const isJan = currMonth == "01" ? true : false,
isDec = currMonth == this.maxMonth ? true : false;
// 若当前为1月,则需减年
if (isJan && direction == "down") {
const active = this.datePickerSubtraction("year", currYear, "active"),
top = this.datePickerSubtraction("year", active, "bottom");
return {
top,
active,
bottom: currYear
};
} else if (isDec && direction == "up") {
// 若当前为最大月,则需加年
const active = this.datePickerAddition("year", currYear, "active"),
bottom = this.datePickerAddition("year", active, "top");
return {
top: currYear,
active,
bottom
};
}
return null;
}
这里的最大月不一定是12月,若年份为当前日期年,最大月则为当前日期月
if (this.activeYear == this.nowYear) {
this.maxMonth = this.nowMonth;
} else {
this.maxMonth = 12;
}
年月联动效果如下图所示:
完整代码
附上完整代码
import React, { Component } from 'react';
import PropTypes from 'prop-types';
class DatePickerComponent extends Component {
constructor(props) {
super(props);
this.now = new Date(),
this.nowYear = this.now.getFullYear(),
this.nowMonth = this.now.getMonth() + 1 > 9 ? this.now.getMonth() + 1 : `0${this.now.getMonth() + 1}`;
this.maxMonth = this.nowMonth;
this.activeYear = this.nowYear;
this.yearChangeY = 0;
this.monthChangeY = 0;
this.state = {
datePickerPanelFlag: 'none',
years: {
top: this.datePickerSubtraction("year", this.nowYear, 'top'),
active: this.nowYear,
bottom: this.datePickerAddition("year", this.nowYear, 'bottom')
},
months: {
top: this.datePickerSubtraction("month", this.nowMonth, 'top'),
active: this.nowMonth,
bottom: this.datePickerAddition("month", this.nowMonth, 'bottom')
},
};
}
// 计算时间控件下滑时,应该展示的值
datePickerSubtraction(type, val, dateType) {
val = parseInt(val);
if (type == 'year') {
if (val == this.nowYear - this.props.yearLimit) {
val = this.nowYear;
} else {
val--;
}
if (dateType == "active") this.initMonths(val);
} else if (type == 'month') {
if (val == 1) {
val = this.maxMonth;
} else if (val <= 10) {
val = `0${val - 1}`;
} else {
val--;
}
}
return `${val}`;
}
// 计算时间控件上滑时,应该展示的值
datePickerAddition(type, val, dateType) {
val = parseInt(val);
if (type == 'year') {
if (val == this.nowYear) {
val = val - this.props.yearLimit;
} else {
val++;
}
if (dateType == "active") this.initMonths(val);
} else if (type == 'month') {
if (val >= this.maxMonth) {
val = "01";
} else if (val < 9) {
val = `0${val + 1}`;
} else {
val = val + 1;
}
}
return `${val}`;
}
//初始化日期选择器月份值
initMonths(activeYear, direction) {
if (!this.state) return;
this.activeYear = activeYear;
if (this.activeYear == this.nowYear) {
this.maxMonth = this.nowMonth;
} else {
this.maxMonth = 12;
}
let stateMonths = this.state.months,
months = Object.assign({}, stateMonths);
if (direction && direction === 'up') {
months.active = '01';
months.top = this.maxMonth;
months.bottom = '02';
} else {
months.active = this.maxMonth;
months.top = this.maxMonth - 1;
months.top = months.top > 9 ? months.top : `0${months.top}`;
months.bottom = '01';
}
this.setState({
months: months,
})
return months;
}
// 日期选择器年的关联变化
yearUpdate({
currYear,
currMonth,
direction
}) {
// 年份关联变化
const isJan = currMonth == "01" ? true : false,
isDec = currMonth == this.maxMonth ? true : false;
// 若当前为1月,则需减年
if (isJan && direction == "down") {
const active = this.datePickerSubtraction("year", currYear, "active"),
top = this.datePickerSubtraction("year", active, "bottom");
return {
top,
active,
bottom: currYear
};
} else if (isDec && direction == "up") {
// 若当前为最大月,则需加年
const active = this.datePickerAddition("year", currYear, "active"),
bottom = this.datePickerAddition("year", active, "top");
return {
top: currYear,
active,
bottom
};
}
return null;
}
datePickerScrollInit() {
const yearBar = document.getElementById('yearBar'),
monthBar = document.getElementById('monthBar');
// 绑定事件
yearBar.addEventListener("touchmove", event =>
this.handleDateTouchMove(event, "year")
);
monthBar.addEventListener("touchmove", event =>
this.handleDateTouchMove(event, "month")
);
yearBar.addEventListener("touchstart", event => {
this.yearChangeY = event.changedTouches[0].screenY;
});
monthBar.addEventListener("touchstart", event => {
this.monthChangeY = event.changedTouches[0].screenY;
});
}
handleDateTouchMove(event, touchType) {
event.stopPropagation();
event.preventDefault();
let screenY = event.changedTouches[0].screenY;
let stateObj = this.state[`${touchType}s`];
let targets = Object.assign({}, stateObj),
targetChangeY = this[`${touchType}ChangeY`];
// 滑动距离小于20px不操作
if (Math.abs(screenY - targetChangeY) <= 20) {
return;
}
// 当前控件的变化
// 判断手指滑动方向
if (screenY - targetChangeY > 0) {
// 手指下滑
targets.top = this.datePickerSubtraction(touchType, targets.top, 'top');
targets.active = this.datePickerSubtraction(touchType, targets.active, 'active');
targets.bottom = this.datePickerSubtraction(touchType, targets.bottom, 'bottom');
} else {
// 手指上滑
targets.top = this.datePickerAddition(touchType, targets.top, 'top');
targets.active = this.datePickerAddition(touchType, targets.active, 'active');
targets.bottom = this.datePickerAddition(touchType, targets.bottom, 'bottom');
}
if (touchType === "month") { // 年份关联变化
let direction = screenY - targetChangeY > 0 ? "down" : "up"
let updateYear = this.yearUpdate({
currYear: this.state.years.active,
currMonth: this.state.months.active,
direction: direction
});
if (updateYear) {
this.setState({
years: updateYear
});
targets = this.initMonths(updateYear.active, direction);
}
}
this.setState({
[`${touchType}s`]: targets
});
// 将当前滑动后的 screenY 赋值给对应的 ChangeY
this[`${touchType}ChangeY`] = screenY;
}
handleDateSelected(value) {
if (value) {
value = String(value);
return value.replace(/\b(0+)/gi, "");
}
}
dateReceive() {
let { defaultYear, defaultMonth } = this.props;
this.activeYear = defaultYear;
let stateMonths = this.state.months,
stateYears = this.state.years,
months = Object.assign({}, stateMonths),
years = Object.assign({}, stateYears);
years.active = defaultYear;
if (years.active != this.nowYear) this.maxMonth = 12;
years.top = this.datePickerSubtraction("year", defaultYear, 'top');
years.bottom = this.datePickerAddition("year", defaultYear, 'bottom');
months.active = defaultMonth;
months.top = this.datePickerSubtraction("month", defaultMonth, 'top');
months.bottom = this.datePickerAddition("month", defaultMonth, 'bottom');
this.setState({
years: years,
months: months,
})
}
handleBody() {
document.body.style.overflow = 'hidden';
document.getElementsByTagName('html')[0].style.overflow = 'hidden';
}
handleBodycancel() {
document.body.style.overflow = '';
document.getElementsByTagName('html')[0].style.overflow = '';
}
listenModalClick() {
let modal = document.getElementById("modal");
modal.addEventListener("click", e => {
if (e.target.className !== "ui-modal") return;
this.props.handleDatePickerClickCancle();
}, false)
}
componentDidMount() {
this.datePickerScrollInit();
this.dateReceive();
this.listenModalClick();
this.handleBody();
}
componentWillUnmount() {
this.handleBodycancel();
}
render() {
const { datePickerPanelFlag } = this.props;
let dateString = this.state.years.active + this.state.months.active;
let datePickerPanel = <div className="ui-modal" id="modal" >
<div className="date-picker-warpper">
<div className="date-picker-title">选择日期</div>
<div className="date-selected">{this.state.years.active}年{this.handleDateSelected(this.state.months.active)}月</div>
<div className="change-date">
<ul className="date-year" id="yearBar">
<li>{this.state.years.top}</li>
<li className="active">
{this.state.years.active}
<span className="year">年</span>
</li>
<li>{this.state.years.bottom}</li>
</ul>
<ul className="date-month" id="monthBar">
<li>{this.state.months.top}</li>
<li className="active">
{this.state.months.active}
<span className="month">月</span>
</li>
<li>{this.state.months.bottom}</li>
</ul>
</div>
<div className="date-picker-button">
<button className="cancel" onClick={(e) => { this.props.handleDatePickerClickCancle(); }} >取消</button>
<button className="determine" onClick={(e) => { this.props.handleDetermineClick(dateString); }} >确定</button>
</div>
</div>
</div>;
return (<div>
{datePickerPanel}
</div>
);
}
}
DatePickerComponent.PropTypes = {
datePickerPanelFlag: PropTypes.string.datePickerPanelFlag,
defaultYear: PropTypes.string.defaultYear,
defaultMonth: PropTypes.string.defaultMonth,
yearLimit: PropTypes.number.yearLimit
};
export default DatePickerComponent;
样式
@defaultSize: 16
.date-picker-warpper {
width: 100%;
height: unit(369/@defaultSize, rem);
background: rgba(255, 255, 255, 1);
border-radius: unit(19/@defaultSize, rem) unit(19/@defaultSize, rem) 0px 0px;
position: fixed;
bottom: 0;
.date-picker-title {
text-align: center;
height: unit(22/@defaultSize, rem);
margin-top: unit(27/@defaultSize, rem);
font-size: unit(16/@defaultSize, rem);
font-weight: bold;
color: rgba(51, 59, 76, 1);
line-height: unit(22/@defaultSize, rem);
}
.date-selected {
text-align: center;
margin-top: unit(5/@defaultSize, rem);
margin-bottom: unit(17/@defaultSize, rem);
font-size: unit(13/@defaultSize, rem);
color: rgba(51, 59, 76, 1);
line-height: unit(18/@defaultSize, rem);
height: unit(18/@defaultSize, rem);
}
.change-date {
display: flex;
height: unit(174/@defaultSize, rem);
justify-content: space-between;
.date-year,
.date-month {
width: 50%;
position: relative;
color: rgba(178, 181, 187, 1);
text-align: center;
font-size: unit(17/@defaultSize, rem);
li {
line-height: rem(58px);
span {
display: inline-block;
color: rgba(17, 110, 255, 1);
font-size: unit(10/@defaultSize, rem);
vertical-align: text-top;
margin-left: unit(5/@defaultSize, rem);
}
}
}
.date-year {
li {
padding: unit(16/@defaultSize, rem) 0 unit(15/@defaultSize, rem) unit(60/@defaultSize, rem);
}
.active {
font-size: unit(19/@defaultSize, rem);
color: rgba(17, 110, 255, 1);
padding: unit(16/@defaultSize, rem) 0 unit(15/@defaultSize, rem) unit(75/@defaultSize, rem);
}
}
.date-month {
li {
padding: unit(16/@defaultSize, rem) unit(60/@defaultSize, rem) unit(15/@defaultSize, rem) 0;
}
.active {
font-size: unit(19/@defaultSize, rem);
color: rgba(17, 110, 255, 1);
padding: unit(16/@defaultSize, rem) unit(42/@defaultSize, rem) unit(15/@defaultSize, rem) 0;
}
}
}
.date-text {
line-height: unit(58/@defaultSize, rem);
height: unit(58/@defaultSize, rem);
font-size: unit(17/@defaultSize, rem);
color: rgba(178, 181, 187, 1);
}
.date-text-current {
line-height: unit(58/@defaultSize, rem);
height: unit(58/@defaultSize, rem);
font-size: unit(19/@defaultSize, rem);
color: rgba(17, 110, 255, 1);
}
.date-unit {
font-size: unit(10/@defaultSize, rem);
color: rgba(17, 110, 255, 1);
line-height: unit(13/@defaultSize, rem);
}
.date-picker-button {
button {
text-align: center;
width: unit(147/@defaultSize, rem);
height: unit(47/@defaultSize, rem);
line-height: unit(47/@defaultSize, rem);
background: rgba(245, 245, 246, 1);
border-radius: unit(23/@defaultSize, rem);
font-size: unit(15/@defaultSize, rem);
}
.cancel {
position: absolute;
left: unit(27/@defaultSize, rem);
}
.determine {
position: absolute;
right: unit(27/@defaultSize, rem);
color: rgba(17, 110, 255, 1);
}
}
}
使用
<DatePickerComponent
handleDatePickerClickCancle={this.handleDatePickerClickCancle.bind(this)}//点击取消
handleDetermineClick={this.handleDetermineClick.bind(this)}//点击确定
datePickerPanelFlag={this.state.datePickerPanelFlag}//控制显示隐藏
defaultYear={this.defaultYear}//默认展示年份
defaultMonth={this.defaultMonth}//默认展示月份
yearLimit={5}//年份限度
/>
总结
用此方法实现移动端日期选择器,虽然避免了使用插件,但是导致滑动时的手感偏差,用户体验方面有待完善。