React不使用滚动插件实现移动端日期选择器

2,627 阅读4分钟

背景

需要做一个只包含年月的日期选择器,但目前大多数的日期选择器中,手指滑动使用了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}//年份限度
/>

总结

用此方法实现移动端日期选择器,虽然避免了使用插件,但是导致滑动时的手感偏差,用户体验方面有待完善。