hello大家好,我是小九九的爸爸。这次我们把目光投向Calendar日历组件。这个组件也是一个高频组件,只不过我们很少有去实现它的机会,一般都是拿来主义。这次,小编带你近距离的感受一下。
一、本次实现的功能
- 日历区间选择功能。
- 日历单选功能。
- 日历单选日期时间功能。
别看我们只实现了这3个功能,麻雀虽小,五脏俱全。
二、本次实现说明
本次使用react框架实现,项目基于create-react-app脚手架创建,时间库使用moment第三方库。
三、画日期面板
首先我们要把日历的面板画出来,大致样式如下:
日历一般都是这样的设计,想要获取日历面板,我们可以直观的感受到,我们需要做的事情有2个,分别是:
- 获取指定年月的总天数。
- 第一天如果不是周日,则需要向前补空格。
- 最后一天即使不是周六,我们也不需要向后补空格。
javascript
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的代码如下:
javascript
import React, { useEffect } from 'react';
import { getMonthDayOfYear } from './utils';
function CustomCalendar(){
useEffect(
() => {
console.log('当前年月对应的总天数:', getMonthDayOfYear());
}
)
return <div>
自定义日历组件
</div>
}
export default CustomCalendar;
我们可以看到,我们的这个工具方法可以很好的把当前年月对应的总天数获取到。有了这个,我们可以直接将当前的日历面板绘画出来。
修改组件内容如下:
javascript
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;
样式文件如下:
css
.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下来,并运行在自己的本地,如果没错的话,此时的面板应该是下面这样:
到这里,我们已经完成了面板的绘画。
四、确定组件属性
现在我们已经把静态的架子搭起来了,此时我们需要思考一下这个组件的整体能力。
按照我们实现的能力来看,我们似乎就需要2个属性就可以。如下:
属性 | 说明 |
---|---|
type | 组件类型:单选(one)、区间(range) |
pickTime | 组件是否需要选择时间 |
五、实现区间选择功能
当面板里的单元格被点击时,此时的逻辑如下:
- 判断当前点击次数,如果是1,说明此时的单元格是开始日期。如果是2,则需要进行判断,判断当前单元格是开始日期,还是结束日期。
- 如果当前点击的次数是3,则需要重制当前点击次数,并且重置区间。
- 处理单元格的颜色。如果当前单元格没有形成区间,那么点击的单元格就应该有一个颜色a。如果当前的单元格形成了区间,那么不仅点击的单元格有一个颜色a,那么区间里面的单元格应该也有一个颜色b。
效果如下:
经过上面的分析,我们来给组件添加交互:
javascript
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
,每次单选都给这个变量赋值。 - 给当前点击的单元格添加颜色。
我们先来看一下效果:
修改组件交互如下:
javascript
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、完成时间面板的吸附功能
在前面的讲解中,我们已经实现了"区间选择"、"单选日期"的功能。我们这次来看下,在"单选日期"的基础上,如何添加选择时间的功能。
还是老样子,先看一下时间面板的大致样式:
三个柱子,分别是时、分、秒。选中的单元格始终在各自柱子的最上方。
时间面板与前面的日期面板的关系如下:
确定了这个交互以后,我们首先来画一下时间面板,还是跟上面一样,先看一眼咱们实现的效果:
看到这个实现效果,我们先来捋清一下时间面板的渲染逻辑:
- 得到三个柱子的value集合。
- 新增3个变量 curSelectedHour、curSelectedMinute、curSelectedSecond,选中时分秒后,分别赋值。当然,他们3个都会有默认值'00'。
- 每个柱子的里的区块颜色都只有2种,要么是选中,要么是没选中
修改组件代码如下:
javascript
// 创建小时、分钟、秒集合
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>
}
新增样式如下:
css
.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 方法来完成这个"吸附"功能。
但是这里还有一个问题,就是下面这样:
因为我们使用scrollTo
来完成区块的"吸附"效果,所以当我们选中最后面几个区块的时候,并不能完成吸附效果,因为他们已经是父容器里的最底部了
。所以为了解决这个问题,我在上面特意加了一个元素(white-split元素),高度至少是一页的高度。
有了上面的逻辑分析,我们来完成剩下的代码。
添加交互代码如下:
javascript
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>
}
至此,我们完成了选中的时间块自动滚动到相应容器的顶部。效果如下:
7.2、最后一公里
我们接着完成剩下的需求:时间面板的出现时机
与 日历组件的值显示
。
我们新增一个变量isSelectedPickTime,当这个值为true的时候就显示时间面板。具体逻辑如下:
- 默认值为false。
- 当type === 'one',并且选择了日期面板的值,才将 isSelectedPickTime 的值设为true。
- 值为true,显示时间面板。
修改代码如下:
javascript
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);
}
交互效果如下:
日历组件的值显示
,这个问题的答案就不写了,因为读到这里,小伙伴肯定发现了,我们会根据type的不同,把当前组件的值set给不同的变量,我们只需要根据type来显示相应的变量即可。
八、最后
好啦,本期日历组件分享到这里就结束啦,如果此文对您有帮助,希望得到您的4连认可(点赞收藏评论+关注),如果想观看更多内容,欢迎关注微信公众号:从0前端。我们一起进步,那么下期再见啦~~