时隔两年,终于动手写这一篇关于UNIAPP仿钉钉打卡统计日历的具体实现流程
前言
某一天ui小姐姐拿着钉钉的打卡统计日历来我跟前说,你能不能也做一个类似这样的打卡日历。我的内心OS:这这这......好呀,连ui都省了,不过有现成的也行,至少还能操作试试功能。我回答:没......没问题,我给你仿一个(男人怎么能说不行呢,抓耳挠腮中...
github:github.com/zhengsenyi/… uniapp插件市场:ext.dcloud.net.cn/plugin?id=7…
初期做法
当时入行已经一年,使用uniapp也近半年多,早已对插件市场的使用得心应手,所以我首先想到的是去看看插件市场里有没有类似需求的日历组件。但经过一番找寻以及使用,发现插件市场搜索出来的组件数量虽然很多,但是很多组件都是半成品,要么不符合需求要么就是一堆BUG,最后还是决定不然就自己搞一个吧,正好插件市场没有相关组件,到时还能传到应用市场供有需要的人使用(不得不承认做我们这一行还是挺有开源精神的
进入主题
上述讲了挺多废话,接下来咱直接进入主题
需求分析
对钉钉这个日历进行一个简单的需求分析后发现有以下主要需求
- 日历上方
第一个
星期几能够随意显示,比如把星期六
放在第一位 - 支持
左右无限
滑动 - 本月份包含
上一个月
以及下一个月
的日期,且点击能够进行切换
- 支持展开收缩,展开状态下为
月历
,收缩状态下为周历
- 点击回到今天能够回到当天日期且
选中
ok,接下来咱直接开始一步步实现吧
随意显示星期
// 初始化周数
initWeek() {
const normalWeek = ['日', '一', '二', '三', '四', '五', '六'] // 正常周数
const sIndex = this.sundayIndex < 0 ? 0 : this.sundayIndex >= normalWeek.length ? normalWeek.length - 1 : this.sundayIndex normalWeek.unshift(...normalWeek.slice( - sIndex)) normalWeek.length = 7 this.week = normalWeek
}
无限滑动
其实这就是一个轮播,只是把图片换成了日历展示,但要支持无限滑动,如果每一个月份都是一个swiper-item的话将会有很多个轮播,这会导致页面越使用越卡顿不流畅。
突然灵光一现,其实只需要三个
swiper-item就可以实现无限滑动,用比较笨的画图解释下其中原理
- 初始状态
- 往右滑动
tip:我们需要保证左右两个不可见的swiper-item显示正确对应的数据,既然这样当滑动结束后需要进行数据更新,注意这里是滑动结束后才进行更新是为了避免滑动的时候更新数据会出现卡顿掉帧现象。
代码示例:
// 轮播图切换结束更新临近月份日期缓存数据
swiperChange(e) {
// 切换上个月/下个月,默认选中一号 / 切换上一周/下一周,默认选中第一天
if (!this.swiperChangeByClick) {
this.getPrevOrNextDate(e)
}
if (this.swiperMode === 'open') { // 展开
// 通过点击上个月/下个月日期进行切换,不需要默认选中下个月的一号,直接选中点击的那个日期
setTimeout(() => {
this.generateAdjacentMonthDate() // 重新生成临近月份日期缓存数据
}, this.duration)
}
},
// 生成临近月份日期缓存数据
generateAdjacentMonthDate() {
const arr = []
this.getAdjacentYMD.map(YM => {
const [year, month] = YM.split('-')
arr.push(this.generateMonthDateCache(year, month))
})
const [prev, cur, next] = arr
this.calendarSwiperDates = this.adjacentSortByCurrent(prev, cur, next)
if (this.swiperChangeByClick) {
this.swiperChangeByClick = false
}
},
/**
* 获取指定日期信息
* isToday: 是否获取当天的信息还是选中日期的信息
* index: 0 表示年份 1 表示月份 2 表示日期
*/
getAssignDateInfo() {
return (isToday, index) => {
return (isToday ? this.today : this.selectedDate).split('-')[index] * 1
}
},
// 计算选中日期的上月、本月、下月的年月信息
getAdjacentYMD() {
const year = this.getAssignDateInfo(false, 0)
const month = this.getAssignDateInfo(false, 1)
const prev = `${month === 1 ? year - 1 : year}-${month === 1 ? 12 : month - 1}`
const cur = `${year}-${month}`
const next = `${month === 12 ? year + 1 : year}-${month === 12 ? 1 : month + 1}`
return [prev, cur, next]
},
// 根据current自动对轮播数据进行衔接排序
adjacentSortByCurrent(prev, cur, next) {
let arr
if (this.current === 0) {
arr = [cur, next, prev]
} else if (this.current === 1) {
arr = [prev, cur, next]
} else if (this.current === 2) {
arr = [next, prev, cur]
}
return arr
}
本月份上下月份日期的包含
这里我们需要判断月份的第一天位于第一行的第几位
,若第一位则不需要填充上一个月的倒数几天。同理月份的最后一天位于最后一行的第几位,若为最后一位也不需要填充下一个月的前几天。
代码示例:
// 生成月份日期缓存数据并返回
generateMonthDateCache(year, month) {
year = Number(year)
month = Number(month)
// 缓存中已存在
if (this.monthDateCache[`${year}-${month}`]) return this.monthDateCache[`${year}-${month}`]
let calendarDate = []
const monthDates = new Date(year, month, 0)
.getDate() // 获取此月份总天数
const normalWeek = ['一', '二', '三', '四', '五', '六', '日'] // 正常周数
const monthFirstDay = normalWeek[new Date(year, month - 1, 0)
.getDay()] // 获取本月一号为星期几
const monthFirstDayIndex = this.week.indexOf(monthFirstDay) // 计算本月一号在日历周数中的索引,索引之前的填充上个月的后几天
// 本月一号在日历中不是第一个位置,需要进行填充
if (monthFirstDayIndex !== 0) {
const prevMonthDates = new Date(year, month - 1, 0)
.getDate() // 获取上一个月份的总天数
// 填充本月一号之前的数据
for (let i = 0; i < monthFirstDayIndex; i++) {
const item = {
year: month === 1 ? year - 1 : year,
month: month === 1 ? 12 : month - 1,
date: prevMonthDates - i,
dateFormat: `${month === 1 ? year - 1 : year}-${String(month === 1 ? 12 : month - 1).padStart(2, '0')}-${String(prevMonthDates - i).padStart(2, '0')}`,
type: 'prev'
}
// 判断填充的日期是否包含今天日期
this.theDateIsToday(item)
calendarDate.unshift(item)
}
}
// 循环生成当月所有日期
for (let i = 1; i <= monthDates; i++) {
const item = {
year,
month,
date: i,
isSelected: false,
dateFormat: `${year}-${String(month).padStart(2, '0')}-${String(i).padStart(2, '0')}`,
type: 'cur'
}
// 今天的日期在不在里面
this.theDateIsToday(item)
calendarDate.push(item)
}
const residue = calendarDate.length % 7
// 判断是否需要填充下个月的前几天
if (residue !== 0) {
for (let i = 1; i <= 7 - residue; i++) {
const item = {
year: month === 12 ? year + 1 : year,
month: month === 12 ? 1 : month + 1,
date: i,
dateFormat: `${month === 12 ? year + 1 : year}-${String(month === 12 ? 1 : month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`,
type: 'next'
}
// 下个月的前几天包含今天
this.theDateIsToday(item)
calendarDate.push(item)
}
}
this.monthDateCache[`${year}-${month}`] = deepClone(calendarDate)
return this.monthDateCache[`${year}-${month}`]
}
展开收缩
其实最让我头疼也是这块了,当时1.0版本使用的是两个轮播,一个用来展示展开状态一个用来展示收缩状态,一开始我就知道这地方使用一个轮播其实就够了,无非就是临近月份变成临近周数。后面2.0索性改成一个轮播了,减少了几十行代码量(同时减少了几十根头发。
- 使用计算属性返回当前月历or周历数据
// 返回当前日期信息(展开状态下为每月,收缩状态下为每周)
getCurCalendarDates() {
if (this.swiperMode === 'open') { // 展开
return this.calendarSwiperDates
} else {
return this.getCalendarShrinkSwiperDates()
}
},
// 计算收缩时的日历轮播日期信息
getCalendarShrinkSwiperDates() {
const[prevYM, curYM, nextYM] = this.getAdjacentYMD
// 本月日期数据
const curDates = this.monthDateCache[curYM]
// 计算当前日期所在行
const line = Math.floor(curDates.map(item = >item.dateFormat).indexOf(this.selectedDate) / 7)
// 当前周日期信息
const cur = curDates.slice(line * 7, (line + 1) * 7)
let prev, next
/**
* 获取上一周日期信息
* 注意:当选中日期为第一周要额外判断,如果刚好为日历的第一天,则上一周数据应为上一个月的最后一周,
否则为上一个月的倒数第二周
*/
if (line === 0) {
// 获取上个月日历数据
const prevDates = this.monthDateCache[prevYM]
// 获取上个月的日历行数
const prevDatesLine = prevDates.length / 7
if (curDates[0].dateFormat === this.selectedDate) { // 选中日期刚好为日历第一天
prev = prevDates.slice((prevDatesLine - 1) * 7) // 上个月倒数第一周数据
} else {
prev = prevDates.slice((prevDatesLine - 2) * 7, (prevDatesLine - 1) * 7) // 上个月倒数第二周数据
}
} else {
prev = curDates.slice((line - 1) * 7, line * 7)
}
/**
* 获取下一周日期信息
* 注意:当选中日期为最后一周要额外判断,如果刚好为日历的最后一天,则下一周数据应为下一个月的第一周,
否则为下一个月的第二周
*/
if (line + 1 === curDates.length / 7) {
// 获取下个月的日期数据
const nextDates = this.monthDateCache[nextYM]
if (curDates[curDates.length - 1].type === 'cur') { // 最后一天为本月的日期
next = nextDates.slice(0, 7) // 下个月第一周数据
} else {
next = nextDates.slice(7, 14) // 下个月第二周数据
}
} else {
next = curDates.slice((line + 1) * 7, (line + 2) * 7)
}
return this.adjacentSortByCurrent(prev, cur, next)
},
接下来用图示给大家讲解一下思路
tip:这里我们需要注意几个点,比如选中的日期位于第一行,这时需要注意包不包含上个月的日期,如果不包含的话则收缩后的上一周为上个月的最后一周(也就是上一月历的最后一行),否则为倒数第二行
- 不包含
- 包含
同样最后一行的判断也是类似的思路,这里就不进行赘述
当然上方是关于展开收缩时的日期展示逻辑,接下来我们还有一个功能需要实现,我们发现钉钉的的周历下选中上一个月份的日期时,此时已经切换到上个月了,再次展开显示的是上一个月的数据,那这部分是如何实现的呢
我们这里对选中日期做一个监听
selectedDate: {
deep: true,
handler(newV, oldV) {
// 判断月历日期数据需不需要改变
if (this.swiperMode === 'close') {
setTimeout(() = >{
this.generateAdjacentMonthDate() // 生成临近月份日期缓存数据
},
this.duration);
}
if (newV && (oldV === null || this.dateClick)) { // 初始化/日历点击选择时直接返回
this.emitDate() this.dateClick = false
} else { // 其它情况做防抖处理
if (this.emitTimer !== null) {
clearTimeout(this.emitTimer) this.emitTimer = null
}
this.emitTimer = setTimeout(() = >{
this.emitDate() this.emitTimer = null
},
this.duration + 200)
}
}
}
当它改变时我们去判断临近月份日期数据需不需要进行更新,其实这部分逻辑在上方也已经提到了,这部分逻辑可以说是这个日历组件的核心了,看不懂的多看几遍反复理解,理解了其实就挺简单了。
回到今天
回到今天其实就是一个日期选中功能,它可以是随意的日期,因此这里封装成了一个更加通用的方法
// 前往某一天 格式 YYYY-MM | YYYY-MM-DD
goToDate(date = this.today) {
try {
if (date.split('-').length < 2 || date.split('-').length > 3) throw '参数有误'
if (date.split('-').length === 2) {
date += '-01'
}
} catch(err) {
throw Error('请检查参数是否符合规范')
}
this.selectedDate = date
this.generateAdjacentMonthDate()
}
总结
第一次写篇幅这么多的文章,可能有些表述并不是很流畅,大家伙也可以自行下载观看源码,文章制作不易,还恳请大家伙能动动手指头,点赞+收藏+关注,你们的肯定是我努力的动力。
转载自:https://juejin.cn/post/7386594095135834150