iScroll 源码学习

前言

强大的 iScroll 啊,功能的确多,但我只想看看你的 flinger 滑动处理的精髓!感觉 iscroll 的滑动处理的还可以吧,虽然比不上系统的 scroll,但也不至于很难受,学会了这招,可以出去吹吹牛(招摇撞骗)了。

须知

通常如果我们给容器设置一个高度,如果子节点的高度超出了父容器的高度,那么内容就可以进行滚动,原因在于 overflow 属性默认为 auto,当然最好将父容器设置为 overflow: scroll,子内容就可以进行滚动。还可以单独设置 X 或 Y 轴的滚动,overflow-x:scrolloverflow-y:scroll,如果不想让内容滚动,则设置 overflow: hidden

是什么

iScroll 是一种高性能,占用空间小,无依赖的多平台 javascript 滚动器。

它适用于台式机,移动电视和智能电视。它已针对性能和尺寸进行了优化,以在现代和旧设备上提供最平滑的结果。

iScroll不仅可以滚动。它可以处理需要通过用户交互移动的任何元素。它为您的项目添加了滚动,缩放,平移,无限滚动,视差滚动,轮播,并且仅以4kb的速度做到了这一点。给它扫帚,它也会打扫你的办公室。

即使在本机滚动足以胜任的平台上,iScroll也会添加原本无法实现的功能。特别:

即使在动量期间,也可以对滚动位置进行精细控制。您始终可以获取并设置滚动条的x,y坐标。
可以使用用户定义的缓动功能(弹跳,弹性,后退…)自定义动画。
您可以轻松地挂钩到大量自定义事件(onBeforeScrollStart,onScrollStart,onScroll,onScrollEnd,flick等)。
开箱即用的多平台支持。从较旧的Android设备到最新的iPhone,从Chrome到Internet Explorer。

Get started

假设我们的元素长这个样子

1
2
3
4
5
6
7
<div id="wrapper">
<ul>
<li>...</li>
<li>...</li>
...
</ul>
</div>

那我们只需要在脚本中使用 new IScroll 即可

1
myScroll = new IScroll('#wrapper');

当然,他还有很多属性(只可意会)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
this.options = {
disablePointer : !utils.hasPointer,
disableTouch : utils.hasPointer || !utils.hasTouch,
disableMouse : utils.hasPointer || utils.hasTouch,
startX: 0, // 开始滚动的值
startY: 0,
scrollY: true,
directionLockThreshold: 5,
momentum: true, // 对其 Native 的 flinger(手指放开后还会继续滚动一段距离)
bounce: true, // 滚动回弹
bounceTime: 600,
bounceEasing: '',
preventDefault: true,
preventDefaultException: { tagName: /^(INPUT|TEXTAREA|BUTTON|SELECT)$/ },
HWCompositing: true,
useTransition: true,
useTransform: true,
bindToWrapper: typeof window.onmousedown === "undefined"
}

原理概括

iScroll 当然没有使用系统的 scroll,原因有很多点,上面也提到过。

iScroll 使用了 transform: translate(px, px)来实现滚动,完全是通过自己计算来实现的,当然,如果系统支持 transation,那么 iScroll 的松手后滚动将借助 transitionTimingFunction,否则就自定义动画,完成平滑滚动。

源码解析

入口

IScroll 是一个方法,调用了以后会创建属性,进行初始化,其中 this.xthis.y 分别记录了x 轴 与 y 轴的滚动距离。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function IScroll (el, options) {
this.wrapper = typeof el == 'string' ? document.querySelector(el) : el;
this.scroller = this.wrapper.children[0];
this.scrollerStyle = this.scroller.style; // cache style for better performance

// 此处省略了 this.options 的赋值

// Some defaults
this.x = 0;
this.y = 0;
this.directionX = 0;
this.directionY = 0;
this._events = {};

this._init();
this.refresh();

this.scrollTo(this.options.startX, this.options.startY);
this.enable();
}

初始化事件

this._init(); 中初始化了事件监听,在手机端主要是 touch 开头的事件,PC 端主要是 mouse 事件,pointer 是指针事件,此外,还会监听 transitionend 事件,用于滚动结束事件的监听。

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
_initEvents: function (remove) {
var eventType = remove ? utils.removeEvent : utils.addEvent,
target = this.options.bindToWrapper ? this.wrapper : window;

eventType(window, 'orientationchange', this);
eventType(window, 'resize', this);

if ( this.options.click ) {
eventType(this.wrapper, 'click', this, true);
}

if ( !this.options.disableMouse ) {
eventType(this.wrapper, 'mousedown', this);
eventType(target, 'mousemove', this);
eventType(target, 'mousecancel', this);
eventType(target, 'mouseup', this);
}

if ( utils.hasPointer && !this.options.disablePointer ) {
eventType(this.wrapper, utils.prefixPointerEvent('pointerdown'), this);
eventType(target, utils.prefixPointerEvent('pointermove'), this);
eventType(target, utils.prefixPointerEvent('pointercancel'), this);
eventType(target, utils.prefixPointerEvent('pointerup'), this);
}

if ( utils.hasTouch && !this.options.disableTouch ) {
eventType(this.wrapper, 'touchstart', this);
eventType(target, 'touchmove', this);
eventType(target, 'touchcancel', this);
eventType(target, 'touchend', this);
}

eventType(this.scroller, 'transitionend', this);
eventType(this.scroller, 'webkitTransitionEnd', this);
eventType(this.scroller, 'oTransitionEnd', this);
eventType(this.scroller, 'MSTransitionEnd', this);
},

初始化变量

计算容器高度、宽度,滚动内容高度、宽度,最大滚动距离(X轴,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
	refresh: function () {
utils.getRect(this.wrapper); // Force reflow

this.wrapperWidth = this.wrapper.clientWidth;
this.wrapperHeight = this.wrapper.clientHeight;

var rect = utils.getRect(this.scroller);
/* REPLACE START: refresh */

this.scrollerWidth = rect.width;
this.scrollerHeight = rect.height;

this.maxScrollX = this.wrapperWidth - this.scrollerWidth;
this.maxScrollY = this.wrapperHeight - this.scrollerHeight;

/* REPLACE END: refresh */

this.hasHorizontalScroll = this.options.scrollX && this.maxScrollX < 0;
this.hasVerticalScroll = this.options.scrollY && this.maxScrollY < 0;

if ( !this.hasHorizontalScroll ) {
this.maxScrollX = 0;
this.scrollerWidth = this.wrapperWidth;
}

if ( !this.hasVerticalScroll ) {
this.maxScrollY = 0;
this.scrollerHeight = this.wrapperHeight;
}

this.endTime = 0;
this.directionX = 0;
this.directionY = 0;

if(utils.hasPointer && !this.options.disablePointer) {
// The wrapper should have `touchAction` property for using pointerEvent.
this.wrapper.style[utils.style.touchAction] = utils.getTouchAction(this.options.eventPassthrough, true);

// case. not support 'pinch-zoom'
// https://github.com/cubiq/iscroll/issues/1118#issuecomment-270057583
if (!this.wrapper.style[utils.style.touchAction]) {
this.wrapper.style[utils.style.touchAction] = utils.getTouchAction(this.options.eventPassthrough, false);
}
}
this.wrapperOffset = utils.offset(this.wrapper);

this._execEvent('refresh');

this.resetPosition();

// INSERT POINT: _refresh

},

滚动函数

给定一个 x,y,滚动时间 time,滚动效果 easing;

如果 time 为 0,则为瞬时滚动,使用 _translate 改变位置,如果环境支持 transition , 那么将使用 transition 属性实现动画。

如果浏览器不支持 transition,time 又不为 0,那么则自定义动画实现滚动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scrollTo: function (x, y, time, easing) {
easing = easing || utils.ease.circular;

this.isInTransition = this.options.useTransition && time > 0;
var transitionType = this.options.useTransition && easing.style;
if ( !time || transitionType ) {
if(transitionType) {
this._transitionTimingFunction(easing.style);
this._transitionTime(time);
}
this._translate(x, y);
} else {
this._animate(x, y, time, easing.fn);
}
},

移动实现

如果元素支持 transform 给元素赋值 transform 属性即可,否则使用 left 属性。然后将 x , y 设置为目标位置,既目前滚动位置,通常都是为负值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
	_translate: function (x, y) {
if ( this.options.useTransform ) {

/* REPLACE START: _translate */

this.scrollerStyle[utils.style.transform] = 'translate(' + x + 'px,' + y + 'px)' + this.translateZ;

/* REPLACE END: _translate */

} else {
x = Math.round(x);
y = Math.round(y);
this.scrollerStyle.left = x + 'px';
this.scrollerStyle.top = y + 'px';
}

this.x = x;
this.y = y;

// INSERT POINT: _translate

},

触摸事件处理 Core

便于分析,我只考虑y方向的滑动,x方向同理(会删除掉 x 方向的代码)

start(movestart,mousedown…)

其中比较重要的是 startY,startTime,记录下目前滚动位置,滚动时刻 pointY 则是点击位置

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
_start: function (e) {
var point = e.touches ? e.touches[0] : e,
pos;

this.initiated = utils.eventType[e.type];
this.moved = false;
this.distY = 0;
this.directionY = 0;
this.directionLocked = 0;

this.startTime = utils.getTime();

if ( this.options.useTransition && this.isInTransition ) {
this._transitionTime();
this.isInTransition = false;
pos = this.getComputedPosition();
this._translate(Math.round(pos.x), Math.round(pos.y));
this._execEvent('scrollEnd');
} else if ( !this.options.useTransition && this.isAnimating ) {
this.isAnimating = false;
this._execEvent('scrollEnd');
}

this.startY = this.y;
this.absStartY = this.y;
this.pointY = point.pageY;

this._execEvent('beforeScrollStart');
},
move

通常,手指不松开,屏幕滚动是跟随手指移动的,手指怎么移动,屏幕就怎么移动。

首先会判断该次滑动是否有效,然后锁定滑动方向,最后计算 newY,需要滑动的位置 newY = this.y + deltaY 是通过 delta 来计算的。然后使用 translate 函数移动进行瞬时移动。

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
63
64
65
66
67
68
69
70
71
72
	_move: function (e) {
var point = e.touches ? e.touches[0] : e,
deltaY = point.pageY - this.pointY,
timestamp = utils.getTime(),
newY,
absDistY;

this.pointY = point.pageY;
this.distY += deltaY;
absDistY = Math.abs(this.distY);

// We need to move at least 10 pixels for the scrolling to initiate
if ( timestamp - this.endTime > 300 && (absDistX < 10 && absDistY < 10) ) {
return;
}

// If you are scrolling in one direction lock the other
if ( !this.directionLocked && !this.options.freeScroll ) {
if ( absDistX > absDistY + this.options.directionLockThreshold ) {
this.directionLocked = 'h'; // lock horizontally
} else if ( absDistY >= absDistX + this.options.directionLockThreshold ) {
this.directionLocked = 'v'; // lock vertically
} else {
this.directionLocked = 'n'; // no lock
}
}

if ( this.directionLocked == 'h' ) {
if ( this.options.eventPassthrough == 'vertical' ) {
e.preventDefault();
} else if ( this.options.eventPassthrough == 'horizontal' ) {
this.initiated = false;
return;
}

deltaY = 0;
} else if ( this.directionLocked == 'v' ) {
if ( this.options.eventPassthrough == 'horizontal' ) {
e.preventDefault();
} else if ( this.options.eventPassthrough == 'vertical' ) {
this.initiated = false;
return;
}

deltaX = 0;
}

deltaY = this.hasVerticalScroll ? deltaY : 0;
newY = this.y + deltaY;

// Slow down if outside of the boundaries
if ( newY > 0 || newY < this.maxScrollY ) {
newY = this.options.bounce ? this.y + deltaY / 3 : newY > 0 ? 0 : this.maxScrollY;
}
this.directionY = deltaY > 0 ? -1 : deltaY < 0 ? 1 : 0;
if ( !this.moved ) {
this._execEvent('scrollStart');
}
this.moved = true;
this._translate(newX, newY);

/* REPLACE START: _move */

if ( timestamp - this.startTime > 300 ) {
this.startTime = timestamp;
this.startX = this.x;
this.startY = this.y;
}

/* REPLACE END: _move */

},

其中一个比较重要的是,如果这次 move 的时刻与上一次(start)的时间超过 300 ms,会进行重置(太妙了!!)这与接下来的 end 有着非常重要的意义

1
2
3
4
if ( timestamp - this.startTime > 300 ) {
this.startTime = timestamp;
this.startY = this.y;
}
end

在 end 中将进行动量滚动(松开后还能进行滚动)

动量滚动最重要的是计算两个值,松开后滚动的 时间距离,也是 iscroll 最核心的部分。

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
	_end: function (e) {
var point = e.changedTouches ? e.changedTouches[0] : e,
momentumY,
duration = utils.getTime() - this.startTime,
newY = Math.round(this.y),
distanceY = Math.abs(newY - this.startY),
time = 0,
easing = '';

this.isInTransition = 0;
this.initiated = 0;
this.endTime = utils.getTime();

// reset if we are outside of the boundaries
if ( this.resetPosition(this.options.bounceTime) ) {
return;
}

this.scrollTo(newX, newY); // ensures that the last position is rounded

// we scrolled less than 10 pixels
if ( !this.moved ) {
if ( this.options.tap ) {
utils.tap(e, this.options.tap);
}

if ( this.options.click ) {
utils.click(e);
}

this._execEvent('scrollCancel');
return;
}

if ( this._events.flick && duration < 200 && distanceX < 100 && distanceY < 100 ) {
this._execEvent('flick');
return;
}

// start momentum animation if needed
if ( this.options.momentum && duration < 300 ) {
momentumY = this.hasVerticalScroll ? utils.momentum(this.y, this.startY, duration, this.maxScrollY, this.options.bounce ? this.wrapperHeight : 0, this.options.deceleration) : { destination: newY, duration: 0 };
newY = momentumY.destination;
time = Math.max(momentumX.duration, momentumY.duration);
this.isInTransition = 1;
}

// INSERT POINT: _end

if ( newX != this.x || newY != this.y ) {
// change easing function when scroller goes out of the boundaries
if ( newX > 0 || newX < this.maxScrollX || newY > 0 || newY < this.maxScrollY ) {
easing = utils.ease.quadratic;
}

this.scrollTo(newX, newY, time, easing);
return;
}

this._execEvent('scrollEnd');
},

计算使用了 util.momentum

输入,现在的滚动位置 y,上一次开始的 startY(该位置会在 move 中重置),time(距离上一次 start 的时间),lowerMargin 最大的滚动距离,wrapperSize 容器的尺寸,deceleration 插值器

话不多说,自己欣赏吧。返回滚动时间和目标滚动位置

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
momentum = function (current, start, time, lowerMargin, wrapperSize, deceleration) {
var distance = current - start,
speed = Math.abs(distance) / time,
destination,
duration;

deceleration = deceleration === undefined ? 0.0006 : deceleration;

destination = current + ( speed * speed ) / ( 2 * deceleration ) * ( distance < 0 ? -1 : 1 );
duration = speed / deceleration;

if ( destination < lowerMargin ) {
destination = wrapperSize ? lowerMargin - ( wrapperSize / 2.5 * ( speed / 8 ) ) : lowerMargin;
distance = Math.abs(destination - current);
duration = distance / speed;
} else if ( destination > 0 ) {
destination = wrapperSize ? wrapperSize / 2.5 * ( speed / 8 ) : 0;
distance = Math.abs(current) + destination;
duration = distance / speed;
}

return {
destination: Math.round(destination),
duration: duration
};
};

然后就是使用 scrollTo 函数进行动量滚动了。this.isInTransition 标志正在进行动量滚动,如果期间存在 touchdown 事件,则会立刻停止滚动。

1
2
3
4
5
6
7
8
9
10
11
if ( this.options.useTransition && this.isInTransition ) {
this._transitionTime();
this.isInTransition = false;
pos = this.getComputedPosition();
this._translate(Math.round(pos.x), Math.round(pos.y));
this._execEvent('scrollEnd');
} else if ( !this.options.useTransition && this.isAnimating ) {
this.isAnimating = false;
this._execEvent('scrollEnd');
}
}

至此,核心功能差不多了

但 iscroll 还有其他很多功能,比如

  • 轮播图
  • 放大缩小

最后

其实吧,iScroll 也也就那么回事。