移動端日曆元件設計與實現

京東設計中心JDC發表於2022-04-18

前言

在大多數的客戶端應用中,日期的選擇與操作是一個常見的功能,使用日曆元件完成對於這一功能的實現,往往是一個高效的解決方案。對於日曆元件的設計與開發,在常見的開源專案中,通常有兩種設計思路:

  • 橫向切換展示,預設渲染單個月份,通過按鈕或左右滑動,進行月份切換;
  • 縱向切換展示,預設渲染展示多個月份,上下滑動進行月份切換;

例如新增 picker 進行檢視切換,新增自定義按鈕,日期單選/多選,自定義文案,日期範圍限制等等功能,這些基本都是在兩種思路的基礎上進行的功能擴充套件。

對比

在日常的應用中,兩種方式各有優劣:

  • 橫向切換,初始渲染的節點更少,渲染效能更加優異;
  • 縱向切換,有更加直觀的視覺感受,更良好的互動操作;

然而,魚和熊掌不可兼得,互動體驗與效能上的取捨,是一個始終都要直面的問題。隨著移動端裝置的不斷髮展,移動端瀏覽器不斷的完善,使用者裝置在相容性與執行效率上都有明顯提升,因此,本文主要闡述的,是以豎向切換方式實現的 NutUI Calendar 日曆元件。

主題介紹

今天的主題是 NutUI Calendar 元件的設計與實現,Calendar 元件是 NutUI 的一個日曆元件,它用以為使用者提供一個直觀的日期選擇方式,以滑動的方式切換月份,支援單個日期與日期範圍的選擇,支援自定義日期內容等功能。今天,讓我們一起來看看,在元件的開發過程中,是如何一步步實現元件功能的。

示例

元件設計思路

日曆元件,不管以何種方式設計互動,日期時間資料的處理都是必不可少的,畢竟檢視也是為資料資訊服務的。在本文中採取的豎向切換展示的方式,也意味著我們要在節點的渲染效能上做一些優化調整。所以我們的實現思路主要有以下幾點:

思路圖

  1. 日期資料處理,一次性初始化原始資料,在可視區域內,分段渲染節點元素。
  2. 應用虛擬列表的方式,減少節點元素的渲染開支
  3. 滾動事件與邊界條件的處理
  4. 功能完善,豐富 Slots,Props,Events 事件等,提升擴充套件性

元件的實現原理

基本引數需求

在處理日期資料時,我們需要先明確我們所需的基本時間入參,例如:日曆元件的可選時間範圍,當前選中的時間。
通過對傳入引數的解析處理,得到我們所需的資料內容,在之後的開發過程中,完成元件內容的渲染與事件處理。

這裡我畫了一張圖方便大家更好理解:

資料處理

  • 原始日期資料:是我們根據日期範圍計算的原始資料
  • 當前選中日期:可視範圍的展示當前月份,需要判斷選中日期是否在日期範圍內
  • 展示範圍區間:根據當前選中日期處理得出,為當前需要渲染的資料範圍
  • 容器尺寸資訊:用以計算日期滾動切換時的位移資訊

日期資料處理

日期資料的計算,需要有多個處理過程。首先,我們需要先計算傳入的日期範圍是否存在,如果不存在,預設使用最近一年的時間範圍。之後計算存在多少個月。在根據月的數量去遍歷生成日期資料。

在計算單個月日期時,每個月的第一天最後一天的星期數是不同的,我們需要根據不同的星期數,以前一個月與後一個月的日期進行補全。這樣既可以省去計算 1 號開始位置偏移量,也可以為功能擴充套件做出鋪墊。

資料處理

// 獲取單個月的日期與狀態
const getDaysStatus = (currMonthDays: number,  dateInfo: any) => {
  let { year, month } = dateInfo;
  return Array.from(Array(currMonthDays), (v, k) => {
    return {
      day: k + 1,
      type: "curr",
      year,
      month,
    };
  });
  // 獲取上一個月的最後一週天數,填充當月空白
  const getPreDaysStatus = (
    preCurrMonthDays: number
    weekNum: number,
    dateInfo: any,
  ) => {
    let { year, month } = dateInfo;
    if ( weekNum >= 7) {
      weekNum -= 7;
    }
    let months = Array.from(Array(preCurrMonthDays), (v, k) => {
      return {
        day: k + 1,
        type: "prev",
        year,
        month,
      };
    });
    return months.slice(preCurrMonthDays - weekNum);
  };
};

處理後的資料如下:

資料處理

虛擬列表

當我們生成或載入的資料量非常大時,可能會產生嚴重的效能問題,導致檢視無法響應操作一段時間。在小程式中檢視的渲染問題更為明顯,為了解決這個問題,虛擬列表是一種不錯的解決方案:比起全量渲染資料生成的檢視,可以只渲染當前可視區域(visible viewport)的檢視,非可視區域的檢視在使用者滾動到可視區域再渲染。
例如,Taro中的長列表渲染(虛擬列表):

虛擬列表

當然以上只是一個簡單的應用,日曆元件的構建需要在這個的基礎上進行一定的優化。如下圖,months wrapper 為需要展示月份的容器。這樣設定,是因為在我們的視口範圍內,會存在不止一個月份。同時因為單個月份包含的節點較多,當通過 視口邊界 後在進行渲染,可能會存在留白現象,所以我們可以預留部分月份內容,在不可視區域進行節點變更與渲染。

虛擬列表

如上圖所示,

  • scrollWarpper:是一個高度為總月份高度的容器,主要用來作為 viewport 中的滾動容器;
  • monthsWrapper:內為當前渲染出的月份的容器;
  • viewport:為當前視口範圍;

當滾動事件觸發後,scrollWrapper 進行向下或向上移動。到達邊界後,monthsWrapper 內的月份資訊改變,其總體高度也可能發生變化。通過對 monthsWrapper 的 transition 進行修改,保障在月份變更後,視口中內容不變,視口外資料更新。

在應用虛擬列表的同時,結合當前的主流框架,將資料加入框架的響應式資料中,框架使用 diff 演算法或其它機制根據資料的不同,可以對 DOM 節點進行一定程度上的複用,減少 DOM 節點元素的新增與刪除操作。畢竟頻繁的進行 DOM 增刪操作是一件較為消耗效能的事情。

<!-- 視口 -->
<view class="nut-calendar-content" ref="months" @scroll="mothsViewScroll">
  <!-- 整體容器-設定一個總體高度用以撐起視口 -->
  <view class="calendar-months-panel" ref="monthsPanel">
    <!-- 月份容器 -->
    <view
      class="viewArea"
      ref="viewArea"
      :style="{ transform: `translateY(${translateY}px)` }"
    >
      <view
        class="calendar-month"
        v-for="(month, index) of compConthsData"
        :key="index"
      >
        <view class="calendar-month-title">{{ month.title }}</view>
        <view class="calendar-month-con">
          <view
            class="calendar-month-item"
            :class="type === 'range' ? 'month-item-range' : ''"
          >
            <template v-for="(day, i) of month.monthData" :key="i">
              <view
                class="calendar-month-day"
                :class="getClass(day, month)"
                @click="chooseDay(day, month)"
              >
                <!-- 日期顯示slot -->
                <view class="calendar-day">
                  <slot name="day" :date="day.type == 'curr' ? day : ''">
                    {{ day.type == 'curr' ? day.day : '' }}
                  </slot>
                </view>
                <view
                  class="calendar-curr-tip-curr"
                  v-if="!bottomInfo && showToday && isCurrDay(day)"
                >
                  今天
                </view>
                <view
                  class="calendar-day-tip"
                  :class="{ 'calendar-curr-tips-top': rangeTip(day, month) }"
                  v-if="isStartTip(day, month)"
                >
                  {{ startText }}
                </view>
                <view class="calendar-day-tip" v-if="isEndTip(day, month)"
                  >{{ endText }}</view
                >
              </view>
            </template>
          </view>
        </view>
      </view>
    </view>
  </view>
</view>

事件處理與邊界狀態

事件選擇

在 Calendar 元件中,月份的切換變更是通過對滾動事件監聽實現的。
考慮使用滾動事件,是因為考慮到對於 Taro 轉換為微信小程式的相容處理。touchmove 事件同樣可以實現載入切換互動,但是 touch 事件要實現滾動效果,需要頻繁的觸發事件修改元素位置,在小程式中就表現為頻繁的setData,而這會導致較大的效能開銷,使得頁面卡頓。

邊界條件

確定好事件後,邊界條件的判斷,就是我們需要考慮的一個問題:每個月所佔高度,不一定相同。每個月包含有幾個星期,不一定相同。導致每個月所佔據的高度也不一定相同。所以要準確到判斷當前滾動的位置資訊,就需要找到一個相同點來進行判斷。

邊界條件

這裡我們以單個日期的高度作為基準值,通過單個日期的高度計算月份的高度,在得出平均單個月份的高度。滾動位置除以平均高度取得近似 current。
如下圖所示:

資料處理

在計算高度過程中,因為小程式的單位為 rpx,h5 為 rem,所以需要對 px 進行轉換計算。

let titleHeight, itemHeight;
//計算單個日期高度
//對小程式與H5,rpx與rem轉換px處理
if (TARO_ENV === "h5") {
  titleHeight = 46 * scalePx.value + 16 * scalePx.value * 2;
  itemHeight = 128 * scalePx.value;
} else {
  titleHeight =
    Math.floor(46 * scalePx.value) + Math.floor(16 * scalePx.value) * 2;
  itemHeight = Math.floor(128 * scalePx.value);
}
monthInfo.cssHeight =
  titleHeight +
  (monthInfo.monthData.length > 35 ? itemHeight * 6 : itemHeight * 5);
let cssScrollHeight = 0;
//儲存月份位置資訊
if (state.monthsData.length > 0) {
  cssScrollHeight =
    state.monthsData[state.monthsData.length - 1].cssScrollHeight +
    state.monthsData[state.monthsData.length - 1].cssHeight;
}
monthInfo.cssScrollHeight = cssScrollHeight;

當我們得到當前的平均 current,就可以進行邊界條件的判斷。

const mothsViewScroll = (e: any) => {
  const currentScrollTop = e.target.scrollTop;
  // 獲取平均current
  let current = Math.floor(currentScrollTop / state.avgHeight);
  if (current == 0) {
    if (currentScrollTop >= state.monthsData[current + 1].cssScrollHeight) {
      current += 1;
    }
  } else if (current > 0 && current < state.monthsNum - 1) {
    if (currentScrollTop >= state.monthsData[current + 1].cssScrollHeight) {
      current += 1;
    }
    if (currentScrollTop < state.monthsData[current].cssScrollHeight) {
      current -= 1;
    }
  } else {
    // 獲取視口高度 判斷是否已經到最後一個月
    const viewPosition = Math.round(currentScrollTop + viewHeight.value);
    if (
      viewPosition <
        state.monthsData[current].cssScrollHeight +
          state.monthsData[current].cssHeight &&
      currentScrollTop < state.monthsData[current].cssScrollHeight
    ) {
      current -= 1;
    }
    if (
      current + 1 <= state.monthsNum &&
      viewPosition >=
        state.monthsData[current + 1].cssScrollHeight +
          state.monthsData[current + 1].cssHeight
    ) {
      current += 1;
    }
    if (currentScrollTop < state.monthsData[current - 1].cssScrollHeight) {
      current -= 1;
    }
  }
  if (state.currentIndex !== current) {
    state.currentIndex = current;
    setDefaultRange(state.monthsNum, current);
  }
  //設定月份標題資訊
  state.yearMonthTitle = state.monthsData[current].title;
};

讓我們來看一看效果吧:

效果圖

功能完善

通過以上過程,我們已經完成了一個基本的滾動日曆元件。在這個基礎上,我們需要進行一些完善,以擴充套件元件的通用性。

  1. 為日期資訊新增 slots,允許日期資訊自定義展示
  2. 標題處提供 slots。方便使用者插入自定義操作
  3. 標題,按鈕,日期範圍文案等資訊提供 props 設定
  4. 新增回撥方法,如選擇日期,點選日期,關閉日曆等操作
// 未傳入的slot不進行載入,減少無意義的dom
<view
  class="calendar-curr-tips calendar-curr-tips-top"
  v-if="topInfo"
>
  <slot name="topInfo" :date="day.type == 'curr' ? day : ''"></slot>
</view>

功能展示

結語

本文介紹了 NutUI 中 Calendar 元件的設計思路與實現原理,希望可以為大家提供一些靈感與思路。最後再提一下我們的 NutUI 元件庫,長期以來,團隊的小夥伴都在盡心盡力地維護著 NutUI。在之後的日子裡,這種堅持也不會放棄,我們依然會積極地維護與迭代,為有需要的同學提供技術支援,也會不定時地釋出一些相關的文章幫助大家更好地理解與使用我們的元件庫。

來點個 Star ❤️ 支援我們一下吧 ~

相關文章