6

手摸手教你用VUE封装日历组件

 3 years ago
source link: https://zhuanlan.zhihu.com/p/359539860
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

手摸手教你用VUE封装日历组件

关注微信公众号:web前端学习圈,领取85G前端全套系统教程

双手奉上代码链接: 传送门 - ajun568

双脚奉上最终效果图:

v2-e1e32a7e3a1886744901c6caab65ca3b_b.jpg

需求分析无非是一个想要什么并逐步细化的过程, 毕竟谁都不能一口吃掉一张大饼, 所以我们先把饼切开, 一点一点吃. 以下基于特定场景来实现一个基本的日历组件. 小生不才, 还望各位看官轻喷, 欢迎各路大神留言指教.

v2-d284d0ea1ee2660e7335138a58b25110_720w.jpg

场景: 在移动端中通过切换日期来切换收益数据, 展现形式为上面日历, 下面对应数据, 只显示日数据.

基于此场景, 我们对该日历功能进行需求分析

  • 普遍场景下, 我们更倾向当天的数据情况. 所以基于此, 首次进入应展示当前月份且选中日期为今日
  • 点选日期, 应可以准确切换, 否则做它何用, 当 瓶吗
  • 切换月份, 以查看更多数据. 场景基于移动端, 交互方式选择体验更好的滑动切换, 左滑切换至上一月, 右滑切换至下一月
  • 滑动切换月份后, 选中该月1号
  • 移动端的展示区域非常宝贵, 减少占用空间显得极为重要, 这时候周视图就有了用武之地. 交互上可上滑切换至周视图, 下拉切换回月视图.
  • 明确月视图滑动切月, 周视图滑动切周
  • 滑动切换星期后, 选中该星期的第一天, 若左滑切换后存在1号, 选中1号
v2-6f25b7bfaea4d924a9275ea6f69fa58f_720w.jpg

结构及样式

先拆分一下日历, 可将其上下拆分成两部分, 上面的 星期 部分, 和下面的 数据 部分, 一周7天限定了列数为7列, 行数会随当月天数1号所在位置而有所不同.

移动端亦应根据屏幕宽度自适应布局, flex布局就是一个很好的选择, 我们对数据部分进行下模拟, 先造一个长度为40数据都为0的数组如下:

const dataArr = Array(40).fill(0, 0, 40)

现在, 我们想要每排显示7个, 顺次下移, 不妨想一下, 如果是你, 你会怎么做?

  • 父元素设置
    • flex-direction : 用于定义主轴方向
    • flex-wrap : 用于定义是否换行
    • flex-flow : 同时定义flex-directionflex-wrap
  • 子元素设置
    • flex-basis : 用于设置伸缩基准值,可设置具体宽度或百分比,默认值是auto
    • flex-grow : 用于设置放大比例,默认为0,如果存在剩余空间,该元素也不会被放大
    • flex-shrink : 用于设置缩小比例,默认为1,如果空间不足,将等比例缩小。如果设置为0,则它不会被缩小
    • flex : flex-growflex-shrinkflex-basis的缩写

综上, 我们可以设置样式为 flex: row wrap flex: 0 0 14.285% (1/7 ≈ 14.285%)

效果图

代码片段

此时, 可以加一层结构, 让子元素宽高固定为40✖️40, 方便对选中后的样式进行处理

我们来随意勾勒两笔样式, 呈现如下

展示当前月份及选中当天日期

凭空想象哪有直接上图片来的直观, 就像老板画的饼哪有money来的实在 , 接下来我们结合下面图片进行进一步的分析, 图片为我截取的手机日历图

首先, 既然是默认选中今天, 我们就先来获取下当前日期

// 获取当前日期
getCurrentDate() {
  this.selectData = {
    year: new Date().getFullYear(),
    month: new Date().getMonth() + 1,
    day: new Date().getDate(),
  }
}

我们来看下这张图片, 不考虑蓝框中的部分, 要显示出当月日期, 我们只需知道以下两个点, 然后做for循环就可以了.

  1. 当前月份的天数
  2. 当前月份第一天应该显示在什么位置

这么一看, 是不是 so easy! 不要太简单有木有.

v2-96857f5ac891d8c3387e416686855eca_b.jpg

当月天数

“一三五七八十腊, 三十一天永不差”, 每年除了二月分平年闰年以外, 其余月份的天数都是固定的, 这么一看, 这不是区分下二月就完事了吗

const { year } = this.selectData
let daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

if ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0) { // 闰年处理
  daysInMonth[1] = 29
}

当月第一天的位置

想知道当月第一天的位置, 换个思路想, 其实就是想知道当月第一天是星期几, 诶, 这不是巧了吗, 拿当月第一天的日期 getDay() 这不就完事了吗

const { year, month } = this.selectData
const monthStartWeekDay = new Date(year, month - 1, 1).getDay()

接下来我们填充下数据, 前后做留白处理, 代码及效果如下:

‍♂️ Code

‍♂️ Image

日期切换及月份切换

日期切换 = 更改当前数组中子元素的isSelected

// 切换点选日期
checkoutDate(selectData) {
  if (selectData.type !== 'normal') return // 非有效日期不可点选

  this.selectData.day = selectData.day // 对选中日期赋值

   // 查找当前选中日期的索引
  const oldSelectIndex = this.dataArr.findIndex(item => item.isSelected && item.type === 'normal')
  // 查找新切换日期的索引 (tips: 这里也可以直接把索引值传过来 -> index)
  const newSelectIndex = this.dataArr.findIndex(item => item.day === selectData.day && item.type === 'normal')

  // 更改isSelected值
  if (this.dataArr[oldSelectIndex]) this.$set(this.dataArr[oldSelectIndex], 'isSelected', false)
  if (this.dataArr[newSelectIndex]) this.$set(this.dataArr[newSelectIndex], 'isSelected', true)
}

月份切换 = 重新生成新月份所对应的dataArr, 并选中当月1号

tips: 这里需要注意的点是, 1月的上一月12月的下一月, 以上一月举例:

checkoutPreMonth() {
  let { year, month, day } = this.selectData
  if (month === 1) {
    year -= 1
    month = 12
  } else {
    month -= 1
  }

  this.selectData = { year, month, day: 1 }
  this.dataArr = this.getMonthData(this.selectData)
},

今日

checkoutCurrentDate() {
  this.getCurrentDate()
  this.dataArr = this.getMonthData(this.selectData)
},

至此, 一个基本的月视图就实现完毕了

v2-05c155e1fef98841925129072a178200_b.jpg

接下来我们来对月视图进行优化, 增加滑动切月的功能. 我们先来看一下实现的效果

v2-5410e60cd810a6351378bda7bfb6c2b4_b.jpg

以左滑为例:

  • 滑动过程中, 我们可以看到部分下个月的数据
  • 滑动距离过小, 自动回弹到当前视图
  • 滑动超过一定距离, 自动滑至下一个月

touch

作案是需要工具的, 想要触发滑动事件, 得先找到对应的工具

v2-bde49e2f7a92fe6873a9f79dce56d315_b.jpg
  • touchstart : 手指触摸屏幕时触发
  • touchmove : 手指在屏幕中拖动时触发
  • touchend : 手指离开屏幕时触发

光靠这个事件, 在滑动过程中是无法看到下个月的部分数据的, 想要在滑动过程中看到数据, 这就是典型的轮播场景. 本质上就是一次transform的过程.

此时, 我们调整下页面结构, 由对dataArr的单层循环改为双层循环模式, 其本质就是上图所示的[pre, current, next]数组

此步骤涉及的代码改动较多, 接下来主要通过新引入的变量来捋清思路, 思路清晰了, 代码顺其自然就好, Let's go, come on baby!

allDataArr: [], // 轮播数组
isSelectedCurrentDate: false, // 是否点选的当月日期
translateIndex: 0, // 轮播所在位置
transitionDuration: 0.3, // 动画持续时间
needAnimation: true, // 左右滑动是否需要动画
isTouching: false, // 是否为滑动状态
touchStartPositionX: null, // 初始滑动X的值
touchStartPositionY: null, // 初始滑动Y的值
touch: { // 本次touch事件,横向,纵向滑动的距离的百分比
  x: 0,
  y: 0,
},

allDataArr - 轮播数组

❓ 什么时候对这个数组进行赋值

️ 当[pre, current, next]中任意值变化时, 而prenext的变化都依附于current的变化, Wow, interesting! watch watch watch !!!

isSelectedCurrentDate - 是否点选的当月日期

❓ 在点选切换数据时, 因为isSelected的变化, watch监听并执行赋值操作, 但此时并没有必要重新生成prenext

translateIndex - 轮播所在位置

用于控制pre, current, next位置, 当触发滑动切月时, 通过更改translateIndex来更改位置. 在重新赋值时还原到初始值.

touchStartPositionX, touchStartPositionY, touch

这三个是为了确定滑动方向及距离的, 向什么方向滑动? (不要和我说你任性, 就想斜着滑动) 滑动多远? 松手后, 滑动距离小做回弹处理, 滑动距离大做切换处理 (结合translateIndex, 我知道你懂得)

needAnimation - 左右滑动是否需要动画

v2-3b7161d2fd5c5f47bc5115e1b295a94f_b.jpg

我们看图说话( ), 是不是感觉这个动画怪怪的, 但又说不清楚哪里怪, 那是因为在动画进行中时候, 我们就对allDataArr进行了赋值操作, 我们在定时器中延迟下这个赋值操作, 效果如下( ):

v2-f6b0dc377c6afe192cdf75afd0b859ca_b.jpg

是不是有一个明显的反复横跳的过程, 因为我们滑动过去时候在next, 但最后回到的是current. 这点小问题怎么能限制住我们的聪明大脑, 将回到current的动画去掉, 不就完美解决问题了吗.

赋部分代码片段:

切换周视图

还是看图说话, 文字哪有图片直观, 我们来分析下切换周的过程:

v2-36ba6fec4dd8017ca5a87706b22044ce_b.jpg

Bingo, 就是一个transformY+height的过程

对于height, 无非是总高度到单行高度反复横跳的过程, 每行高度是固定的, 总高度=单行高度*总行数

isWeekView: false, // 周视图还是月视图
itemHeight: 50, // 日历行高
lineNum: 0, // 当前视图总行数

this.lineNum = Math.ceil(this.dataArr.length / 7)

对于transformY, 其移动距离=(当前所在行数-1)*单行高度

offsetY: 0, // 周视图 Y轴偏移量

// 处理周视图的数据变化
dealWeekViewData() {
  const selectedIndex = this.dataArr.findIndex(item => item.isSelected)
  const indexOfLine = Math.ceil((selectedIndex + 1) / 7)
  this.offsetY = -((indexOfLine - 1) * this.itemHeight)
},

补全视图信息

在做周视图的滑动切换之前, 我们来补全一下视图信息, 将daraArr的空白处填上对应日期

年和月的填充就不说了, 简单说下日的填充

next比较简单, 循环次数=7-最后一行天数=7-次月1日的星期索引 (tip: 需要注意的是, 若次月1日索引为0, 代表无空白处可填充, 自然也无需循环), day的赋值从1号顺次增加即可.

const nextInfo = this.getNextMonth()

let nextObj = {
  type: 'next',
  day: i + 1,
  month: nextInfo.month,
  year: nextInfo.year,
}

再来说说pre, 循环次数=7-第一行天数=当月1号的星期索引, day的赋值等于上月日期的倒序 => 上月天数 - (当月1号星期索引 - (index + 1))

const preInfo = this.getPreMonth(date)

let preObj = {
  type: 'pre',
  day: daysInMonth[preInfo.month - 1] - (monthStartWeekDay - i - 1),
  month: preInfo.month,
  year: preInfo.year,
}

❓ 这里getPreMonth()函数传date的原因

️ 说白了, date就是参照物呗, 对谁取上个月就传谁; 而getNextMonth()为什么不传呢, 单纯的无所谓, 传与不传它都是从1递增, 谁又会在一个无关紧要的事上浪费感情呢.

点选非本月日期时, 对应做切换月份的处理即可, 此时切换后的日期为点选日期, 而非1号

滑动切换星期

在视图切换的过程中, 与我们一同上下摩擦的, 还是陪着我们不离不弃的preArrnextArr. 既然甩不掉, 何不将它们的价值榨干到极致, 这样才符合利益最大化嘛, 我们对同一横行的前后数据做狸猫换太子的操作, 将其分别换成当前数据的前一周和后一周, 毕竟破坏才是更好的创造.

要想狸猫换太子, 得先找到那只狸猫, 在找到太子, 才能进行两者的对调. 我们以切换至上一周为例, 来具体找一下狸猫和太子.

  • 狸猫 - lastWeek

No.1 如果非首行数据, 上周=上一行. 通过当前行数, 拿到两端数据的索引, 分别减7获取上一周两端数据的索引, 进而拿到上一周的数据.

No.2 如果当前为首行, 又可进一步划分为: 首个数据项是否为1号, 若是, 则取上个月最后一行数据; 若否, 则取上个月倒数第二行数据(tips: 此时上个月最后一行等同于当前首行); 以上两点, 也可考虑成查找特定日期在上个月的所在行.

  • 太子 - 平行世界的当前行
// 获取处理周视图所需的位置信息
getInfoOfWeekView(selectedIndex, length) {
  const indexOfLine = Math.ceil((selectedIndex + 1) / 7) // 当前行数
  const totalLine = Math.ceil(length / 7) // 总行数
  const sliceStart = (indexOfLine - 1) * 7 // 当前行左端索引
  const sliceEnd = sliceStart + 7 // 当前行右端索引

  return { indexOfLine, totalLine, sliceStart, sliceEnd }
},

// 处理lastWeek、nextWeek, 并返回替换行索引
dealWeekViewSliceStart() {
  const selectedIndex = this.dataArr.findIndex(item => item.isSelected)
  const {
    indexOfLine,
    totalLine,
    sliceStart,
    sliceEnd
  } = this.getInfoOfWeekView(selectedIndex, this.dataArr.length)

  this.offsetY = -((indexOfLine - 1) * this.itemHeight)

  // 前一周数据
  if (indexOfLine === 1) {
    const preDataArr = this.getMonthData(this.getPreMonth(), true)
    const preDay = this.dataArr[0].day - 1 || preDataArr[preDataArr.length - 1].day
    const preIndex = preDataArr.findIndex(item => item.day === preDay && item.type === 'normal')
    const { sliceStart: preSliceStart, sliceEnd: preSliceEnd } = this.getInfoOfWeekView(preIndex, preDataArr.length)
    this.lastWeek = preDataArr.slice(preSliceStart, preSliceEnd)
  } else {
    this.lastWeek = this.dataArr.slice(sliceStart - 7, sliceEnd - 7)
  }

  // 后一周数据
  if (indexOfLine >= totalLine) {
    const nextDataArr = this.getMonthData(this.getNextMonth(), true)
    const nextDay = this.dataArr[this.dataArr.length - 1].type === 'normal' ? 1 : this.dataArr[this.dataArr.length - 1].day + 1
    const nextIndex = nextDataArr.findIndex(item => item.day === nextDay)
    const { sliceStart: nextSliceStart, sliceEnd: nextSliceEnd } = this.getInfoOfWeekView(nextIndex, nextDataArr.length)
    this.nextWeek = nextDataArr.slice(nextSliceStart, nextSliceEnd)
  } else {
    this.nextWeek = this.dataArr.slice(sliceStart + 7, sliceEnd + 7)
  }

  return sliceStart
},

dealWeekViewData() {
  const sliceStart = this.dealWeekViewSliceStart()
  this.allDataArr[0].splice(sliceStart, 7, ...this.lastWeek)
  this.allDataArr[2].splice(sliceStart, 7, ...this.nextWeek)
},

到这里基本就大功告成了, 我们总结下剩下的问题并加以处理, 阿拉霍洞开

  • 一些蹩脚的动画: 此场景下, 一切奇怪的动画都是由transitionDuration导致的, 所以我们要想清楚什么时候需要动画, 什么时候不需要, 不需要时候赋值为0就好了
  • 类似卡顿的效果: 此场景下, 几乎所有的卡顿、延迟, 都是那个万恶的setTimeout导致的, 所以要想好什么时候需要它, 什么时候果断舍弃它
  • 最后加个底部的touch条, 使其更美观些

长图预警, 此处请单击点开大图观看, 也可直接去我的github上查看, 传送门 - ajun568



原作者姓名:黄刀小五
原出处:segmentfault
原文链接:手摸手教你用VUE封装日历组件 - SegmentFault 思否


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK