手摸手教你用VUE封裝日曆套件

寫在前面

雙手奉上程式碼連結: 傳送門 - ajun568

雙腳奉上最終效果圖:

image

需求分析

需求分析無非是一個想要什麼並逐步細化的過程, 畢竟誰都不能一口吃掉一張大餅, 所以我們先把餅切開, 一點一點吃. 以下基於特定場景來實現一個基本的日曆套件. 小生不才, 還望各位看官輕噴, 歡迎各路大神留言指教.

「我自己是一名從事了6年web前端開發的老程式設計師(我的微信:webxxq),今年年初我花了一個月整理了一份最適合2021年自學的web前端全套培訓教學(影片+原始碼+筆記+專案實作),從最基礎的HTML+CSS+JS到行動裝置HTML5以及各種框架和新技術都有整理,打包給每一位前端小夥伴,這裡是前端學習者聚集地,歡迎初學和進階中的小夥伴(所有前端教學關注我的微信公眾號:web前端學習圈,關注後回復「web」即可領取)。

image

場景: 在行動裝置中透過切換日期來切換收益資料, 展現形式為上面日曆, 下面對應資料, 只顯示日資料.

基於此場景, 我們對該日曆功能進行需求分析

  • 普遍場景下, 我們更傾向當天的資料情況. 所以基於此, 首次進入應展示當前月份且選中日期為今日
  • 點選日期, 應可以準確切換, 否則做它何用, 當??瓶嗎
  • 切換月份, 以檢視更多資料. 場景基於行動裝置, 互動方式選擇體驗更好的滑動切換, 左滑切換至上一月, 右滑切換至下一月
  • 滑動切換月份後, 選中該月1號
  • 行動裝置的展示區域非常寶貴, 減少佔用空間顯得極為重要, 這時候周檢視表就有了用武之地. 互動上可上滑切換至周檢視表, 下拉切換回月檢視表.
  • 明確月檢視表滑動切月, 周檢視表滑動切周
  • 滑動切換星期後, 選中該星期的第一天, 若左滑切換後存在1號, 選中1號

image

結構及樣式

先拆解一下日曆, 可將其上下拆解成兩部分, 上面的 星期 部分, 和下面的 資料 部分, 一周7天限定了列數為7列, 行數會隨當月天數1號所在位置而有所不同.

行動裝置亦應根據螢幕寬度自適應佈局, flex佈局就是一個很好的選擇, 我們對資料部分進行下模擬, 先造一個長度為40資料都為0的陣列如下:

1
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%)

效果圖 ??

image

程式碼片段 ??

image

此時, 可以加一層結構, 讓子元素寬高固定為40??40, 方便對選中後的樣式進行處理

我們來隨意勾勒兩筆樣式, 呈現如下 ??

image

展示當前月份及選中當天日期

憑空想像哪有直接上圖像來的直觀, 就像老闆畫的餅哪有money來的實在??, 接下來我們結合下面圖像進行進一步的分析, 圖像為我擷取的行動電話日曆圖

image

首先, 既然是預設選中今天, 我們就先來取得下當前日期

1
2
3
4
5
6
7
8
// 取得當前日期
getCurrentDate() {
  this.selectData = {
    year: new Date().getFullYear(),
    month: new Date().getMonth() + 1,
    day: new Date().getDate(),
  }
}

我們來看下這張圖像, 不考慮藍框中的部分, 要顯示出當月日期, 我們只需知道以下兩個點, 然後做for迴圈就可以了.

  1. 當前月份的天數
  2. 當前月份第一天應該顯示在什麼位置

這麼一看, 是不是 so easy! 不要太簡單有木有.

image

當月天數

「一三五七八十臘, 三十一天永不差」, 每年除了二月分平年閏年以外, 其餘月份的天數都是固定的, 這麼一看, 這不是區分下二月就完事了嗎

1
2
3
4
5
6
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() 這不就完事了嗎

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

接下來我們填充下資料, 前後做留白處理, 程式碼及效果如下:

???♂? Code

image

???♂? Image

image

日期切換及月份切換

日期切換 = 更改當前陣列中子元素的isSelected

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 切換點選日期
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月的下一月, 以上一月舉例:

1
2
3
4
5
6
7
8
9
10
11
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)
},

今日

1
2
3
4
checkoutCurrentDate() {
  this.getCurrentDate()
  this.dataArr = this.getMonthData(this.selectData)
},

至此, 一個基本的月檢視表就實現完畢了

image

滑動切月

接下來我們來對月檢視表進行調校, 增加滑動切月的功能. 我們先來看一下實現的效果??

image

以左滑為例:

  • 滑動過程中, 我們可以看到部分下個月的資料
  • 滑動距離過小, 自動回彈到當前檢視表
  • 滑動超過一定距離, 自動滑至下一個月

touch

作案是需要工具的, 想要觸發滑動事件, 得先找到對應的工具

image

  • touchstart : 手指觸控螢幕時觸發
  • touchmove : 手指在螢幕中拖動時觸發
  • touchend : 手指離開螢幕時觸發

光靠這個事件, 在滑動過程中是無法看到下個月的部分資料的, 想要在滑動過程中看到資料, 這就是典型的輪播場景. 本質上就是一次transform的過程.

image

此時, 我們調整下頁面結構, 由對dataArr的單層迴圈改為雙層迴圈模式, 其本質就是上圖所示的[pre, current, next]陣列

此步驟涉及的程式碼改動較多, 接下來主要透過新引入的變數來捋清思路, 思路清晰了, 程式碼順其自然就好, ?? Let's go, come on baby!

1
2
3
4
5
6
7
8
9
10
11
12
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 - 左右滑動是否需要影片

image

我們看圖說話(??), 是不是感覺這個影片怪怪的, 但又說不清楚哪裡怪, 那是因為在影片進行中時候, 我們就對allDataArr進行了賦值操作, 我們在定時器中延遲下這個賦值操作, 效果如下(??):

image

是不是有一個明顯的反覆橫跳的過程, 因為我們滑動過去時候在next, 但最後回到的是current. 這點小問題怎麼能限制住我們的聰明大腦, 將回到current的影片去掉, 不就完美解決問題了嗎.

image

賦部分程式碼片段:

image

image

切換周檢視表

還是看圖說話, 文字哪有圖像直觀, 我們來分析下切換周的過程:

image

Bingo, 就是一個transformY+height的過程

?? 對於height, 無非是總高度到單行高度反覆橫跳的過程, 每行高度是固定的, 總高度=單行高度*總行數

1
2
3
4
5
isWeekView: false, // 周檢視表還是月檢視表
itemHeight: 50, // 日曆行高
lineNum: 0, // 當前檢視表總行數

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

?? 對於transformY, 其移動距離=(當前所在行數-1)*單行高度

1
2
3
4
5
6
7
8
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的空白處填上對應日期.

image

年和月的填充就不說了, 簡單說下日的填充

next比較簡單, 迴圈次數=7-最後一行天數=7-次月1日的星期索引 (tip: 需要注意的是, 若次月1日索引為0, 代表無空白處可填充, 自然也無需迴圈), day的賦值從1號順次增加即可.

1
2
3
4
5
6
7
8
const nextInfo = this.getNextMonth()

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

再來說說pre, 迴圈次數=7-第一行天數=當月1號的星期索引, day的賦值等於上月日期的倒序 => 上月天數 - (當月1號星期索引 - (index + 1))

1
2
3
4
5
6
7
8
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. 既然甩不掉, 何不將它們的價值榨乾到極致, 這樣才符合利益最大化嘛, 我們對同一橫行的前後資料做狸貓換太子的操作, 將其分別換成當前資料的前一周和後一周, 畢竟破壞才是更好的創造.

image

要想狸貓換太子, 得先找到那隻狸貓, 在找到太子, 才能進行兩者的對調. 我們以切換至上一周為例, 來具體找一下狸貓和太子.

  • 狸貓 - lastWeek

No.1 如果非首行資料, 上周=上一行. 透過當前行數, 拿到兩端資料的索引, 分別減7取得上一周兩端資料的索引, 進而拿到上一周的資料.

No.2 如果當前為首行, 又可進一步劃分為: 首個資料項是否為1號, 若是, 則取上個月最後一行資料; 若否, 則取上個月倒數第二行資料(tips: 此時上個月最後一行等同於當前首行); 以上兩點, 也可考慮成搜尋特定日期在上個月的所在行.

  • 太子 - 平行世界的當前行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 取得處理周檢視表所需的位置訊息
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

image

image