关于日历:移动端日历组件设计与实现

51次阅读

共计 6153 个字符,预计需要花费 16 分钟才能阅读完成。

前言
在大多数的客户端利用中,日期的抉择与操作是一个常见的性能,应用日历组件实现对于这一性能的实现,往往是一个高效的解决方案。对于日历组件的设计与开发,在常见的开源我的项目中,通常有两种设计思路:

横向切换展现,默认渲染单个月份,通过按钮或左右滑动,进行月份切换;
纵向切换展现,默认渲染展现多个月份,高低滑动进行月份切换;
例如增加 picker 进行视图切换,增加自定义按钮,日期单选 / 多选,自定义文案,日期范畴限度等等性能,这些根本都是在两种思路的根底上进行的性能扩大。

在日常的利用中,两种形式各有优劣:

横向切换,初始渲染的节点更少,渲染性能更加优异;
纵向切换,有更加直观的视觉感触,更良好的交互操作;
然而,鱼和熊掌不可兼得,交互体验与性能上的取舍,是一个始终都要直面的问题。随着挪动端设施的一直倒退,挪动端浏览器一直的欠缺,用户设施在兼容性与运行效率上都有显著晋升,因而,本文次要论述的,是以竖向切换形式实现的 NutUI Calendar 日历组件。

主题介绍
明天的主题是 NutUI Calendar 组件的设计与实现,Calendar 组件是 NutUI 的一个日历组件,它用认为用户提供一个直观的日期抉择形式,以滑动的形式切换月份,反对单个日期与日期范畴的抉择,反对自定义日期内容等性能。明天,让咱们一起来看看,在组件的开发过程中,是如何一步步实现组件性能的。

组件设计思路
日历组件,不论以何种形式设计交互,日期工夫数据的解决都是必不可少的,毕竟视图也是为数据信息服务的。在本文中采取的竖向切换展现的形式,也意味着咱们要在节点的渲染性能上做一些优化调整。所以咱们的实现思路次要有以下几点:

日期数据处理,一次性初始化原始数据,在可视区域内,分段渲染节点元素。
利用虚构列表的形式,缩小节点元素的渲染开销
滚动事件与边界条件的解决
功能完善,丰盛 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;
};
复制代码

让咱们来看一看成果吧:

功能完善
通过以上过程,咱们曾经实现了一个根本的滚动日历组件。在这个根底上,咱们须要进行一些欠缺,以扩大组件的通用性。

为日期信息增加 slots,容许日期信息自定义展现
题目处提供 slots。不便用户插入自定义操作
题目,按钮,日期范畴文案等信息提供 props 设置
增加回调办法,如抉择日期,点击日期,敞开日历等操作

// 未传入的 slot 不进行加载,缩小无意义的 dom
<view
  class="calendar-curr-tips calendar-curr-tips-top"
  v-if="topInfo"
>
  <slot name="topInfo" :date="day.type =='curr'? day :''"></slot>
</view>

复制代码

最初
如果你感觉这篇文章对你有点用的话,麻烦请给咱们的开源我的项目点点 star:http://github.crmeb.net/u/defu 不胜感激!

收费获取源码地址:http://www.crmeb.com

PHP 学习手册:https://doc.crmeb.com

技术交换论坛:https://q.crmeb.com

正文完
 0