【iScroll源码学习02】分解iScroll三个核心事件点
前言
最近两天看到很多的总结性发言,我想想今年好像我的变化挺大的,是不是该晚上来水一发呢?嗯,决定了,晚上来水一发!
上周六,我们简单模拟了下iScroll的实现,周日我们开始了学习iScroll的源码,今天我们接着上次的记录学习,因为最近事情稍微有点多了
学习进度可能要放慢,而且iScroll这个库实际意义很大,不能囫囵吞枣的学习,要学到精华,并且要用于项目中的,所以初步规划是最近两周主要围绕iScroll展开
而后两个选择:① 分离iScroll代码用于项目;② 抄袭iScroll精华部分用于项目。无论如何都要用于项目......
几个事件点
iScroll的整体逻辑由三大事件点组成:
① touchStart(mousedown)
② touchMove(mousemove)
③ touchEnd(mouseUp)
也就是iScroll整体的功能逻辑其实是由这几个事件串起来的,其中
touchStart会保留一些初始化操作,或者停止正在进行的动画
touchMove会带动dom一起移动
而touchEnd最为复杂,在touchend阶段可能需要处理很多东西
① 一般性拖动结束事件
② 超出边界还原后触发的事件(此时可以滚动加载数据)
③ 如果此次为一次按钮点击,需要触发按钮事件那么还有对preventDefault进行处理(preventDefault可能导致事件不触发)
④ 如果此次为一次点击事件,并且对象为文本框或者select(其它会获得焦点的事件),那么应该让其获得焦点,并且弹出键盘
⑤ ......
以上为主观臆测下的猜想,我们来看看iScroll实际干了些什么,下面再细细的分析各个阶段
start
1 _start: function (e) { 2 // React to left mouse button only 3 if (utils.eventType[e.type] != 1) { 4 if (e.button !== 0) { 5 return; 6 } 7 } 8 if (!this.enabled || (this.initiated && utils.eventType[e.type] !== this.initiated)) { 9 return; 10 } 11 if (this.options.preventDefault && !utils.isBadAndroid && !utils.preventDefaultException(e.target, this.options.preventDefaultException)) { 12 e.preventDefault(); 13 } 14 var point = e.touches ? e.touches[0] : e, 15 pos; 16 17 this.initiated = utils.eventType[e.type]; 18 this.moved = false; 19 this.distX = 0; 20 this.distY = 0; 21 this.directionX = 0; 22 this.directionY = 0; 23 this.directionLocked = 0; 24 25 this._transitionTime(); 26 27 this.startTime = utils.getTime(); 28 29 if (this.options.useTransition && this.isInTransition) { 30 this.isInTransition = false; 31 pos = this.getComputedPosition(); 32 this._translate(Math.round(pos.x), Math.round(pos.y)); 33 this._execEvent('scrollEnd'); 34 } else if (!this.options.useTransition && this.isAnimating) { 35 this.isAnimating = false; 36 this._execEvent('scrollEnd'); 37 } 38 39 this.startX = this.x; 40 this.startY = this.y; 41 this.absStartX = this.x; 42 this.absStartY = this.y; 43 this.pointX = point.pageX; 44 this.pointY = point.pageY; 45 46 this._execEvent('beforeScrollStart'); 47 },
1 if ( utils.eventType[e.type] != 1 ) { 2 if ( e.button !== 0 ) { 3 return; 4 } 5 }
首先一段代码特别针对非touch事件进行了处理,其中的意图暂时不明,应该是只有点击鼠标左键的情况下才会触发下面逻辑
1 if ( !this.enabled || (this.initiated && utils.eventType[e.type] !== this.initiated) ) { 2 return; 3 }
第二段代码做了一次是否开始的验证,this.enabled应该是程序功能总开关,this.initiated为首次触发touchStart的事件类型
可能是mousedown,touchstart或者其他,如果两次不等的话,这里也会终止流程(此段代码确实不明意图)
// This should find all Android browsers lower than build 535.19 (both stock browser and webview) me.isBadAndroid = /Android/.test(window.navigator.appVersion) && !(/Chrome\/\d/.test(window.navigator.appVersion));
1 if ( this.options.preventDefault && !utils.isBadAndroid && !utils.preventDefaultException(e.target, this.options.preventDefaultException) ) { 2 e.preventDefault(); 3 }
第三段做了一些兼容性操作,各位可以看到只有这里有preventDefault的操作,如果默写dom元素在你手触碰会有默认的事件,比如文本框,便会触发
但是我们默认是阻止的,而在一些android设备上市必须阻止的,这里建议都阻止
1 var point = e.touches ? e.touches[0] : e, 2 pos; 3 4 this.initiated = utils.eventType[e.type]; 5 this.moved = false; 6 this.distX = 0; 7 this.distY = 0; 8 this.directionX = 0; 9 this.directionY = 0; 10 this.directionLocked = 0;
接下来进行了一些初始化属性的定义,其中比较重要的是
① point做了最简单的兼容性处理
② this.moved你想知道现在控件是不是在拖动就看他了
然后这里执行了一个方法:_transitionTime
1 _transitionTime: function (time) { 2 time = time || 0; 3 4 this.scrollerStyle[utils.style.transitionDuration] = time + 'ms'; 5 6 if (!time && utils.isBadAndroid) { 7 this.scrollerStyle[utils.style.transitionDuration] = '0.001s'; 8 } 9 10 if (this.indicators) { 11 for (var i = this.indicators.length; i--; ) { 12 this.indicators[i].transitionTime(time); 13 } 14 } 15 16 // INSERT POINT: _transitionTime 17 },
utils具有以下style属性:
1 me.extend(me.style = {}, { 2 transform: _transform, 3 transitionTimingFunction: _prefixStyle('transitionTimingFunction'), 4 transitionDuration: _prefixStyle('transitionDuration'), 5 transitionDelay: _prefixStyle('transitionDelay'), 6 transformOrigin: _prefixStyle('transformOrigin') 7 });
这里为其设置了适合放弃浏览器的运动时间属性,就是简单的兼容处理
在有些android浏览器上,这个使用是有问题的,所以就直接当没传时间,直接给了个0.001s
其中的indicators就是我们的滚动条,这里既然涉及到了,我们也暂时不管,因为涉及到滚动条的篇幅也不小,我们暂时不关注
这个方法也涉及滚动条相关,我们这里先简单提一下,后面在补充,现在继续看下面的逻辑
再下面就开始真正初始化信息,这些信息在以下会被用到
1 this.startTime = utils.getTime(); 2 3 if ( this.options.useTransition && this.isInTransition ) { 4 this.isInTransition = false; 5 pos = this.getComputedPosition(); 6 this._translate(Math.round(pos.x), Math.round(pos.y)); 7 this._execEvent('scrollEnd'); 8 } else if ( !this.options.useTransition && this.isAnimating ) { 9 this.isAnimating = false; 10 this._execEvent('scrollEnd'); 11 } 12 13 this.startX = this.x; 14 this.startY = this.y; 15 this.absStartX = this.x; 16 this.absStartY = this.y; 17 this.pointX = point.pageX; 18 this.pointY = point.pageY;
首先记录了手指触屏屏幕的时间,而后记录手指所处的位置,其中有两个if语句需要我们注意,这里的代码还是相当关键的
这段话的意义是告诉我们,如果我们当前正在运动,而此时触屏了,那么就触发scrollEnd事件停止动画(这里非常关键)
其中若是使用了CSS3的属性实现动画会做一些特别的处理,这里的this.isAnimating = false 是一个关键点,各位要注意
他在首次为undefined(我觉得这种属性应该给他一个初始化值false),运动过程中为true,运动结束为false
这里再提一下若是使用CSS3的话,会马上让dom移动到特定位置,然后停止动画
1 _translate: function (x, y) { 2 if ( this.options.useTransform ) { 3 this.scrollerStyle[utils.style.transform] = 'translate(' + x + 'px,' + y + 'px)' + this.translateZ; 4 5 } else { 6 x = Math.round(x); 7 y = Math.round(y); 8 this.scrollerStyle.left = x + 'px'; 9 this.scrollerStyle.top = y + 'px'; 10 } 11 this.x = x; 12 this.y = y; 13 if ( this.indicators ) { 14 for ( var i = this.indicators.length; i--; ) { 15 this.indicators[i].updatePosition(); 16 } 17 } 18 // INSERT POINT: _translate 19 },
下面的代码依旧在操作滚动条,不用现在关注,这里将信息全部写入了scrollerStyle对象,同事dom style属性的引用,这里就直接给赋值了
然我们来看看this._execEvent('scrollEnd');这段代码
首先_execEvent是用于触发存储在this._event数组中的事件的方法,然后我们只看scrollEnd即可,这里的事件机制,我们提到后面来说
很奇怪的是,这里有触发事件的代码却没有注册的代码,这是因为这个接口应该是开放给用户的,然后这里的beforeScrollStart也是开放给用户注册事件的
到此touchstart相关事件就结束了,我们接下来看move事件
move
1 _move: function (e) { 2 if (!this.enabled || utils.eventType[e.type] !== this.initiated) { 3 return; 4 } 5 6 if (this.options.preventDefault) { // increases performance on Android? TODO: check! 7 e.preventDefault(); 8 } 9 10 var point = e.touches ? e.touches[0] : e, 11 deltaX = point.pageX - this.pointX, 12 deltaY = point.pageY - this.pointY, 13 timestamp = utils.getTime(), 14 newX, newY, 15 absDistX, absDistY; 16 17 this.pointX = point.pageX; 18 this.pointY = point.pageY; 19 20 this.distX += deltaX; 21 this.distY += deltaY; 22 absDistX = Math.abs(this.distX); 23 absDistY = Math.abs(this.distY); 24 25 // We need to move at least 10 pixels for the scrolling to initiate 26 if (timestamp - this.endTime > 300 && (absDistX < 10 && absDistY < 10)) { 27 return; 28 } 29 30 // If you are scrolling in one direction lock the other 31 if (!this.directionLocked && !this.options.freeScroll) { 32 if (absDistX > absDistY + this.options.directionLockThreshold) { 33 this.directionLocked = 'h'; // lock horizontally 34 } else if (absDistY >= absDistX + this.options.directionLockThreshold) { 35 this.directionLocked = 'v'; // lock vertically 36 } else { 37 this.directionLocked = 'n'; // no lock 38 } 39 } 40 41 if (this.directionLocked == 'h') { 42 if (this.options.eventPassthrough == 'vertical') { 43 e.preventDefault(); 44 } else if (this.options.eventPassthrough == 'horizontal') { 45 this.initiated = false; 46 return; 47 } 48 49 deltaY = 0; 50 } else if (this.directionLocked == 'v') { 51 if (this.options.eventPassthrough == 'horizontal') { 52 e.preventDefault(); 53 } else if (this.options.eventPassthrough == 'vertical') { 54 this.initiated = false; 55 return; 56 } 57 58 deltaX = 0; 59 } 60 61 deltaX = this.hasHorizontalScroll ? deltaX : 0; 62 deltaY = this.hasVerticalScroll ? deltaY : 0; 63 64 newX = this.x + deltaX; 65 newY = this.y + deltaY; 66 67 // Slow down if outside of the boundaries 68 if (newX > 0 || newX < this.maxScrollX) { 69 newX = this.options.bounce ? this.x + deltaX / 3 : newX > 0 ? 0 : this.maxScrollX; 70 } 71 if (newY > 0 || newY < this.maxScrollY) { 72 newY = this.options.bounce ? this.y + deltaY / 3 : newY > 0 ? 0 : this.maxScrollY; 73 } 74 75 this.directionX = deltaX > 0 ? -1 : deltaX < 0 ? 1 : 0; 76 this.directionY = deltaY > 0 ? -1 : deltaY < 0 ? 1 : 0; 77 78 if (!this.moved) { 79 this._execEvent('scrollStart'); 80 } 81 82 this.moved = true; 83 84 this._translate(newX, newY); 85 86 /* REPLACE START: _move */ 87 88 if (timestamp - this.startTime > 300) { 89 this.startTime = timestamp; 90 this.startX = this.x; 91 this.startY = this.y; 92 } 93 94 /* REPLACE END: _move */ 95 96 },
move为功能第二个阶段,首先仍然是做全局开关检测,如果不通过就直接给干掉
1 if (!this.enabled || utils.eventType[e.type] !== this.initiated) { 2 return; 3 } 4 5 if (this.options.preventDefault) { // increases performance on Android? TODO: check! 6 e.preventDefault(); 7 }
然后这个时候必须要将浏览器默认事件搞掉,否则你滚动时候,如果body跟着一起滚动就麻烦了
PS:Android下有些浏览器preventDefault并不能阻止浏览器滚动,这事情很烦
接下来就是记录当前移动数据,为dom移动做准备了:
1 var point = e.touches ? e.touches[0] : e, 2 deltaX = point.pageX - this.pointX, 3 deltaY = point.pageY - this.pointY, 4 timestamp = utils.getTime(), 5 newX, newY, 6 absDistX, absDistY; 7 8 this.pointX = point.pageX; 9 this.pointY = point.pageY; 10 11 this.distX += deltaX; 12 this.distY += deltaY; 13 absDistX = Math.abs(this.distX); 14 absDistY = Math.abs(this.distY);
首先记录了当前鼠标位置,而后记录移动位置后,重置当前鼠标位置,然后这里做了一个判断,这个判断是如果我们手指一直停到一个位置不动的话,就给他终止了
这里也做了一个优化,为了防止浏览器不停的重绘吗,一定是移动10px以上才真正的移动
1 if (!this.directionLocked && !this.options.freeScroll) { 2 if (absDistX > absDistY + this.options.directionLockThreshold) { 3 this.directionLocked = 'h'; // lock horizontally 4 } else if (absDistY >= absDistX + this.options.directionLockThreshold) { 5 this.directionLocked = 'v'; // lock vertically 6 } else { 7 this.directionLocked = 'n'; // no lock 8 } 9 }
这里做了一个判断,让DOM朝一个方向运动即可,因为我们关注的是Y方向,这里可以暂时不予关注
然后开始计算新位置了,这里要开始移动了哦(注意:这里做了一个判断如果超出边界的话,拖动率要减低)
1 newX = this.x + deltaX; 2 newY = this.y + deltaY; 3 4 // Slow down if outside of the boundaries 5 if (newX > 0 || newX < this.maxScrollX) { 6 newX = this.options.bounce ? this.x + deltaX / 3 : newX > 0 ? 0 : this.maxScrollX; 7 } 8 if (newY > 0 || newY < this.maxScrollY) { 9 newY = this.options.bounce ? this.y + deltaY / 3 : newY > 0 ? 0 : this.maxScrollY; 10 }
我们在touchstart时候将this.moved设置为了false,这里就触发一个scrollSatrt事件后将其还原为true,所以这个事件只会触发一次,
这个事件同样是开放给用户的,iScroll本身并未注册任何事件
1 if (timestamp - this.startTime > 300) { 2 this.startTime = timestamp; 3 this.startX = this.x; 4 this.startY = this.y; 5 }
每300ms会重置一次当前位置以及开始时间,这个就是为什么我们在抓住不放很久突然丢开仍然有长距离移动的原因,这个比较精妙哦!
最后我们说下其中的_translate方法,这个方法用于移动DOM,这种封装的思想很不错的,值得借鉴,现在就进入关键点了touchend
end
1 _end: function (e) { 2 if (!this.enabled || utils.eventType[e.type] !== this.initiated) { 3 return; 4 } 5 6 if (this.options.preventDefault && !utils.preventDefaultException(e.target, this.options.preventDefaultException)) { 7 e.preventDefault(); 8 } 9 10 var point = e.changedTouches ? e.changedTouches[0] : e, 11 momentumX, 12 momentumY, 13 duration = utils.getTime() - this.startTime, 14 newX = Math.round(this.x), 15 newY = Math.round(this.y), 16 distanceX = Math.abs(newX - this.startX), 17 distanceY = Math.abs(newY - this.startY), 18 time = 0, 19 easing = ''; 20 21 this.isInTransition = 0; 22 this.initiated = 0; 23 this.endTime = utils.getTime(); 24 25 // reset if we are outside of the boundaries 26 if (this.resetPosition(this.options.bounceTime)) { 27 return; 28 } 29 30 this.scrollTo(newX, newY); // ensures that the last position is rounded 31 32 // we scrolled less than 10 pixels 33 if (!this.moved) { 34 if (this.options.tap) { 35 utils.tap(e, this.options.tap); 36 } 37 38 if (this.options.click) { 39 utils.click(e); 40 } 41 42 this._execEvent('scrollCancel'); 43 return; 44 } 45 46 if (this._events.flick && duration < 200 && distanceX < 100 && distanceY < 100) { 47 this._execEvent('flick'); 48 return; 49 } 50 51 // start momentum animation if needed 52 if (this.options.momentum && duration < 300) { 53 momentumX = this.hasHorizontalScroll ? utils.momentum(this.x, this.startX, duration, this.maxScrollX, this.options.bounce ? this.wrapperWidth : 0) : { destination: newX, duration: 0 }; 54 momentumY = this.hasVerticalScroll ? utils.momentum(this.y, this.startY, duration, this.maxScrollY, this.options.bounce ? this.wrapperHeight : 0) : { destination: newY, duration: 0 }; 55 newX = momentumX.destination; 56 newY = momentumY.destination; 57 time = Math.max(momentumX.duration, momentumY.duration); 58 this.isInTransition = 1; 59 } 60 61 62 if (this.options.snap) { 63 var snap = this._nearestSnap(newX, newY); 64 this.currentPage = snap; 65 time = this.options.snapSpeed || Math.max( 66 Math.max( 67 Math.min(Math.abs(newX - snap.x), 1000), 68 Math.min(Math.abs(newY - snap.y), 1000) 69 ), 300); 70 newX = snap.x; 71 newY = snap.y; 72 73 this.directionX = 0; 74 this.directionY = 0; 75 easing = this.options.bounceEasing; 76 } 77 78 // INSERT POINT: _end 79 80 if (newX != this.x || newY != this.y) { 81 // change easing function when scroller goes out of the boundaries 82 if (newX > 0 || newX < this.maxScrollX || newY > 0 || newY < this.maxScrollY) { 83 easing = utils.ease.quadratic; 84 } 85 86 this.scrollTo(newX, newY, time, easing); 87 return; 88 } 89 90 this._execEvent('scrollEnd'); 91 },
开始我们就说过,touchend为这个控件一个关键点与难点,现在我们就来啃一啃,这个看完了,iScroll核心部分也就结束了,后面就只需要拆解分析即可
首先仍然是一点初始化操作
1 if (!this.enabled || utils.eventType[e.type] !== this.initiated) { 2 return; 3 } 4 5 if (this.options.preventDefault && !utils.preventDefaultException(e.target, this.options.preventDefaultException)) { 6 e.preventDefault(); 7 }
而后逐步好戏上场了,在手指离开前做了状态保存
1 var point = e.changedTouches ? e.changedTouches[0] : e, 2 momentumX, 3 momentumY, 4 duration = utils.getTime() - this.startTime, 5 newX = Math.round(this.x), 6 newY = Math.round(this.y), 7 distanceX = Math.abs(newX - this.startX), 8 distanceY = Math.abs(newY - this.startY), 9 time = 0, 10 easing = ''; 11 12 this.isInTransition = 0; 13 this.initiated = 0; 14 this.endTime = utils.getTime();
duration是当前拖动的事件,这里可不是手指触屏到离开哦,因为move时候每300ms变了一次
PS:这里想象一下如果我们想要快速的滑动是不是触屏屏幕很快呢,而我们一直拖动DOM在最后也是有可能想让他快速移动的
记录当前x,y位置记录当前移动位置distanceY,然后重置结束时间,这里有一个resetPosition方法:
1 resetPosition: function (time) { 2 var x = this.x, 3 y = this.y; 4 5 time = time || 0; 6 7 if ( !this.hasHorizontalScroll || this.x > 0 ) { 8 x = 0; 9 } else if ( this.x < this.maxScrollX ) { 10 x = this.maxScrollX; 11 } 12 13 if ( !this.hasVerticalScroll || this.y > 0 ) { 14 y = 0; 15 } else if ( this.y < this.maxScrollY ) { 16 y = this.maxScrollY; 17 } 18 19 if ( x == this.x && y == this.y ) { 20 return false; 21 } 22 23 this.scrollTo(x, y, time, this.options.bounceEasing); 24 25 return true; 26 },
他是记录我们是不是已经离开了边界了,如果离开边界了就不会执行后面逻辑,而直接重置DOM位置,这里还用到了我们的scrollTo方法,该方法尤其关键
scrollTo
1 scrollTo: function (x, y, time, easing) { 2 easing = easing || utils.ease.circular; 3 4 this.isInTransition = this.options.useTransition && time > 0; 5 6 if ( !time || (this.options.useTransition && easing.style) ) { 7 this._transitionTimingFunction(easing.style); 8 this._transitionTime(time); 9 this._translate(x, y); 10 } else { 11 this._animate(x, y, time, easing.fn); 12 } 13 },
这个方法是此处一个重要的方法,传入距离与时间后,他就会高高兴兴的移动到对应位置
如果启用了CSS3的动画,便会使用CSS3动画方式进行动画(这个动画我们下期再说),否则使用_animate方法(js实现方案)
1 _animate: function (destX, destY, duration, easingFn) { 2 var that = this, 3 startX = this.x, 4 startY = this.y, 5 startTime = utils.getTime(), 6 destTime = startTime + duration; 7 8 function step () { 9 var now = utils.getTime(), 10 newX, newY, 11 easing; 12 13 if ( now >= destTime ) { 14 that.isAnimating = false; 15 that._translate(destX, destY); 16 17 if ( !that.resetPosition(that.options.bounceTime) ) { 18 that._execEvent('scrollEnd'); 19 } 20 21 return; 22 } 23 24 now = ( now - startTime ) / duration; 25 easing = easingFn(now); 26 newX = ( destX - startX ) * easing + startX; 27 newY = ( destY - startY ) * easing + startY; 28 that._translate(newX, newY); 29 30 if ( that.isAnimating ) { 31 rAF(step); 32 } 33 } 34 35 this.isAnimating = true; 36 step(); 37 },
这里用到了前文说描述的settimeout实现动画方案,这里有一点需要我们回到start部分重新思考,为什么CSS停止了动画?
原因是因为transitionend事件
transitionend 事件会在 CSS transition 结束后触发. 当transition完成前移除transition时,比如移除css的transition-property 属性,事件将不会被触发.
1 _transitionEnd: function (e) { 2 if ( e.target != this.scroller || !this.isInTransition ) { 3 return; 4 } 5 6 this._transitionTime(); 7 if ( !this.resetPosition(this.options.bounceTime) ) { 8 this.isInTransition = false; 9 this._execEvent('scrollEnd'); 10 } 11 },
所以,我们第二次touchstart时候,便高高兴兴停止了动画(之一_transitionTime未传time时候会重置时间),所以先取消动画再移动位置
于是继续回到我们的end事件,
1 this.scrollTo(newX, newY);
如果没有超出边界便滑动到应该去的位置(这里有动画哦)
点击情况
当然,我们手指可能当前只不过想点击而已,这个时候就要触发相关的点击事件了,如果需要获取焦点,便获取焦点
PS:他这里还模拟的fastclick想提升响应速度,但是他这样会引起大量BUG
1 if (!this.moved) { 2 if (this.options.tap) { 3 utils.tap(e, this.options.tap); 4 } 5 6 if (this.options.click) { 7 utils.click(e); 8 } 9 10 this._execEvent('scrollCancel'); 11 return; 12 } 13 14 if (this._events.flick && duration < 200 && distanceX < 100 && distanceY < 100) { 15 this._execEvent('flick'); 16 return; 17 }
运动参数
第一步的scrollTo其实可以放到move里面去,后面就用到了我们上文所说,根据动力加速度计算出来的动画参数:
1 if (this.options.momentum && duration < 300) { 2 momentumX = this.hasHorizontalScroll ? utils.momentum(this.x, this.startX, duration, this.maxScrollX, this.options.bounce ? this.wrapperWidth : 0) : { destination: newX, duration: 0 }; 3 momentumY = this.hasVerticalScroll ? utils.momentum(this.y, this.startY, duration, this.maxScrollY, this.options.bounce ? this.wrapperHeight : 0) : { destination: newY, duration: 0 }; 4 newX = momentumX.destination; 5 newY = momentumY.destination; 6 time = Math.max(momentumX.duration, momentumY.duration); 7 this.isInTransition = 1; 8 }
那个snap不必关注,直接看下面,在此使用
1 this.scrollTo(newX, newY, time, easing);
开始运动,最后触发scrollend事件,这里如果超出边界会执行resetPosition方法还原的,不必关心
由此,我们几大核心事件点便学习结束了,轻松愉快哈
结语
今天学习了iScroll的几个核心点,我们下次来说下他的滚动条以及事件机制相关,整个iScroll就七七八八了