前言
强大的 iScroll 啊,功能的确多,但我只想看看你的 flinger 滑动处理的精髓!感觉 iscroll 的滑动处理的还可以吧,虽然比不上系统的 scroll,但也不至于很难受,学会了这招,可以出去吹吹牛(招摇撞骗)了。
须知
通常如果我们给容器设置一个高度,如果子节点的高度超出了父容器的高度,那么内容就可以进行滚动,原因在于 overflow 属性默认为 auto,当然最好将父容器设置为 overflow: scroll,子内容就可以进行滚动。还可以单独设置 X 或 Y 轴的滚动,overflow-x:scroll 或 overflow-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.x 和 this.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 也也就那么回事。