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

寫在前面


雙腳奉上最終效果圖:

需求分析

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

場景: 在移動端中通過切換日期來切換收益數據, 展現形式爲上面日曆, 下面對應數據, 只顯示日數據.

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

結構及樣式

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

移動端亦應根據屏幕寬度自適應佈局, flex佈局就是一個很好的選擇, 我們對數據部分進行下模擬, 先造一個長度爲 40 數據都爲 0 的數組如下:

現在, 我們想要每排顯示 7 個, 順次下移, 不妨想一下, 如果是你, 你會怎麼做?

綜上, 我們可以設置樣式爲 👉🏼   父   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! 不要太簡單有木有.

當月天數

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

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)
},

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

滑動切月

接下來我們來對月視圖進行優化, 增加滑動切月的功能. 我們先來看一下實現的效果👇

以左滑爲例:

touch

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

光靠這個事件, 在滑動過程中是無法看到下個月的部分數據的, 想要在滑動過程中看到數據, 這就是典型的輪播場景. 本質上就是一次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,
},

❓ 什麼時候對這個數組進行賦值

🅰️ 當[pre, current, next]中任意值變化時, 而prenext的變化都依附於current的變化, Wow, interesting! watch watch watch !!!

isSelectedCurrentDate - 是否點選的當月日期

❓ 在點選切換數據時, 因爲isSelected的變化, watch監聽並執行賦值操作, 但此時並沒有必要重新生成prenext

translateIndex - 輪播所在位置

用於控制pre, current, next位置, 當觸發滑動切月時, 通過更改translateIndex來更改位置. 在重新賦值時還原到初始值.

touchStartPositionXtouchStartPositionYtouch

這三個是爲了確定滑動方向及距離的, 向什麼方向滑動? (不要和我說你任性, 就想斜着滑動) 滑動多遠? 鬆手後, 滑動距離小做回彈處理, 滑動距離大做切換處理 (結合translateIndex, 我知道你懂得)

needAnimation - 左右滑動是否需要動畫

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

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

賦部分代碼片段:

切換周視圖

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

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

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

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)
},

優化代碼

到這裏基本就大功告成了, 我們總結下剩下的問題並加以處理, 阿拉霍洞開

源自:https://segmentfault.com/a/1190000039370231

聲明:文章著作權歸作者所有,如有侵權,請聯繫小編刪除。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/4LFzKxXzV-EZX3UBvcGBdQ