项目背景

最近刚刚用vue完成了一个商城项目,ui给的页面里有个如下图的经典滚动组件,带有下拉刷新和上拉加载的功能。我不禁虎躯一震,有点意思。

鉴于老板一直比较讨厌微信下拉露黑底的问题(其实我一直觉得这不是问题,京东也这样)。还是照例使用IScroll-lite作为滚动组件。

iscroll-lite源码解析

iscroll-lite作为iscroll专为移动端所做的精简版,代码控制在1000行以内。
首先肯定考虑到从touchstart,touchmove,touchend事件回调入手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
327: this._init();
352: _init: function () {
this._initEvents();
},
895: _initEvents: function(){
var eventType = remove ? utils.removeEvent : utils.addEvent,
target = this.options.bindToWrapper ? this.wrapper : window;
.......
if ( utils.hasTouch && !this.options.disableTouch ) {
eventType(this.wrapper, 'touchstart', this);
eventType(target, 'touchmove', this);
eventType(target, 'touchcancel', this);
eventType(target, 'touchend', this);
}
}
50: me.addEvent = function (el, type, fn, capture) {
el.addEventListener(type, fn, !!capture);
};
me.removeEvent = function (el, type, fn, capture) {
el.removeEventListener(type, fn, !!capture);
};

我们可以清楚的看到在IScroll构造函数中调用prototype中的_init方法,完成事件的初始化,这里将this也就是当前对象this传递给addEventListener的第二个参数,那么比较下下面两种事件的注册方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// using funcion handle
function clickHandler() {
console.log('clickHandler:', this); //document
}
document.addEventListener('click', clickHandler, false);
// using obj handle
var handleObj = {
handleEvent: function() {
console.log("handleEvent obj:", this); //handleObj
}
}
document.addEventListener('click', handleObj, false);

可以看出使用function handler,this指向的是绑定事件的元素本身;使用object handler,this指向的是该对象。而IScroll使用的就是object handler,它将为IScroll.prototype添加了handleEvent方法,使得其更容易调用原型上的其他方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
handleEvent: function (e) {
switch ( e.type ) {
case 'touchstart':
case 'pointerdown':
case 'MSPointerDown':
case 'mousedown':
//add code
this.options.touchstartCall();
this._start(e);
break;
case 'touchmove':
case 'pointermove':
case 'MSPointerMove':
case 'mousemove':
this._move(e);
break;
case 'touchend':
case 'pointerup':
case 'MSPointerUp':
case 'mouseup':
case 'touchcancel':
case 'pointercancel':
case 'MSPointerCancel':
case 'mousecancel':
//add code
this.options.touchendCall(); //在_end函数中resetPosition之前调用touchendcall
this._end(e);
break;
.........
}
}

滑动加速度的实现

之后在_start回调函数中记录了startX,absStartX等等起始值。在_move回调中this._translate(newX, newY);进行页面css3位移,以垂直滚动的newY计算为例:

1
2
3
4
5
6
7
8
//522
newY = this.y + deltaY; //this.y是当前滚动y值
//467
var point = e.touches ? e.touches[0] : e,
//469
deltaY = point.pageY - this.pointY,
//474
this.pointY = point.pageY;

可以看出当你快速滑动的时候,deltaY作为垂直方向的加速度,值也越大,页面也会滑动更迅速。这就是滑动加速的实现。

bounce效果的实现

还是以垂直滚动为例,其实还是newY的计算问题。看_move方法中的这段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//_move
if ( newY > 0 || newY < this.maxScrollY ) {
newY = this.options.bounce ? this.y + deltaY / 3 : newY > 0 ? 0 : this.maxScrollY;
}
//_end
if ( this.resetPosition(this.options.bounceTime) ) {
return;
}
resetPosition{
//......
if ( !this.hasVerticalScroll || this.y > 0 ) {
y = 0;
} else if ( this.y < this.maxScrollY ) {
y = this.maxScrollY;
}
//......
this.scrollTo(x, y, time, this.options.bounceEasing);
}

newY > 0就是上滑超出边界,newY < this.maxScrollY就是下滑超出边界。这时,this.options.bounce默认值为true,newY的值就以this.y + deltaY / 3减速增长。在_end中又调用resetPosition,resetPostion scrollTo正常的边界,这就是bounce的实现

scrollTo滚动的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//iscroll-probe中代码
if ( this.options.probeType == 3 ) {
this.options.useTransition = false;
}
scrollTo: function (x, y, time, easing) {
easing = easing || utils.ease.circular;
this.isInTransition = this.options.useTransition && time > 0;
if ( !time || (this.options.useTransition && easing.style) ) {
this._transitionTimingFunction(easing.style);
this._transitionTime(time);
this._translate(x, y);
} else {
this._animate(x, y, time, easing.fn);
}
}
_animate: function (destX, destY, duration, easingFn) {
var that = this,
startX = this.x,
startY = this.y,
startTime = utils.getTime(),
destTime = startTime + duration;
function step () {
var now = utils.getTime(),
newX, newY,
easing;
if ( now >= destTime ) {
that.isAnimating = false;
that._translate(destX, destY);
if ( !that.resetPosition(that.options.bounceTime) ) {
that._execEvent('scrollEnd');
}
return;
}
now = ( now - startTime ) / duration;
easing = easingFn(now);
newX = ( destX - startX ) * easing + startX;
newY = ( destY - startY ) * easing + startY;
that._translate(newX, newY);
if(that.isAnimating) {
tempRfa = rAF(step);
}
if ( !!that.options.pullupH ) {
that._execEvent('scroll');
}
}
this.isAnimating = true;
step();
},

由于会下拉刷新和上拉加载更多会需要监听滚动事件,而iscroll-probe中需要监听滚动时将useTransition设置为false,导致滚动调用的一致都是_animate,_animate方法用到了requestAnimationFrame,也就是逐帧动画,每一帧绘制结束之后调用step函数,完成滚动的动画实现。

IScroll的主要代码分析大概就是这样,接下来到了vue刷新组件的实现

vue滚动刷新插件的实现

先贴出项目github地址:vue pullup refresh and pulldown loadmore component
这是可能出现的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div :id="wrapperId" class="wrapper">
<div :id="scrollId" class="scroll">
<div v-if="needRefresh" class="pullup">
<div v-show="pullupStatus==1">↓下拉刷新</div>
<div v-show="pullupStatus==2">↑释放立即刷新</div>
<div v-show="pullupStatus==3">加载中...</div>
<div v-show="pullupStatus==4">刷新成功</div>
<div v-show="pullupStatus==5">刷新失败,请重试</div>
</div>
<slot></slot>
<div v-if="needRefresh && hasMoreData" class="pulldown">
<!-- <div v-show="pulldownStatus==1">上拉加载更多</div> -->
<div v-show="pulldownStatus==2">加载中...</div>
<div v-show="pulldownStatus==4">加载失败,请重试</div>
</div>
<div v-if="!hasMoreData && moreTip" class="noData">没有更多数据了</div>
</div>
</div>
</template>

pullup和pulldown高度都限定为40。下拉刷新思路如下:

  1. 用户下拉,页面要随之下拉。这里有个问题:iscroll小于一屏的时候没有bounce效果。所以这里要加上这段代码;
  2. 下拉高度>2*pullupH,pulldownStatus从1变成2;
  3. 用户释放,如果pullupStatus==2,则这是激发刷新操作,pullupStatus赋值为3;反之如果pullupStatus==1,则不做任何操作;这里有个问题:在加载的过程中,应该滚动到y=pullupH的高度,只有接收到refresh-finish的broadcast的时候,才滚动到0。所以这里肯定要修改resetPosition的代码;

刷新相关重点代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//ZZScroll.vue
//解决不满一页bounce效果代码
//add code 模拟小于一页弹性滑动
moveOverScroll: function(newY){
//处理在swiper中纵向滑动的scroller在横向swipe时禁止滑动
if(this.directionLocked == 'h' && this.options.hswipeStopScroll && this.options.scrollY){
return 0;
}
//对于小于一页不能下拉刷新的问题,修改,计算滑动效果
if(this.options.pullupH > 0 && this.y >= 0){
var tempx = this.pointY-this.startPointY + this.absStartY;
var tempplH = this.options.pullupH;
if(tempx > 0){ //只在下拉过程有效,并且在touch move函数中
if(tempx > 0 && tempx <= tempplH){
} else if(tempx <= tempplH * 2){
tempx = tempplH + (tempx - tempplH) * 0.5;
} else if(tempx > tempplH * 2){
tempx = tempplH + tempplH * 0.5 + (tempx - tempplH * 2) * 0.2
}
// console.log(tempx+'---------'+(this.pointY-this.startPointY));
if ( this.options.probeType == 3 ) {
this._execEvent('scroll');
}
return tempx;
}
}
return newY;
},
//_move
newY = this.moveOverScroll(newY); //计算小于一页滑动
this._translate(newX, newY);

在move函数中调用moveOverScroll函数,手动计算滚动y值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
//zzScroll.vue
teCall () {
this.isTouch = false;
if(this.pullupStatus == 2){ /*激发下拉操作了*/
this.pullupStatus = 3;
this.myScroll.setRefresh(true); //teCall调用之后调用resetPosition 回滚到pullupH高度
this.refreshCall(this.scrollId);
}
}
//.........
//监听父组件$broadcast msg
"refresh-finish": function(msg){
let self = this;
msg = msg || {};
msg.scrollId = msg.scrollId || 'scroll';
if(self.scrollId == msg.scrollId){ //区分一个页面同时有多个scroll
if(msg.errorCode == 0)
self.pullupStatus = 4;
else
self.pullupStatus = 5;
self.myScroll.setRefresh(false);
if(self.hasMoreData)
self.pulldownStatus = 2; //重新修改load-finish状态。hasMoreData的双向绑定没有这么快生效,要放在setTimeout中
}
},
//iscroll-lite-zly
setRefresh: function(isRefreshing){
var self = this;
if(isRefreshing){
this.isRefreshing = true;
} else { //refresh结束分为两种情况
this.isRefreshing = false;
if(this.isAnimating){ //还没有滚动到40,正在滚动过程中。直接滚动到0,不能直接调用resetPostion
function endCall(){
self.off('scrollEnd', endCall);
// self.finishAnimate = false;
self.refresh();
}
this.on('scrollEnd', endCall);
} else { //正在40这里等待刷新成功
this.resetPosition(this.options.bounceTime);
}
}
},
resetPosition: function(time){
//*******
//add Code
if(this.isRefreshing){
y = this.options.pullupH;
} else {
y = y;
}
//*********
},
_animate: function (destX, destY, duration, easingFn) {
//*********
if ( !that.resetPosition(that.options.bounceTime) ) {
that._execEvent('scrollEnd');
}
//********
}

这一段就是刷新的代码,teCall是自定义的touchend回调,在teCall中设置isRefreshing为true。这样在_end中调用resetPosition的时候,y值就会被设置成为pullupH。resetPosition也就回scrollTo(0, pullupH)。如果这个时候刷新结束,在setRefresh函数中会监听_animate的scrollEnd事件;这里不需要再次手动调用_animate,因为在_animate函数中,滚动结束会调用resetPostion,而这时isRefresh已经变成了false,所以又是resetPosition会scrollTo(0, 0); 等到真正滚动到0,animate中也就回_execEvent(‘scrollEnd’); 这时再refresh当前页面,重新计算高度。

加载更多相关重点代码

上拉加载更多其实没什么好说的。无非就是一个滚动到哪里激发事件的问题,这里注意还要考虑加载一次之后可能还是不足一页的情况

1
2
3
showLoadMore () { //判断是否达到下拉加载更多的时机(maxScrollY==0时不足一页)
return (this.myScroll.y<this.myScroll.maxScrollY+pullupH/2) || (this.myScroll.maxScrollY==0);
},

最终效果