手把手实现一个H5版的钉钉日历联动组件高仿1:1

5,453 阅读7分钟

前言

最近公司想要一个需求实现钉钉日历的效果,用过钉钉日历的应该都知道上面的日历这可以切换周日历和月日历点击的时候下面对应的日程滚动,然后日程滚动的时候上面的日历会选中置顶的日程最终是这样一个联动效果,因为日程列表已经接入了公司的业务代码,所以本次可能只有日历组件的实现流程,大概是下面这个样子搞了一个动图给各位看官观赏一下🌺

1.gif

准备工作

先把需要用到包文件下好 npm i -D dayjs,这个包主要用来处理日历的渲染逻辑

安排一个节流函数之后会用到

const throttle = (fun: (val: any) => void, time: number) => {
  let delay = 0
  return (...params: any) => {
    const now = +new Date()
    if (now - delay > time) {
      fun.apply(this, params)
      delay = now
    }
  }
}

因为用到了tsx,所以先把类型都贴上来一会下面就直接用了

type weekType = {
  id: number,
  name: string
}
type toutchType = { x: number, y: number }
​
type dayjsType = Array<dayjs.Dayjs>
​
type generateWeekDataType = {
  currenWeekFirstDay: dayjs.Dayjs,
  generateWeekDateList: Array<dayjsType>
}
​
type generateMonthDataType = {
  currentMonthFirstDay: dayjs.Dayjs,
  generateMonthDateList: Array<dayjsType>
}

两个函数一个用来生成渲染的月日历 generateMonthData、一个用来生成渲染的周日历generateWeekData

顺便说一下日历生成的逻辑,通过dayjsDate获取当前月份,之后使用startOf获取第一周第一天

因为我们需要渲染一个月的所有日期包括在当月显示的上个月的日期和在当月显示的下一个月的日期

WechatIMG704.jpeg

如果直接使用当前月份,那么相当于是从当前月份的1号开始的,获取不到前一个月的日期 generateMonthData每次返回三个数组代表当前月份已经上一个月和下一个月我们渲染只渲染当前月份,之后每次滑动日历也会继续根据当前传进来的月份生成当月和上一个月以及下一个月 周日历的生成逻辑类似

generateMonthData

/**
 *
 * @param {*} dayjsDate dayjs对象
 */
const generateMonthData = (dayjsDate: dayjs.Dayjs): generateMonthDataType => {
  //返回当前月份的第一天
  const currentMonthFirstDay = dayjsDate.startOf('month')
  // 返回当月的第一周第一天
  const currentMonthStartDay = currentMonthFirstDay.startOf('week')

  //上一个月
  const prevMonthFirstDay = currentMonthFirstDay.subtract(1, 'month')
  const prevMonthStartDay = prevMonthFirstDay.startOf('week')

  //下一个月
  const nextMonthFirstDay = currentMonthFirstDay.add(1, 'month')
  const nextMonthStartDay = nextMonthFirstDay.startOf('week')

  return {
    currentMonthFirstDay,
    generateMonthDateList: [
      new Array(42).fill('').map((_, index) => prevMonthStartDay.add(index, 'day')),
      new Array(42).fill('').map((_, index) => currentMonthStartDay.add(index, 'day')),
      new Array(42).fill('').map((_, index) => nextMonthStartDay.add(index, 'day')),
    ],
  }
}

generateWeekData

/**
 *
 * @param {*} dayjsDate dayjs对象
 */
const generateWeekData = (dayjsDate: dayjs.Dayjs): generateWeekDataType => {
  const currenWeekStartDay = dayjsDate.startOf('week')
  const prevWeekStartDay = currenWeekStartDay.subtract(1, 'week')
  const nextWeekStartDay = currenWeekStartDay.add(1, 'week')
  return {
    currenWeekFirstDay: currenWeekStartDay,
    generateWeekDateList: [
      new Array(7).fill('').map((_, index) => prevWeekStartDay.add(index, 'day')),
      new Array(7).fill('').map((_, index) => currenWeekStartDay.add(index, 'day')),
      new Array(7).fill('').map((_, index) => nextWeekStartDay.add(index, 'day')),
    ],
  }
}

正题

因为我们业务的关系布局我分成了两部分目前只留下了日历组件的部分

const ScheduleList = () => {
  return <>
    <div className='schedule_view_wrap'>
      <div className='schedule_list_section'>
        {/* 日历组件 */}
        <CalendarComp />
      </div>
    </div>
  </>
}
export default ScheduleList

看看<CalendarComp/>具体逻辑,接下来的声明以及逻辑代码都是在这个组件中,大部分函数和变量都会有注释

CalendarComp组件

const CalendarComp = () => {
}

useRef

const { current } = useRef({
    currentDate: dayjs().format('YYYY-MM-DD'), //获取今日
    isTouch: false, //控制滑动动画
    touchStartX: 0, //判断滑动位置
    touchStartY: 0,
    calendarRef: { offsetWidth: 0 } //判断滑动位置
})

useState

  const dayjsDate = dayjs(current.currentDate) //将当日日期转化为dayjs对象准备获取渲染的月日历
  const { currentMonthFirstDay, generateMonthDateList = [] } = generateMonthData(dayjsDate)
  const { currenWeekFirstDay, generateWeekDateList = [] } = generateWeekData(dayjsDate)

  const [mounthFirstDay, setMounthFirstDay] = useState<dayjs.Dayjs>(currentMonthFirstDay) //月日历第一天
  const [mountDateList, setMountDateList] = useState<Array<dayjsType>>(generateMonthDateList)// 月日历需要展示的日期 包括前一月 当月 下一月
  const [weekFirstDay, setWeekFirstDay] = useState<dayjs.Dayjs>(currenWeekFirstDay)
  const [weekDateList, setWeekDateList] = useState<Array<dayjsType>>(generateWeekDateList)// 周日历需要展示的日期  包括前一周 当周 下一周
  const [selectDate, setSelectDate] = useState<string>(current.currentDate) //设置选中的day的样式
  const [moveIndex, setMoveIndex] = useState<number>(0) //记录日历滑动的位置
  const [touch, setTouch] = useState<toutchType>({ x: 0, y: 0 }) //手指滑动的位置
  const [isMountView, setIsMountView] = useState<boolean>(false) //true/周日历 false/月日历
  const [weekInd, selectWeekInd] = useState<number>(0) //记录周日历选中的index

weekList

const weekList: Array<weekType> = [{
    id: 0,
    name: '日'
  }, {
    id: 1,
    name: '一'
  }, {
    id: 2,
    name: '二'
  }, {
    id: 3,
    name: '三'
  }, {
    id: 4,
    name: '四'
  }, {
    id: 5,
    name: '五'
  }, {
    id: 6,
    name: '六'
  }]

核心业务结构

return <div className='calendar_comp'>
    {/* 首部展示当前月份标题 */}
    <header>
      <span>{handelFormtDate(isMountView ? weekFirstDay : mounthFirstDay, 'YYYY年MM月')}</span>
    </header>
    {/* 周列表 */}
    <p className='week_list'>
      {
        weekList.map(item => {
          return <span key={item.id}>{item.name}</span>
        })
      }
    </p>
​
    {/*月日历视图/周日历视图*/}
    <div className={`calendar_comp_wrap ${isMountView ? 'pushHei' : 'pullHei'}`}
      ref={(e: any) => current.calendarRef = e}
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
    >
      <div className='calendar_comp_section' style={{
        transform: `translateX(${-moveIndex * 100}%)`,
      }}
      >
        {
          (isMountView ? weekDateList : mountDateList).map((item, index) => {
            return <ul key={index} className='calendar_list_day'
              style={{
                transform: `translateX(${(index - 1 + moveIndex + (current.isTouch ? touch.x : 0)) * 100
                  }%)`,
                transitionDuration: `${current.isTouch ? 0 : 0.3}s`
              }}
            >
              {
                item.map((date, ind) => {
                  const isOtherMonthDay = isMountView || date.isSame(mounthFirstDay, 'month')
                  const formtDate = handelFormtDate(date, 'YYYY-MM-DD')
​
                  return <li
                    key={ind}
                    onClick={() => {
                      handelSelectDate(formtDate, isOtherMonthDay, ind)
                    }}
                    style={{ color: isOtherMonthDay ? '#333333' : '#CCCCCC' }}
                  >
                    <span className={renderClassName(formtDate)}>{date.format('DD')}</span>
                  </li>
                })
              }
            </ul>
          })
        }
      </div>
    </div>
​
    {/* 日历底部操作收起/展开按钮 */}
    <span className='calendar_pull' onClick={() => {
      handelIsMountView()
    }}>{isMountView ? '展开' : '收起'}</span>
    <p className='current_day'>{handelFormtDate(selectDate, 'MM月DD日')} {`周${getWeekDate(selectDate)?.name}`} </p>
  </div >

核心业务逻辑会分段展示(全部放一起可能看起来很累)从useEffect开始会慢慢引入其他函数

第一段从useEffect开始

useEffect(() => {
    handelSetWeekIndex(current.currentDate, weekDateList) //设置周日历选中的位置
    setSelectDate(current.currentDate) //初始化日历选中当日
}, [])

useEffect(() => {
  //月日历切换的时候触发会直接选中下一个月的第一天
  if (!isMountView) {
    //月日历
    let currentM = handelFormtDate(mounthFirstDay, 'YYYY-MM-DD')
    setSelectDate(currentM)
  }
}, [mounthFirstDay])

//格式化日期函数
const handelFormtDate = (date: dayjs.Dayjs | string, exp: string): string => {
  return dayjs(date).format(exp)
}

 //设置周日历选中的位置
const handelSetWeekIndex = (selectDate: string | dayjs.Dayjs, generateWeekDateList: Array<{ [key: string]: any }>): void => {
  const currentWeekDateList = generateWeekDateList[1]
  let weekIndex = 0
  //设置周日历应该选中的位置(比如切换成周日历的时候当前在周三那么在切换周日历的时候保证切换之后还在周三的位置)
  currentWeekDateList.forEach((v: any, index: number) => {
    if (handelFormtDate(v, 'YYYY-MM-DD') === selectDate) weekIndex = index
  })
  selectWeekInd(weekIndex)
}

//周标题列表渲染逻辑
const getWeekDate = (cDate: string): weekType => {
  const day = new Date(cDate).getDay()
  return weekList[day];
}

第二段从handleTouchStart开始

//滑动日历开始触发
const handleTouchStart = (e: any) => {
  e.stopPropagation()
  const touchs = e.touches[0]
  current.touchStartX = touchs.clientX
  current.touchStartY = touchs.clientY
  current.isTouch = true 
}

//滑动日历中触发
const handleTouchMove = throttle((e: any) => {
    e.stopPropagation()
    const touchs = e.touches[0]
    const moveX = touchs.clientX - current.touchStartX
    const moveY = touchs.clientY - current.touchStartY
    const calendarWidth = current.calendarRef.offsetWidth
    if (Math.abs(moveX) > Math.abs(moveY)) {
      // 左右滑动
      setTouch({ x: moveX / calendarWidth, y: 0 })
    }
  }, 25)

//滑动日历结束触发
 const handleTouchEnd = (e: any) => {
    e.stopPropagation()
    current.isTouch = false
    const touchX = Math.abs(touch.x)
    const touchY = Math.abs(touch.y)
    const newTranslateIndex = touch.x > 0 ? moveIndex + 1 : moveIndex - 1

    if (touchX > touchY && touchX > 0.15) {

      if (isMountView) {
        //周日历走的逻辑
        const nextWeekFirstDay = weekFirstDay[touch.x > 0 ? 'subtract' : 'add'](1, 'week')
        const { currenWeekFirstDay = null, generateWeekDateList = [] } = generateWeekData(nextWeekFirstDay)
				
        //更新周日历视图
        updateWeekView(newTranslateIndex, currenWeekFirstDay, generateWeekDateList)

        //周日历滚动默认选中
        const currentWeekDays = generateWeekDateList[1]
        const selectWeekDay = currentWeekDays[weekInd]
        const formtSelectWeekDay = handelFormtDate(selectWeekDay, 'YYYY-MM-DD')
        setSelectDate(formtSelectWeekDay)

      } else {
        //月日历走的逻辑
        const nextMonthFirstDay = mounthFirstDay[touch.x > 0 ? 'subtract' : 'add'](1, 'month')
        const { currentMonthFirstDay = null, generateMonthDateList = [] } = generateMonthData(nextMonthFirstDay)
      	
        //更新月日历视图
        updateMounthView(newTranslateIndex, currentMonthFirstDay, generateMonthDateList)
      }
    }
    setTouch({ x: 0, y: 0 })
 }

 
 //更新周日历视图
 const updateWeekView = (index: number, currentFirstDay: any, dateList: Array<dayjsType>): void => {
   setMoveIndex(index)
   setWeekFirstDay(currentFirstDay)
   setWeekDateList(dateList)
 }
 
 //更新月日历视图
 const updateMounthView = (index: number, currentFirstDay: any, dateList: Array<dayjsType>): void => {
   setMoveIndex(index)
   setMounthFirstDay(currentFirstDay)
   setMountDateList(dateList)
 }

第三段从handelSelectDate日历选中逻辑开始

//日历选中逻辑
/**
@isOtherMonthDay 判断当前day是否在本月,如果不在本月那么在点击的时候需要自动切换到下一个月或者上一个月
**/
const handelSelectDate = (formtDate: string, isOtherMonthDay: boolean, index: number) => {
  let selectM = handelFormtDate(formtDate, 'YYYYMM')
  let currentM = handelFormtDate(mounthFirstDay, 'YYYYMM')
  if (!isOtherMonthDay) {
    //在当月点击上一个月或者下一个月
    const { generateMonthDateList = [] } = generateMonthData(dayjs(formtDate))
    const newTranslateIndex = selectM < currentM ? moveIndex + 1 : moveIndex - 1
    //更新月视图
    updateMounthView(newTranslateIndex, dayjs(formtDate), generateMonthDateList)
  }
	
  //保证在展开和收起的时候在日历上选中的位置是正确的
  if (isMountView) {
    //周日历 记录选中的位置
    selectWeekInd(index)
  }

  //更新日历选中样式
  setSelectDate(formtDate)
}


//渲染选中样式,这里写成个函数主要是为了区分当天的样式和其他选中的样式
const renderClassName = (formtDate: string): string => {
  if (selectDate === formtDate) return 'selectDay'
  if (formtDate === current.currentDate) return 'currentDay'
  return ''
}

第四段从切换周日历/月日历开始handelIsMountView

//月日历/周日历切换
const handelIsMountView = () => {
  //根据当前选中的天生成的对应的周日历和月日历
  const dayjsDate = dayjs(selectDate)
  const { generateWeekDateList = [], generateMonthDateList = [] } = {
    ...generateMonthData(dayjsDate),
    ...generateWeekData(dayjsDate)
  }

  const flag = !isMountView
  if (flag) {
    //更新周日历
    setWeekFirstDay(dayjsDate)
    setWeekDateList(generateWeekDateList)
    //更新周日历的时候需要记录当前的位置这个方法第一段写了
    handelSetWeekIndex(selectDate, generateWeekDateList)
  } else {
    //更新月日历
    setMounthFirstDay(dayjsDate)
    setMountDateList(generateMonthDateList)
  }
  setIsMountView(flag)
}

以上是日历组件的主要逻辑和业务结构

样式也贴一下

.schedule_view_wrap {
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    overflow: hidden;
    .schedule_list_section{
        height: 100%;
        overflow: hidden;
        display: flex;
        flex-direction: column;
    }
}
.calendar_comp {
    width: 100%;
    background: #fff;
    header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 37px 40px 23px 40px;
        background: #fff;
        span {
            font-size: 36px;
            font-family: PingFangSC-Medium, PingFang SC;
            font-weight: 500;
            color: #000000;
        }
        img {
            width: 40px;
            height: 40px;
        }
    }
    .week_list {
        display: flex;
        span {
            width: calc(100%/7);
            height: 40px;
            text-align: center;
            line-height: 40px;
            font-size: 24px;
            font-family: PingFangSC-Regular, PingFang SC;
            color: #999999;
        }
    }
    .calendar_comp_wrap {
        width: 100%;
        position: relative;
        &.pullHei {
            height: 480px;
            transition: 0.25s;
        }
        &.pushHei {
            height: 80px;
            transition: 0.15s;
        }
        .calendar_comp_section {
            width: 100%;
            display: flex;
            .calendar_list_day {
                display: flex;
                flex-wrap: wrap;
                padding-top: 8px;
                flex-shrink: 0;
                width: 100%;
                position: absolute;
                li {
                    margin-bottom: 32px;
                    width: calc(100%/7);
                    height: 48px;
                    text-align: center;
                    line-height: 48px;
                    font-size: 28px;
                    font-family: PingFangSC-Medium, PingFang SC;
                    font-weight: 500;
                    color: #333333;
                    .currentDay {
                        background: rgba(255, 142, 34, 0.1) !important;
                        color: #FF8E22 !important;
                        padding: 6px 10px;
                        border-radius: 8px;
                    }
                    .selectDay {
                        border-radius: 8px;
                        background: #FF8E22 !important;
                        color: #FFFFFF !important;
                        padding: 6px 10px;
                        &.selectDayActive{
                            padding: 6px 18px;
                        }
                    }
                    .circle {
                        display: block;
                        width: 8px;
                        height: 8px;
                        background: #ccc;
                        border-radius: 50%;
                        margin: 0 auto;
                        margin-top: 10px;
                    }
                }
            }
        }
    }
    .calendar_pull {
        width: 48px;
        height: 8px;
        padding-bottom: 22px;
        padding-top: 10px;
        margin: 0 auto;
        display: block;
    }
    .current_day {
        width: 100%;
        text-align: center;
        padding-bottom: 10px;
        font-size: 28px;
        font-family: PingFangSC-Regular, PingFang SC;
        font-weight: 400;
        color: #333333;
    }
}

最后

上面提及因为日程列表已经接入公司的业务不太方便贴出来,但是如果你真的有需要可以评论区讨论一下
附上git地址:github.com/gaoxinxiao/…

创作不易评论美三代、点赞富一生😁

王有胜.gif