likes
comments
collection
share

手撸一个Calendar日历组件

作者站长头像
站长
· 阅读数 10

又到了手撸小专题,这次我们把目光投向Calendar日历组件。这个组件也是一个高频组件,只不过我们很少有去实现它的机会,一般都是拿来主义。这次,小编带你近距离的感受一下。

一、本次实现的功能

  • 日历区间选择功能。
  • 日历单选功能。
  • 日历单选日期时间功能。

别看我们只实现了这3个功能,麻雀虽小,五脏俱全。

二、本次实现说明

本次使用react框架实现,项目基于create-react-app脚手架创建,时间库使用moment第三方库。

三、画日期面板

首先我们要把日历的面板画出来,大致样式如下:

手撸一个Calendar日历组件

日历一般都是这样的设计,想要获取日历面板,我们可以直观的感受到,我们需要做的事情有2个,分别是

  • 获取指定年月的总天数。
  • 第一天如果不是周日,则需要向前补空格。
  • 最后一天即使不是周六,我们也不需要向后补空格。
import moment from 'moment';

// 将周几转换为数字
export const transformWeekdayToNumber = (value) => {
    if (value === 'Sunday'){ return 0 }
    if (value === 'Monday'){ return 1 }
    if (value === 'Tuesday'){ return 2 }
    if (value === 'Wednesday'){ return 3 }
    if (value === 'Thursday'){ return 4 }
    if (value === 'Friday'){ return 5 }
    if (value === 'Saturday'){ return 6 }
}

export const getMonthDayOfYear = (year, month) => {
    // 获取当前年
    let curYear = year || moment().format().split('-')[0];
    // 当前月(默认当前月)
    /**
     * title: 月份
     * 值:  0   ->  11
     * 含义:1月  ->  12月
     */
    let curMonth = month || (new Date().getMonth() + 1);
    // 获取当前月的天数
    let curMonthContainDaysNumber = moment(`${curYear}-0${curMonth}`, "YYYY-MM").daysInMonth();
    let result = [];
    /**
     * title: 星期几
     * 日 一 二 三 四 五 六
     * 0  1  2  3  4  5  6
     */
    for (let index = 1; index <= curMonthContainDaysNumber; index++){
        let obj = {};
        obj.weekDay = moment(`${curYear}-${curMonth >= 10 ? curMonth : '0' + curMonth}-${index >= 10 ? index : '0' + index}`, "YYYY-MM-DD").format('dddd');
        obj.weekDayToNumber = transformWeekdayToNumber(obj.weekDay);
        obj.value = index;
        result.push(obj);
    }
    if (result[0]?.weekDayToNumber !== 0){
        // 需要向前补多余的空格
        for (let index = result[0]?.weekDayToNumber; index > 0; index--){
            result.unshift({});
        }
    }
    return [{
        curYear,
        curMonth,
        days: result
    }];
}

此时我们将这个工具方法引入到我们的自定义组件里看一下效果。自定义组件CustomCalendar的代码如下:

import React, { useEffect } from 'react';
import { getMonthDayOfYear } from './utils';

function CustomCalendar(){
    useEffect(
        () => {
            console.log('当前年月对应的总天数:', getMonthDayOfYear());
        }
    )
    return <div>
        自定义日历组件
    </div>
}

export default CustomCalendar;

手撸一个Calendar日历组件

我们可以看到,我们的这个工具方法可以很好的把当前年月对应的总天数获取到。有了这个,我们可以直接将当前的日历面板绘画出来。

修改组件内容如下:

import React, { useEffect, useState } from 'react';
import moment from 'moment';
import { getMonthDayOfYear } from './utils';
import './index.css';

function CustomCalendar(){
    let [dayTitle, setDayTitle] = useState(
        [
            { name: '日', key: 'Sunday' },
            { name: '一', key: 'Monday' },
            { name: '二', key: 'Tuesday' },
            { name: '三', key: 'Wednesday' },
            { name: '四', key: 'Thursday' },
            { name: '五', key: 'Friday' },
            { name: '六', key: 'Saturday' },
        ]
    );
    let [allMonthArr, setAllMonthArr] = useState(getMonthDayOfYear());
    let [curYear, setCurYear] = useState(moment().format().split('-')[0]);
    let [curMonth, setCurMonth] = useState(new Date().getMonth() + 1);

    return <div>
        <div className = 'custom-calendar'>
            <div className = 'custom-calendar-head'>
                {
                    `${curYear}年${curMonth}月`
                }
            </div>
            <div className = 'custom-calendar-body'>
                <div className = 'day-title'>
                    {
                        dayTitle.map(item => {
                            return <div className = 'day-title-cell'>{item.name}</div>
                        })
                    }
                </div>
                <div className = 'show-month-contain-days-box'>
                    {
                        allMonthArr.map(item => {
                            return <div className = 'month-box'>
                                <div className = 'month-title-days'>
                                    {
                                        (item?.days || []).map(cell => {
                                            return <div 
                                                className = {`
                                                    cell-box
                                                `} 
                                                onClick={() => this.clickCellValue(cell)}
                                            >
                                                {cell.value}
                                            </div>
                                        })
                                    }
                                </div>
                            </div>
                        })
                    }
                </div>
            </div>
        </div>
    </div>
}

export default CustomCalendar;

样式文件如下:

.custom-calendar {
    width: 300px;
    height: 321px;
    box-sizing: border-box;
    border-radius: 8px;
    border: 1px solid rgb(240, 240, 240);
    margin-top: 50px;
    margin-left: 500px;
}
.custom-calendar-head {
    width: 100%;
    height: 50px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: center;
}
.custom-calendar-body {
    width: 100%;
    height: calc(100% - 50px);
    border-radius: 0 0 8px 8px;
    border-top: 1px solid rgba(5, 5, 5, 0.06);
}
.day-title {
    width: 100%;
    height: 30px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: flex-start;
}
.day-title-cell {
    flex: 1;
}
.show-month-contain-days-box {
    width: 100%;
    height: calc(100% - 30px);
    box-sizing: border-box;
}
.month-box, .month-title-days {
    width: 100%;
    height: 100%;
    box-sizing: border-box;
}
.month-title-days {
    display: flex;
    justify-content: flex-start;
    flex-wrap: wrap;
}
.cell-box {
    min-width: 14%;
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
}
.selected-cell-box {
    background: #3371FF;
    border-radius: 8px;
    color: #FFFFFF;
}
.range-cell {
    background: rgba(51,113,255,0.15);
    color: #111111;
}

小伙伴们可以直接将代码copy下来,并运行在自己的本地,如果没错的话,此时的面板应该是下面这样:

手撸一个Calendar日历组件

到这里,我们已经完成了面板的绘画

四、确定组件属性

现在我们已经把静态的架子搭起来了,此时我们需要思考一下这个组件的整体能力。

按照我们实现的能力来看,我们似乎就需要2个属性就可以。如下:

属性说明
type组件类型:单选(one)、区间(range)
pickTime组件是否需要选择时间

五、实现区间选择功能

当面板里的单元格被点击时,此时的逻辑如下:

  • 判断当前点击次数,如果是1,说明此时的单元格是开始日期。如果是2,则需要进行判断,判断当前单元格是开始日期,还是结束日期。
  • 如果当前点击的次数是3,则需要重制当前点击次数,并且重置区间。
  • 处理单元格的颜色。如果当前单元格没有形成区间,那么点击的单元格就应该有一个颜色a。如果当前的单元格形成了区间,那么不仅点击的单元格有一个颜色a,那么区间里面的单元格应该也有一个颜色b。

效果如下:

手撸一个Calendar日历组件

经过上面的分析,我们来给组件添加交互:

function CustomCalendar(){
    // 其余代码不变 -----
    let [clickCount, setCurClickCount] = useState(0); // 点击次数
    let [type, setType] = useState('range'); // 判断组件类型
    let [startDate, setStartDate] = useState(''); // 选中的开始日期
    let [endDate, setEndDate] = useState(''); // 选中的结束日期
    
    const dealClickCellOfRange = (obj) => {
        let self = this;
        // 获取当前点击的次数
        let curClickCount = clickCount + 1;
        // 当前点击的可能是开始日期,也有可能是结束日期
        let curStartDateOrEndStartDate = `${curYear}-${curMonth}-${obj.value}`;
        if (curClickCount === 1){ // 说明是初始点击
            setCurClickCount(curClickCount);
            setStartDate(curStartDateOrEndStartDate);
            return
        }
        if (curClickCount === 2){ // 说明是点击了2次
            // 自动切换终止日期与起止日期
            if (moment(curStartDateOrEndStartDate).isBefore(startDate)){
                setCurClickCount(curClickCount);
                setEndDate(startDate);
                setStartDate(curStartDateOrEndStartDate);
            } else {
                setCurClickCount(curClickCount);
                setEndDate(curStartDateOrEndStartDate);
            }
        }
        if (curClickCount === 3){ // 说明要重新定义区间
            setCurClickCount(0);
            setEndDate('');
            setStartDate('')
        }
    }

    // 点击面板里的值
    const clickCellValue = (obj) => {
        if(type === 'one'){
            dealClickCellOfOne(obj);
        } else {
            dealClickCellOfRange(obj);
        }
    }
    
    // 判断当前单元格的样式
    const judgeCellBackground = (val1, val2) => {
        if (type === 'range'){
            if (val1 === val2){
                return 'selected-cell-box'
            } else {
                return ''
            }
        }
    }

    // 判断当前cell是否在区间里,并且不是2边的节点
    const judgeCellInRange = (obj) => {
        if (type === 'one'){
            return '';
        }
        let curStartDateOrEndStartDate = `${curYear}-${curMonth}-${obj.value}`;
        if (clickCount == 2){ // 如果形成区间
            if (moment(curStartDateOrEndStartDate).isBetween(startDate, endDate)){
                if (curStartDateOrEndStartDate !== startDate && curStartDateOrEndStartDate !== endDate){
                    return 'range-cell';
                }
            }
        }
        return '';
    }
    
    return <div>
        <div className = 'custom-calendar'>
            {/** 其余代码不变, 只改变cell单元格 */}
            {
                ...
                    <div 
                        className = {`
                            cell-box
                            ${judgeCellBackground(startDate, `${curYear}-${curMonth}-${cell.value}`)}
                            ${judgeCellBackground(endDate, `${curYear}-${curMonth}-${cell.value}`)}
                            ${judgeCellInRange(cell)}
                        `} 
                        onClick={() => clickCellValue(cell)}
                    >
                ...
            }
        </div>
    </div>
    
}

export default CustomCalendar;

六、实现日期单选功能

在上面,我们通过点击次数的概念,定义 startDate + endDate来实现了区间的功能。单选的这个功能相信它已经不是问题了。我们只要忽略点击次数的概念即可。逻辑如下:

  • type == 'one',忽略点击次数。
  • 新增一个绑定单选的变量 curSelectedDateObj,每次单选都给这个变量赋值。
  • 给当前点击的单元格添加颜色。

我们先来看一下效果:

手撸一个Calendar日历组件

修改组件交互如下:

function CustomCalendar(){

    // 其余代码不变------

    // 修改组件的type默认值为“one”
    let [type, setType] = useState('one'); // 判断组件类型
    
    //单选时绑定的值
    let [curSelectedDateObj, setCurSelectedDateObj] = useState({
        value: '',
        name: ''
    });
    // 单选时,点击面板单元格的处理
    const dealClickCellOfOne = (obj) => {
        let curSelectedDate = `${curYear}-${curMonth}-${obj.value}`;
        setCurSelectedDateObj({
            value: curSelectedDate,
            name: moment(curSelectedDate).format('YYYY-MM-DD')
        });
    }
    // 判断当前单元格的样式
    const judgeCellBackground = (val1, val2) => {
        if (type === 'one'){
            if (val2 === curSelectedDateObj.value){
                return 'selected-cell-box'
            }
            return '';
        }
        if (type === 'range'){
            if (val1 === val2){
                return 'selected-cell-box'
            } else {
                return ''
            }
        }
    }
    // 其余代码不变------
    return (...)
}

七、实现单选日期时间功能

7.1、完成时间面板的吸附功能

在前面的讲解中,我们已经实现了“区间选择”、“单选日期”的功能。我们这次来看下,在“单选日期”的基础上,如何添加选择时间的功能。

还是老样子,先看一下时间面板的大致样式:

手撸一个Calendar日历组件

三个柱子,分别是时、分、秒。选中的单元格始终在各自柱子的最上方。

时间面板与前面的日期面板的关系如下:

手撸一个Calendar日历组件

确定了这个交互以后,我们首先来画一下时间面板,还是跟上面一样,先看一眼咱们实现的效果:

手撸一个Calendar日历组件

看到这个实现效果,我们先来捋清一下时间面板的渲染逻辑:

  • 得到三个柱子的value集合。
  • 新增3个变量 curSelectedHourcurSelectedMinutecurSelectedSecond,选中时分秒后,分别赋值。当然,他们3个都会有默认值'00'。
  • 每个柱子的里的区块颜色都只有2种,要么是选中,要么是没选中

修改组件代码如下:

// 创建小时、分钟、秒集合
function createTimeOfHourArr(final){
    let result = [];
    for (let index = 0; index <= final; index++){
        result.push(index >= 10 ? `${index}` : `0${index}`);
    }
    return result;
}

function CustomCalendar(){
    // 其余代码不变------
    let [curSelectedHour, setCurSelectedHour] = useState('00'); // 当前选中的小时
    let [curSelectedMinute, setCurSelectedMinute] = useState('00'); // 当前选中的分钟
    let [curSelectedSecond, setCurSelectedSecond] = useState('00'); // 当前选中的秒
    let [timeModalObj, setTimeModalObj] = useState({ // 小时、分钟、描述渲染的集合
        hourArr: createTimeOfHourArr(23),
        minuteArr: createTimeOfHourArr(59),
        secondArr: createTimeOfHourArr(59)
    });
    
    return <div>
        {/** 日期看板代码不变 */}
        <div className='time-box'>
            {/* 时间body */}
            <div className = 'time-body'>
                <div className = 'time-body-hour' ref={(value) => getScrollRef(value, 'hour')}>
                    {
                        timeModalObj.hourArr?.map((item, index) => {
                            return <div 
                                className = {`time-cell ${item === curSelectedHour ? 'selected-time-cell' : ''}`}
                            >
                                {item}
                            </div>
                        })
                    }
                    <div className = 'white-split'></div>
                </div>
                <div className = 'time-body-minute' ref={(value) => getScrollRef(value, 'minute')}>
                    {
                        timeModalObj.minuteArr?.map((item, index) => {
                            return <div 
                                className = {`time-cell ${item === curSelectedMinute ? 'selected-time-cell' : ''}`}
                            >
                                {item}
                            </div>
                        })
                    }
                    <div className = 'white-split'></div>
                </div>
                <div className = 'time-body-second' ref={(value) => getScrollRef(value, 'second')}>
                    {
                        timeModalObj.secondArr?.map((item, index) => {
                            return <div 
                                className = {`time-cell ${item === curSelectedSecond ? 'selected-time-cell' : ''}`}
                            >
                                {item}
                            </div>
                        })
                    }
                    <div className = 'white-split'></div>
                </div>
            </div>
        </div>
    </div>
}

新增样式如下:

.time-box {
    width: 300px;
    height: 321px;
    box-sizing: border-box;
    border-radius: 8px;
    border: 1px solid rgb(240, 240, 240);
    margin-top: 50px;
}

.time-body {
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    display: flex;
}

.time-body-hour, .time-body-minute, .time-body-second {
    width: 33%;
    height: 100%;
    flex: 1;
    box-sizing: border-box;
    overflow-y: scroll;
}

.time-cell {
    width: 100%;
    height: 40px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.41rem;
    font-family: PingFangSC-Medium, PingFang SC;
    font-weight: 500;
    color: #333333;
}
.selected-time-cell {
    background: #E1EAFF !important;
}
.white-split {
    width: 100%;
    height: 300px;
    box-sizing: border-box;
}

目前为止,我们就剩下最后2步了,分别是给 时间面板添加滚动效果控制时间面板的显示与隐藏。我们之前说过,选中的“时分秒”一定是在面板的最上层,相当于吸附顶端,因为“时分秒”的3个柱子是滚动的,所以我们可以利用父容器 scrollTo 方法来完成这个“吸附”功能。

但是这里还有一个问题,就是下面这样:

手撸一个Calendar日历组件

因为我们使用scrollTo来完成区块的“吸附”效果,所以当我们选中最后面几个区块的时候,并不能完成吸附效果,因为他们已经是父容器里的最底部了。所以为了解决这个问题,我在上面特意加了一个元素(white-split元素),高度至少是一页的高度。

有了上面的逻辑分析,我们来完成剩下的代码。

添加交互代码如下:

import React, { useEffect, useRef, useState } from 'react';

function CustomCalendar(){
    let [isSelectedPickTime, setIsSelectedPickTime] = useState(true);  // 是否出现选择时间面板
    let timeBodyHour = useRef(null);          // 小时dom容器(控制容器滚动)
    let timeBodyMinute = useRef(null);        // 分钟dom容器
    let timeBodySecond = useRef(null);        // 秒dom容器
    
    // 选中时间(flag: hour、minute、second), 将选中块置顶
    const clickTimeCell = (flag, value, index) => {
        // 获取每个块的高度
        let height = timeBodyHour.children[0]?.getBoundingClientRect()?.height || 0;
        if (flag === 'hour'){
            // 如果点击的是“小时区域”
            setCurSelectedHour(value);
            timeBodyHour.scrollTo(0, index * height);
        }
        if (flag === 'minute'){
            // 如果点击的是“分钟区域”
            setCurSelectedMinute(value);
            timeBodyMinute.scrollTo(0, index * height);
        }
        if (flag === 'second'){
            // 如果点击的是“秒区域”
            setCurSelectedSecond(value);
            timeBodySecond.scrollTo(0, index * height);
        }
    }
    
    // 容器滚动方法
    const getScrollRef = (value, flag) => {
        if (flag === 'hour' && isSelectedPickTime){
            timeBodyHour = value;
            let hourIndex = timeModalObj.hourArr.indexOf(curSelectedHour);
            if (timeBodyHour){
                let height = timeBodyHour.children[0]?.getBoundingClientRect()?.height || 0;
                timeBodyHour.scrollTo(0, hourIndex * height);
            }
        }
        if (flag === 'minute' && isSelectedPickTime){
            timeBodyMinute = value;
            let minuteIndex = timeModalObj.minuteArr.indexOf(curSelectedMinute);
            if (timeBodyMinute){
                let height = timeBodyMinute.children[0]?.getBoundingClientRect()?.height || 0;
                timeBodyMinute.scrollTo(0, minuteIndex * height);
            }
        }
        if (flag === 'second' && isSelectedPickTime){
            timeBodySecond = value;
            let secondIndex = timeModalObj.secondArr.indexOf(curSelectedSecond);
            if (timeBodySecond){
                let height = timeBodySecond.children[0]?.getBoundingClientRect()?.height || 0;
                timeBodySecond.scrollTo(0, secondIndex * height);
            }
        }
    }
    
    return <div>
        {/** ---其余代码不变--- */}
        
        <div className = 'time-body'>
            <div className = 'time-body-hour' ref={(value) => getScrollRef(value, 'hour')}>
                {
                    timeModalObj.hourArr?.map((item, index) => {
                        return <div 
                            className = {`time-cell ${item === curSelectedHour ? 'selected-time-cell' : ''}`}
                            onClick={() => clickTimeCell('hour', item, index)}
                        >
                            {item}
                        </div>
                    })
                }
                <div className = 'white-split'></div>
            </div>
            <div className = 'time-body-minute' ref={(value) => getScrollRef(value, 'minute')}>
                {
                    timeModalObj.minuteArr?.map((item, index) => {
                        return <div 
                            className = {`time-cell ${item === curSelectedMinute ? 'selected-time-cell' : ''}`}
                            onClick={() => clickTimeCell('minute', item, index)}
                        >
                            {item}
                        </div>
                    })
                }
                <div className = 'white-split'></div>
            </div>
            <div className = 'time-body-second' ref={(value) => getScrollRef(value, 'second')}>
                {
                    timeModalObj.secondArr?.map((item, index) => {
                        return <div 
                            className = {`time-cell ${item === curSelectedSecond ? 'selected-time-cell' : ''}`}
                            onClick={() => clickTimeCell('second', item, index)}
                        >
                            {item}
                        </div>
                    })
                }
                <div className = 'white-split'></div>
            </div>
            </div>
        
        {/** ---其余代码不变--- */}
    </div>
    
}

至此,我们完成了选中的时间块自动滚动到相应容器的顶部。效果如下:

手撸一个Calendar日历组件

7.2、最后一公里

我们接着完成剩下的需求:时间面板的出现时机 与 日历组件的值显示

我们新增一个变量isSelectedPickTime,当这个值为true的时候就显示时间面板。具体逻辑如下:

  • 默认值为false。
  • 当type === ‘one’,并且选择了日期面板的值,才将 isSelectedPickTime 的值设为true。
  • 值为true,显示时间面板。

修改代码如下:

let [isSelectedPickTime, setIsSelectedPickTime] = useState(false);

// dealClickCellOfOne方法修改如下
const dealClickCellOfOne = (obj) => {
    let curSelectedDate = `${curYear}-${curMonth}-${obj.value}`;
    setCurSelectedDateObj({
        value: curSelectedDate,
        name: moment(curSelectedDate).format('YYYY-MM-DD')
    });
    setIsSelectedPickTime(true);
}

交互效果如下:

手撸一个Calendar日历组件

手撸一个Calendar日历组件

日历组件的值显示,这个问题的答案就不写了,因为读到这里,小伙伴肯定发现了,我们会根据type的不同,把当前组件的值set给不同的变量,我们只需要根据type来显示相应的变量即可。

八、最后

又到了跟大家说再见的时候了,其实UI组件专题也已经进行了一半,目前我们已经实现了

  • Calendar日历组件
  • 滚动条组件
  • Notification消息提示组件
  • Skeleton骨架屏组件
  • 圆形进度条组件
  • 虚拟列表组件
  • Carousel走马灯组件
  • Countdown倒计时组件
  • Modal对话框组件
  • Switch开关组件
  • Pagination分页器组件
  • Table组件
  • DatePicker日期组件
  • step步骤条组件

这里也提前剧透一下,我已经在着手写引导页组件了,应该会和大家见面,哈哈哈。我忽然蹦出来了一个想法,我好像从没有跟大家互动过,那么在引导页组件之前,欢迎小伙伴们在评论区里打出自己想看的组件,呼声高的,我会优先处理。

那么,这篇文章就到这了?

如果此文对您有帮助,希望得到您的4连认可(点赞收藏评论+关注),我好贪呀,哈哈哈,我们下期再见啦~~