前言—
在大多数的客户端应用中,日期的选择与操作是一个常见的功能,使用日历组件完成对于这一功能的实现,往往是一个高效的解决方案。对于日历组件的设计与开发,在常见的开源项目中,通常有两种设计思路:
-
横向切换展示,默认渲染单个月份,通过按钮或左右滑动,进行月份切换; -
纵向切换展示,默认渲染展示多个月份,上下滑动进行月份切换;
例如添加 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[1]中的长列表渲染(虚拟列表):
当然以上只是一个简单的应用,日历组件的构建需要在这个的基础上进行一定的优化。如下图,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[2],而这会导致较大的性能开销,使得页面卡顿。
边界条件
确定好事件后,边界条件的判断,就是我们需要考虑的一个问题:每个月所占高度,不一定相同。每个月包含有几个星期,不一定相同。导致每个月所占据的高度也不一定相同。所以要准确到判断当前滚动的位置信息,就需要找到一个相同点来进行判断。
这里我们以单个日期的高度作为基准值,通过单个日期的高度计算月份的高度,在得出平均单个月份的高度。滚动位置除以平均高度取得近似 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>
结语—
本文介绍了 NutUI 中 Calendar 组件的设计思路与实现原理,希望可以为大家提供一些灵感与思路。最后再提一下我们的 NutUI 组件库,长期以来,团队的小伙伴都在尽心尽力地维护着 NutUI。在之后的日子里,这种坚持也不会放弃,我们依然会积极地维护与迭代,为有需要的同学提供技术支持,也会不定时地发布一些相关的文章帮助大家更好地理解与使用我们的组件库。
来点个 Star ❤️ 支持我们一下吧 ~[3]
参考资料
Taro: https://taro-docs.jd.com/taro/docs/virtual-list
[2]setData: https://developers.weixin.qq.com/miniprogram/dev/framework/performance/tips/runtime_setData.html
[3]来点个 Star ❤️ 支持我们一下吧 ~: https://github.com/jdf2e/nutui
本文分享自微信公众号 - 凹凸实验室(AOTULabs)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。