之前写了一篇Calendar -『为移动端而生』的自定义日历,一直有童鞋对这个插件的手势处理存在一些问题,所以想写篇文章,来说说它的成长史~
在阅读本文之前,确保你有稍微看过 calendar 的效果 喔~
点击查看github, 查看calendar源码
也可以在 NPM上搜索 mob-calendar 找到它。
一、 确认需求
想做一个日历最主要的原因,当然还是因为在开发过程中频繁的遇到。而且对日历的需求又是奇葩到不行,市面上的插件都满足不了我们产品的需求。所以,我不得不动手自己造。
这段话,好像在造 上一个插件 - 级联选择器 的时候也说过
大家就当无事发生过(⁎⁍̴̛ᴗ⁍̴̛⁎)
首要问题依然是处理需求:
第1个问题:『日历的出现场景有哪些特点?』
用户不确定自己要选择的时间点或时间范围,需要一些基本的时间参照单位,比如“下星期一”、“下个周末”。
用户需要查看某个时间区间,之后再有选择性的选取时间点或时间范围,比如“尽可能避开周末的20天翘班请假计划”。
用户需要查看某个时间区间的行为记录,比如“查看过去几周的打卡情况”
当出现以上问题的时候,日历的时间定位优势就显示出来了。
第2个问题:『日历会有哪些奇葩需求?』
日历存在着点击事件,点击事件是 跳转事件 还是 高亮事件 无法预知。
日历存在着选取操作,选取的结果是 时间点 还是 时间范围 无法预知。
日历有多种展现形式,是直接 文档流显示 还是 弹层显示 无法预知。
针对这些不稳定因素,接下来,会带你一步步解决。
二、构造函数的参数设计
确定了日历的需求,就来设计一下构造函数的参数吧~
第3个问题:『日历有哪些常见的展现形式?』
从现在市面上的常见的app上看,我们会发现,日历常见的展现形式有两种:
普通文档流形式
弹层形式
在参数的设置中,表现为设置isMask,false:普通形式,true:弹层形式。

第4个问题:『参数要怎么灵活和高效地设置?』
1. 让开发人员更方便地定位日期
①:在确定时间范围的时候,使用一个 length 为 3 的数组,数组的每一位分别对应【年】【月】【日】
比如beginTime
、endTime
和recentTime
的设定②:在对特定日期指定样式或操作的时候,使用该日期的时间戳。
比如设置beforeRenderArr
的时候,需要传入一个符合规范的对象数组
参数 | 类型 | 举例 | 说明 |
---|---|---|---|
stamp | {Number} | eg:1514822400000 | 指定一个特定的时间戳 |
className | {String} | eg: "enable" | 指定一个用户自己设置的css的类名 |
2. 灵活控制星期的排列、星期的显示格式、月份的显示格式
①:
isSundayFirst
控制星期日是否要放在第一列,true为星期日放第一列②:
isChinese
控制星期的显示方式,true为显示中文,false为显示英文③:
monthType
控制月份的显示格式,以一月份为例,0: 1月, 1: 一月, 2:Jan, 3: January
3. 对最重要的滑动手势做一些配置
①:
angle
控制滑动的角度,间接控制灵敏度,建议取值范围5-20②:
isToggleBtn
是否需要展示切换按钮, true为需要展示③:
canViewDisabled
是否可以查询不在规定范围内的月份,true为可以查询
4. 可供开发者自定义的灵活的回调函数
①:
success
点击某个日期之后的回调,用户自定义点击后的操作。自带参数(item, arr)
。item
为当前点击的时间戳,arr
为智能判断后的连续两次点击的两个时间戳的数组②:
switchRender
切换月份时的回调,用户自定义切换后需要进行的操作,如发起请求更新数据等。自带参数(year, month, cal)
。year
为新生成的年份,month
为新生成的月份(从0开始),cal
指向当前实例
三、暴露在原型上的、可使用的api
名称 | 传入参数的类型 | 作用 |
---|---|---|
renderCallbackArr(arr) | {Array} | 渲染指定的arr,arr的格式和beforeRenderArr 的对象数组的格式一样 |
prevent() | - | 在微信浏览器中,你可能需要用到的阻止默认事件的api |
hideBackground() | - | 在弹层模式的success 回调中,你可能需要用到的关闭弹层的api |
适当解释一下api的用意:
1.向renderCallbackArr
中传入一个数组,(数组格式和beforeRenderArr
一样,不再说明),这个方法能够往你需要的时间点上添加指定样式。设想一种场景:
通过滑动切换,查看三个月前的打卡情况,已打卡和未打卡的日期都有不同的高亮样式。
显然,这个月的打卡情况是需要你在
switchRender
回调中发起http请求后得到。在http返回结果后,构造一个符合
beforeRenderArr
格式的数组,然后调用renderCallbackArr
,传入构造好的数组,就能对指定的日期渲染指定的className了。
// 举个栗子?
switchRender: function(year, month,cal) {
console.log('计算机识别的: 年份: ' + year + ' 月份: ' + month);
$.ajax({
url: 'xxxx',
type: 'GET',
data: {
applyYear: year,
applyMonth: (month + 1),
},
success: function(newArr) {
cal.renderCallbackArr(newArr);
}
})
}
2. 使用prevent()
的场景应该不会太多。主要是为了阻止微信浏览器的默认滑动。
// 这是prevent 方法的源码
prevent: function (e) {
e.preventDefault();
},
3.使用hideBackground()
的场景一般是在弹层模式的success
回调中。设想一种场景:
触发了日历弹层之后,如果你只想【选择一个时间点】,那么点击某个日期之后就可以直接调用
hideBackground()
收起弹层。如果你想【选择某个时间区间】,那么可以在第二个时间点确定之后再调用
hideBackground()
收起弹层。当然,也可以不收起弹层。
四、如何利用五个DOM做到无限滑动
其实我在写第一个版本的日历的时候,采取的解决办法是当新的月份产生之后,往body中不断append dom。不过当时的业务的场景比较简单,撑死也只有10个月。但是显然如果有100个月,我这样的做法明显不行。
所以必须要让dom可以复用,实现无限滑动
思考第5个问题:『无限滑动的话至少需要几个dom呢?』
首先明确,这里指的一个dom就是一个月份,每次切换月份就是切换包裹着月份的dom
如下图,假设当前月份为【2017年9月】,由于滑动是实时的,当我的手指从右向左滑的过程,【2017年10月】也会渐渐的露出来一些,考虑一种特殊情况:
以打卡为例,2017年10月是有打卡记录的,如果等使用者松开手指,停在2017年10月的时候突然闪现出打卡记录的高亮样式,会给使用者很不舒适的感觉。
为避免这种情况,就需要在当前月份为【2017年9月】的时候,就已经渲染好【2017年10月】的高亮样式了,左边的【2017年8月】也是同理,所以至少必须要渲染出完整的、带有数据高亮的三个月
所以我们得到了结论,月份的dom至少为3个,并且这三个dom是已经连高亮样式都渲染好,不会在实时滑动结束后有任何变动的。
但是为什么最后是要用5个dom来实现无限滑动呢?
参考一下swiper的效果,为了能让这三个dom两边的极端dom也能够正常的实时滑动。所以在头尾分别加一个dom,所以一共需要5个dom来实现无限滑动。
如下图,绿色线框的部分为最初开始分析的3个dom。

思考第6个问题:『头尾两个作为填充的dom要显示哪个月呢?』
直接参考一下swiper的效果就能够得到答案,我现在举一个实例来做一些说明:
先考虑以下情况:
手势操作:连续从右向左滑
操作结果:连续查看下个月
以下是图例,红色箭头的更新操作:

以当前进入页面的初始月份是2017年9月为例:
初始状态:


紫色的数字是代表月份dom的下标,相同下标对应的月份也相同。
中间的1、2、3对应的是之前说过的 -----【至少要提前渲染好3个月份的dom】。
那首尾填充的月份为什么是 3 和 1 呢?
假设我们现在不限制5个dom,而是无限个dom,那么代表月份dom的下标组合就会是:
1、2、3、1、2、3、1、2、3、1、2、3......
我们以一个1、2、3为中心,取到连续的5个月份dom,那么取到的下标组合就是:
1、2、【3、1、2、3、1】、2、3、1、2、3......
没懂没关系,看下去就会明白。
思考第7个问题:『为了配合无限滑动,要怎么控制显示的月份呢?』
实际上,未来,我会需要取到dom的下标进行更新月份数据的操作,所以我试图发现【3、1、2、3、1】这个下标数组中的规律。
我发现这个下标循环是3的循环,我可以通过取3的模的方式取到每个位置上的dom下标。
现在我要对这个下标做一点小的改动。
我要把3改成0。即【0、1、2、0、1】
原因很简单,是为了在计算滑动距离的时候,将 dom下标 和 translateX 对应起来比较方便。即当滑到最左侧的月份dom的时候,月份的dom的translateX
的值为0,可以和下标 0 % 3 的结果相对应。
这样,这个下标,就和translateX
直接联系起来了。
好,以初始月份是2017年9月为例,最终初始化的结果为:


接下来,从右向左滑,查看下一个月份,touchend
之后,操作如下:

当滑到了最右边的月份的dom的时候(其实只要滑到边界都做一样的处理),在touchstart
中执行一个特殊操作:
就是在touchstart的时候,瞬间translate3d
到和它dom下标一样的月份去:
比如上面【2017.11】已经到最右边的,那在我下次滑动的touchstart
的时候定位到下图的位置中:


这就是实现无限滑动的核心原理。当然还可以接着一直滑:


以此类推,无限左滑也是类似的道理。
五、为什么需要预判用户手势
从上面讲述无限滑动的原理中,你可以大概感觉到: 滑动的距离是通过控制中间的灰色矩形相对于手机屏幕的translateX
来决定的。
如何控制translateX
的值实现滑动效果,这个问题不是这次的重点。
重点是,思考第8个问题:『如果只在日历的dom区域中控制translateX,当我想滑动整个页面的时候,滑不动,怎么办?』
假设下图中的蓝色曲线代表用户的滑动曲线:
当用户的滑动曲线是A的情况时,用户的意图明显是想把页面往上拉
当用户的滑动曲线是B的情况时,用户的意图明显是想查看上一个月
可实际上,如果只通过控制translateX
的值实现滑动效果的时候,无论是曲线A或者B都会被认为是想查看上一个月

也就是说,如果控制了translateX
,那么,在这个占据着文档流巨大的面积的dom范围内,永远无法上下滑动。这是万万不被允许的。
所以我们需要预判手势,来实现在日历的dom范围内,既能够上下滑动,又能够左右滑动。效果如下:
思考第9个问题:『是否有简单的方式能够预判手势?』
比如之前提到的【滑动曲线A和B】的示例图,如果以绿线为标准,
斜率小于绿线的曲线,都归为和滑动曲线B一样的左右滑动
斜率大于绿线的曲线,都归为和滑动曲线A一样的上下滑动
这样不就可以了吗?
但其实用户的手势曲线一般都是下面的橙色曲线....

而且计算用户手势的斜率一定是在touchmove
中实时计算(为什么?当然是为了实时滑动),所以最后,靠斜率预判用户手势的思路,就到这里结束了。
六、如何利用微积分预判用户手势
思考第10个问题:『对用户手势进行积分,就能够解决问题吗?』
用户的手势实际上是一条弧线,当前只考虑从左下角向右上角滑的情况,就能把用户的手势曲线简化在第一象限中。
如下图,我们从微积分的概念出发,得到以下结论。

先看看中间的红色矩形部分,这个红色矩形是把某个细长条矩形夸张的放大后的矩形,其宽为△X,其高为△Y。
通过touchmove
实时计算每一次滑动的△X 和 △Y,然后累加面积。面积的累加实际上直接按照△X × △Y
的结果正负进行累加,这样就把第一象限的手势推广到所有象限的手势中去了。
计算手势的核心代码如下,其中cal指向当前实例:


我们可以利用用户手势的曲线面积来把用户手势操作量化。
但量化是量化了,要如何知道我量化的结果是上下滑动还是左右滑动呢?
所以就需要像计算斜率时的标准线(那条绿线)一样,必须有一个标准面积。
思考第10个问题:『如何计算标准面积?』
如下图,我们有三条曲线,这三条曲线与X轴围起来的面积,就是我们前面辛辛苦苦量化的结果。其中:
蓝色的曲线围成的面积就是我们理想中的标准面积,虽然还不知道怎么算
黄色的曲线围成的面积比标准面积大,我们将判定所有大于蓝色曲线的量化曲线为【用户试图上下滑动】
绿色的曲线围成的面积比标准面积小,我们将判定所有小于蓝色曲线的量化曲线为【用户试图左右滑动】

问题回到了,如何计算标准面积?
观察上图可以发现有一个明显的蓝色的角A,这个角A和实例化的参数angle是同一个东西。
开发者可以通过控制angle的值(angle的单位是°)来控制标准面积的大小。
当然通过我的测试,angle的取值在 [5 , 20]最佳。
那源码中是如何通过开发者传入的angle进行标准面积的计算的呢?
首先,我会将用户的角度转化为tan值。


为什么需要tan值呢,因为我就可以根据△X
计算得到 △Y = △X * tanA
。


所以标准面积也能通过累加得到了。


至此,我们就可以通过用户手势的面积和标准面积的比较来得到一个比较理想的预判。
通过预判,让用户在页面的任何地方滑动,都感到舒适。
结束语
Github地址:『为移动端而生』的自定义日历插件 https://github.com/AppianZ/calendar
欢迎大家提出宝贵建议和技术交流 ٩(•̤̀ᵕ•̤́๑)
我是嘉宝Appian,一个卖萌出家的算法妹纸(❁ᴗ͈ˬᴗ͈)