寫寫【Codepen】吧 - 無限滾動日曆

今天不知道哪來的興致(明明手上還一大堆雜事),秉持著「想到就要立馬做」的精神,還是把它來寫一寫吧。

Infinite Scroll Calendar 無限滾動日曆

這是一個可以無限滾動的日曆,在某個專案上設計師提案要仿冒一個完全的 iCalendar。以前有個工程師前輩,他雖然是寫 C 語言的,但他告訴過我通常工程師第一關就是自己算一個萬年曆出來,這次寫完也有種,哇~好像跨了什麼門檻哈哈…(可是明明以前已經寫過正常版的日曆…)。

由於我電腦本機容量幾乎快滿了,這次就直接寫在 Codepen 上好了,反正沒用過 Codepen,來玩玩好像也滿有趣。

使用工具⬇

CSS:SCSS (我處於接案型態公司,維護專案時,還是有很多CSS我很懶得全改成Sass所以就使用這種可以通用的) Javascript:VUE2 (做此專案的時候是使用VUE2,所以基於懶人個性,就繼續用這個寫文章吧)

開了直接使用 Codepen 模板
開了直接使用 Codepen 模板

首先在資料部分先定義好我需要的一些基本資料

我需要目前日期 currentDate,日期清單 datesList。再來需要翻譯中文的星期、月文字 monthZh, weeks,以及畫面上要顯示的年月 showYear, showMonth。

開了直接使用 Codepen 模板

我是個視覺導向的人,所以我喜歡先排版,看到畫面再說XD

Html 先做起來,日期要有資料才能顯示,我這裡就先用「星期」來做排版。在滾動時「星期」是要固定在最上方的,這樣就不用還要每次算都要把「星期」寫進去,體驗也就是稍微比較酷。

CSS排版部分因為要所有格子都長的一樣,所以把排版分開寫,.calendar 底下全部的 .col 都是一樣寬度、一樣 border、一樣週六日長的比較帥。.week 要固定在最上方所以先給他個 index 1,然後格子比較高線才能頂到天花板。

開了直接使用 Codepen 模板

再來日期的部分我的邏輯是想說,寫一個 Function / Method,在初始登入時就先算當月份有多少格子,再來每次滾動快要到下個月時再重新調用這個 Function。(我還是喜歡說 Function 不喜歡說 Method,因為本來就是調用 Function啊!)

由於體驗與一般的日曆不同,不是藉由每個月切換 Tab 來顯示日期,而是要滾動時慢慢一直增加下個月份的格子。所以我從體驗的順序來思考程式的寫法。

算當月的格子需要分成三個步驟,這幾乎就是全部日曆最需要的部分,寫完這個等於寫完了。

  1. 第一週的日期格子,只有在初始化的時候需要知道上個月有多少天在同一週,之後滾動填入格子只需要知道從哪一天開始填入就好。
  2. 最後一週的日期格子
  3. 加總該月份所有日期格子

開始前,先初始化一下,現在日期的資料。

created() {
  this.currentDate.year = new Date().getFullYear()
  this.currentDate.month = new Date().getMonth()
  this.currentDate.date = new Date().getDate()
  this.showMonth = this.currentDate.month 
  // 標題要展示的月份,由於我的月份陣列本來就是從 0 開始
  // 所以不用特別去 + 1 得到正確的月份
}

然後建立我要的 Function 名稱,getCoolDatesList()

methods: {
  getCoolDatesList(year, month, next){
    // 接著在這開始寫啦
  }
}

第一週的日期格子

這邊我是想先知道第一天是星期幾

// 第一天的日期
const monthFirstDate = new Date(year, month, 1)
// 第一天的星期
const firstDateDay = monthFirstDate.getDay()
// 該月份的全部天數,就只是用下個月的0號
// 就會知道該月份最後一天是幾號,也就等於是該月份所有天數
const currentMonthDates = new Date(year, month + 1, 0).getDate()

然後因為星期日 getDay() 會得到 0,我想把它換成 7,就簡單換一下如果是 0 就等於 7。

const firstDateDay = 
monthFirstDate.getDay() === 0 ? 7 : monthFirstDate.getDay()

最後就可以得到初始時,我的第一格子是誰。

const initFirstWeekDate = new Date(year, month, 2 - firstDateWeek)
// 因為我是星期日在最後一格,所以用 2 - firstDateWeek

最後一週的日期格子

因為我想要每次讀取完都可以跑出剛剛好的星期排數,不是這星期有幾天沒出現,所以首先要知道這個月最後一天是星期幾,然後還有幾天是屬於下個月的。用一星期七天下去減,跟剛剛邏輯剛好反過來,減完如果是 7 則代表沒有任何天數了,最後一天也是最後一格,所以就直接等於 0。

加總該月份所有日期格子

因為 firstDateDay 是星期,需要扣掉本身該日,所以 -1 才會得到正確的格數

let totalDates = 
firstDateDay - 1 + currentMonthDates + lastWeekOverDates

這樣我們有了格數之後就可以開數組裝日期數據囉。由於我的專案之前是需要把該月格數都拿到之後,再依照個別日期塞進各種雜七雜八資料。所以在這時候如果有資料格式就可以先做,例如以下 postLists, holiday 之類的。

// 在外部先宣告一個 Object
let dateObj
for (let i = 0; i < totalDates; i++) {
   // 每一格的日期
   dateObj = new Date(
      initFirstWeekDate.getFullYear(),
      initFirstWeekDate.getMonth(),
      initFirstWeekDate.getDate() + i
  )
  // 然後推進去陣列裡面
  this.datesList.push({
      year: dateObj.getFullYear(),
      month: dateObj.getMonth(),
      date: dateObj.getDate(),
      postLists: [],
      holiday: {}
  })
}

最後組裝起來,大概長以下這樣。與上面唯一不同的是,要在 created 的時候把 Function 叫出來一下喔。

渲染出來看結果

到目前為止,感覺已經差不多做完了XD。有看到我剛剛的 Function ,getCoolDatesList(year, month, next) 裡面有個 next 嗎?這是我拿來區分是否為第一次渲染用的 true / false。因為接下來要做往下滾動繼續算出更多日期,我們格子的算法需要剔除掉第一週有上個月份日期的這件事情。

渲染很簡單,只是 v-for 出 datesList 而已,有需要別的東西就另外再自己塞HTML 吧。

開始聽滾動的事件囉

由於我之前是寫 Nuxt,會有 server side render 以及 client side render 問題,我習慣 client side 我就全部放在 mounted 做,不想寫 if (process.client) …。而且在 mounted 是已經確保所有生命週期都跑差不多,我也會做開啟日曆的特效,所以在 mounted 才開始做 client 端需要的東西我覺得是比較好的。

另外原因:在 mounted 我們才可以拿到 window 的參數。

首先我要可以拿到 Dom 才能聽滾動,我是用 vue 的 ref,當然直接 document.query 也是可以。

<div class="dates wrap" ref="calContent">

開始聽吧

mounted() {
  this.$refs.calContent.addEventListener('scroll', this.coolScroll)
},
methods: {
  getCoolDatesList(year, month, next){
    // 日期的
  },
  coolScroll(event){
    // 滾動相關從這開始寫
  }
}

要能聽到東西,首先日曆內 CSS 要讓他自己滾動,你也可以使用 body 的滾動,但我之後是會把它包成某個部份中的元件,所以我不用 body 來聽。

所以我的 .dates 就變成了以下

.dates{ 
  我不喜歡原生的 bar,通常會直接隱藏,有需要才自己寫
  -ms-overflow-style: none; 
  scrollbar-width: none; /* Firefox */
  &::-webkit-scrollbar{
      display: none;
  }
  這裡主要就是 for 滾動的
  position: relative;
  overflow-y: auto;
  overflow-x: hidden;
  .col{
    border-bottom: 1px solid rgba(255,255,255,.2);
  }
}

Oh YA 滾滾

我們要拿到最重要的三個數值,height 外觀的高度,totalHeight 裏面全部的高度,最後 scrollValue 滾動的值。

let height = event.target.clientHeight
let totalHeight = event.target.scrollHeight
let scrollValue = event.target.scrollTop
console.log(height, totalHeight, scrollValue)

我想要滾到一半就讀取下個月了,所以我要知道 scrollValue > ?多少的時候開始執行新的日曆。因為滾動起始是 0,滾到最底還碰不到 totalHeight 的數值,原因就是你還要加進眼前畫面的高度。

scrollValue > ( totalHeight總高 - height畫面高 )
所以判斷會變成這樣
if (scrollValue >= totalHeight - height) {
  console.log('碰到啦')
}

但這樣到底才開始讀取,會看到醜醜的畫面,我想要先跑。

if (scrollValue >= totalHeight - height - 您要偷跑的值) {
  console.log('這樣就是偷跑啦')
}

滾動搞定,接著回去處理日曆的部分

我們要把算下個月的寫法寫進去。會有差別的就是第一週了,下個月會出現一種狀況。

第一週已經被上個月渲染過了,我撈下個月時不可以跑出已經被渲染過的,我們可以從這個月的第一天是否是星期一來判斷這件事情,因為依照格子的設計星期一是在第一格。如果不是星期一那就一定被渲染過了。

如果已經被渲染過,那我的第一格 initFirstWeekDate 就會是第二週的星期一。因為許多邏輯剛剛上面已經順過了,我這邊就貼 Code 就好。

進來的時候變成先判斷是否為下個月要進場,是的話則開始重新安排 newNextFirst 是哪一天。稍微講一下如果已經被渲染過,那我就從該月第一天是星期幾,用七天去減星期。

假設是1號是星期三,那我下週就是 7 - 3=4天,再加上本身的1天,這樣就是5天,這個5天是我日曆已經被上個月渲染完的5天,所以總格數要先減去這個5天。

接著 initFirstWeekDate 因為是這個月我要拿的第一天,會是6號,就直接使用 newNextFirst + 1 就會是那個六號囉。

newNextFirst =  monthFirstDate.getDay() === 0 ? 7 : monthFirstDate.getDay()
newNextFirst = 7 - newNextFirst + 1
initFirstWeekDate = new Date(year, month, 7 - newNextFirst + 1)

好啦完結啦!

寫點特效進去,在該月份格子才會亮(偷學iCalendar) ,一樣懶人做法,直接比對 showMonth,Vue 已經幫你做好了。

<div class="num din" :class="{cur: date.month === showMonth}">
   {{ date.date }}
</div>

結果就是這樣囉~

想玩玩實體的朋友可以到我們的 ↗Codepen 去玩~

終於完成啦,我的第一篇技術文…!如果文法不順請多多包涵。第一次寫其實我也不知到底要不要用灰色底的程式碼顯示,因為那沒有樣式,所以變成部分用截圖的比較完整。然後寫法還有很多地方其實可以優化的,但還是以講解為主,拆的比較開。

目前視覺效果還很粗糙,僅只是可以無限滾動看日曆而已。如果想要知道更多如何放進資料、例如該日的所有活動列表、往回滾動看日期等等。

可以再寫信跟我們說喔!