写点什么

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

作者:CRMEB
  • 2022 年 4 月 18 日
  • 本文字数:5076 字

    阅读完需:约 17 分钟

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

前言

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

  • 横向切换展示,默认渲染单个月份,通过按钮或左右滑动,进行月份切换;

  • 纵向切换展示,默认渲染展示多个月份,上下滑动进行月份切换;

例如添加 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>
复制代码
复制代码



最后

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

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

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

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

用户头像

CRMEB

关注

还未添加个人签名 2021.11.02 加入

CRMEB就是客户关系管理+营销电商系统实现公众号端、微信小程序端、H5端、APP、PC端用户账号同步,能够快速积累客户、会员数据分析、智能转化客户、有效提高销售、会员维护、网络营销的一款企业应用

评论

发布
暂无评论
移动端日历组件设计与实现_CRMEB_InfoQ写作平台