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

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

横向切换展示,默认渲染单个月份,通过按钮或左右滑动,进行月份切换;
纵向切换展示,默认渲染展示多个月份,上下滑动进行月份切换;
例如添加 picker 进行视图切换,添加自定义按钮,日期单选/多选,自定义文案,日期范围限制等等功能,这些基本都是在两种思路的基础上进行的功能扩展。

移动端日历组件设计与实现_第1张图片

在日常的应用中,两种方式各有优劣:

横向切换,初始渲染的节点更少,渲染性能更加优异;
纵向切换,有更加直观的视觉感受,更良好的交互操作;
然而,鱼和熊掌不可兼得,交互体验与性能上的取舍,是一个始终都要直面的问题。随着移动端设备的不断发展,移动端浏览器不断的完善,用户设备在兼容性与运行效率上都有明显提升,因此,本文主要阐述的,是以竖向切换方式实现的 NutUI Calendar 日历组件。

主题介绍
今天的主题是 NutUI Calendar 组件的设计与实现,Calendar 组件是 NutUI 的一个日历组件,它用以为用户提供一个直观的日期选择方式,以滑动的方式切换月份,支持单个日期与日期范围的选择,支持自定义日期内容等功能。今天,让我们一起来看看,在组件的开发过程中,是如何一步步实现组件功能的。
移动端日历组件设计与实现_第2张图片

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

移动端日历组件设计与实现_第3张图片

日期数据处理,一次性初始化原始数据,在可视区域内,分段渲染节点元素。
应用虚拟列表的方式,减少节点元素的渲染开支
滚动事件与边界条件的处理
功能完善,丰富 Slots,Props,Events 事件等,提升扩展性
组件的实现原理
基本参数需求
在处理日期数据时,我们需要先明确我们所需的基本时间入参,例如:日历组件的可选时间范围,当前选中的时间。 通过对传入参数的解析处理,得到我们所需的数据内容,在之后的开发过程中,完成组件内容的渲染与事件处理。

这里我画了一张图方便大家更好理解:

移动端日历组件设计与实现_第4张图片

原始日期数据:是我们根据日期范围计算的原始数据
当前选中日期:可视范围的展示当前月份,需要判断选中日期是否在日期范围内
展示范围区间:根据当前选中日期处理得出,为当前需要渲染的数据范围
容器尺寸信息:用以计算日期滚动切换时的位移信息
日期数据处理
日期数据的计算,需要有多个处理过程。首先,我们需要先计算传入的日期范围是否存在,如果不存在,默认使用最近一年的时间范围。之后计算存在多少个月。在根据月的数量去遍历生成日期数据。

在计算单个月日期时,每个月的第一天与最后一天的星期数是不同的,我们需要根据不同的星期数,以前一个月与后一个月的日期进行补全。这样既可以省去计算 1 号开始位置偏移量,也可以为功能扩展做出铺垫。

移动端日历组件设计与实现_第5张图片

// 获取单个月的日期与状态
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);
  };
};
复制代码

处理后的数据如下:

移动端日历组件设计与实现_第6张图片

虚拟列表
当我们生成或加载的数据量非常大时,可能会产生严重的性能问题,导致视图无法响应操作一段时间。在小程序中视图的渲染问题更为明显,为了解决这个问题,虚拟列表是一种不错的解决方案:比起全量渲染数据生成的视图,可以只渲染当前可视区域(visible viewport)的视图,非可视区域的视图在用户滚动到可视区域再渲染。 例如,Taro中的长列表渲染(虚拟列表):

移动端日历组件设计与实现_第7张图片

当然以上只是一个简单的应用,日历组件的构建需要在这个的基础上进行一定的优化。如下图,months wrapper 为需要展示月份的容器。这样设置,是因为在我们的视口范围内,会存在不止一个月份。同时因为单个月份包含的节点较多,当通过 视口边界 后在进行渲染,可能会存在留白现象,所以我们可以预留部分月份内容,在不可视区域进行节点变更与渲染。
移动端日历组件设计与实现_第8张图片

如上图所示,

scrollWarpper:是一个高度为总月份高度的容器,主要用来作为 viewport 中的滚动容器;
monthsWrapper:内为当前渲染出的月份的容器;
viewport:为当前视口范围;
当滚动事件触发后,scrollWrapper 进行向下或向上移动。到达边界后,monthsWrapper 内的月份信息改变,其总体高度也可能发生变化。通过对 monthsWrapper 的 transition 进行修改,保障在月份变更后,视口中内容不变,视口外数据更新。

在应用虚拟列表的同时,结合当前的主流框架,将数据加入框架的响应式数据中,框架使用 diff 算法或其它机制根据数据的不同,可以对 DOM 节点进行一定程度上的复用,减少 DOM 节点元素的新增与删除操作。毕竟频繁的进行 DOM 增删操作是一件较为消耗性能的事情。



  
  
    
    
      
        {{ month.title }}
        
          
            
          
        
      
    
  

复制代码

事件处理与边界状态
事件选择

在 Calendar 组件中,月份的切换变更是通过对滚动事件监听实现的。 考虑使用滚动事件,是因为考虑到对于 Taro 转换为微信小程序的兼容处理。touchmove 事件同样可以实现加载切换交互,但是 touch 事件要实现滚动效果,需要频繁的触发事件修改元素位置,在小程序中就表现为频繁的setData,而这会导致较大的性能开销,使得页面卡顿。

边界条件

确定好事件后,边界条件的判断,就是我们需要考虑的一个问题:每个月所占高度,不一定相同。每个月包含有几个星期,不一定相同。导致每个月所占据的高度也不一定相同。所以要准确到判断当前滚动的位置信息,就需要找到一个相同点来进行判断。

移动端日历组件设计与实现_第9张图片

这里我们以单个日期的高度作为基准值,通过单个日期的高度计算月份的高度,在得出平均单个月份的高度。滚动位置除以平均高度取得近似 current。 如下图所示:

移动端日历组件设计与实现_第10张图片

在计算高度过程中,因为小程序的单位为 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;
};
复制代码

让我们来看一看效果吧:

移动端日历组件设计与实现_第11张图片

功能完善
通过以上过程,我们已经完成了一个基本的滚动日历组件。在这个基础上,我们需要进行一些完善,以扩展组件的通用性。

为日期信息添加 slots,允许日期信息自定义展示
标题处提供 slots。方便用户插入自定义操作
标题,按钮,日期范围文案等信息提供 props 设置
添加回调方法,如选择日期,点击日期,关闭日历等操作

// 未传入的slot不进行加载,减少无意义的dom

  


复制代码

移动端日历组件设计与实现_第12张图片

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

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

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

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

你可能感兴趣的:(日历)