前言
在大多数的客户端应用中,日期的选择与操作是一个常见的功能,使用日历组件完成对于这一功能的实现,往往是一个高效的解决方案。对于日历组件的设计与开发,在常见的开源项目中,通常有两种设计思路:
例如添加 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 为需要展示月份的容器。这样设置,是因为在我们的视口范围内,会存在不止一个月份。同时因为单个月份包含的节点较多,当通过 视口边界
后在进行渲染,可能会存在留白现象,所以我们可以预留部分月份内容,在不可视区域进行节点变更与渲染。
如上图所示,
当滚动事件触发后,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
评论