移动端滑动 以你之姓@ 2022-05-30 08:45 284阅读 0赞 # 前言 # 移动端,滑动是很常见的需求。很多同学都用过[swiper.js][],本文从原理出发,实践出一个类swiper的滑动小插件[ice-skating][]。 小插件的例子: * [移动端][Link 1] * [pc端][pc] 在写代码的过程中产生的一些思考: * 滑动的原理是什么 * 怎么判断动画完成 * 事件绑定到哪个元素,可否使用事件委托优化 * pc端和移动端滑动有何不同 * 正在进行的动画触摸时怎么取得当前样式 * 如何实现轮播 # 基本原理 # 滑动就是用`transform: translate(x,y)`或者`transform: translate3d(x,y,z)`去控制元素的移动,在松手的时候判定元素最后的位置,元素的样式应用`transform: translate3d(endx , endy, 0)`和`transition-duration: time`来达到一个动画恢复的效果。标准浏览器提供`transitionend`事件监听动画结束,在结束时将动画时间归零。 Note: 这里不讨论非标准浏览器的实现,对于不支持`transform`和`transition`的浏览器,可以使用`position: absolute`配合`left`和`top`进行移动,然后用基于时间的动画的算法来模拟动画效果。 # html结构 # 举例一个基本的结构: //example <div class="ice-container"> <div class="ice-wrapper" id="myIceId"> <div class="ice-slide">Slide 1</div> <div class="ice-slide">Slide 2</div> <div class="ice-slide">Slide 3</div> </div> </div> `transform: translate3d(x,y,z)`就是应用在className为`ice-slide`的元素上。这里不展示css代码,可以在[ice-skating][]的`example`文件中里查看完整的css。css代码并不是唯一的,简单说只要实现下图的结构就可以。 ![861999-20170404155840738-1980032266.png][] 从图中可以直观的看出,移动的是绿色的元素。className为`ice-slide`的元素的宽乘于当前索引(offsetWidth \* index),就是每次稳定时的偏移量。例如最开始`transform: translate3d(offsetWidth * 0, 0, 0)`,切换到slide2后,`transform: translate3d(offsetWidth * 1, 0, 0)`,大致就是这样的过程。 # 实践 # 源码位于[ice-skating][]的`dist/iceSkating.js`。我给插件起名叫`ice-skating`,希望它像在冰面一样顺畅^\_^ ## 兼容各模块标准的容器 ## 以前我们会将代码包裹在一个简单的匿名函数里,现在需要加一些额外的代码来兼容各种模块标准。 (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (factory((global))); }(this, (function (exports) { 'use strict'; }))); ## 状态容器 ## 用两个对象来存储信息 * 一个页面可以实例化很多滑动对象,mainStore存储的是每个对象的信息,比如宽高,配置参数之类的。 * state存储的是触摸之类的临时信息,每次触摸后都会清空。 var mainStore = Object.create(null); var state = Object.create(null); `Object.create(null)`创建的对象不会带有`Object.prototype`上的方法,因为我们不需要它们,例如`toString`、`valueOf`、`hasOwnProperty`之类的。 ## 构造函数 ## function iceSkating(option){ if (!(this instanceof iceSkating)) return new iceSkating(option); } iceSkating.prototype = { } `if (!(this instanceof iceSkating)) return new iceSkating(option);`很多库和框架都有这句,简单说就是不用new生成也可以生成实例。 ## 触摸事件 ## 对于触摸事件,在移动端,我们会用`touchEvent`,在pc端,我们则用`mouseEvent`。所以我们需要检测支持什么事件。 iceSkating.prototype = { support: { touch: (function(){ return !!(('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch); })() } 支持touch则认为是移动端,否则为pc端 var events = ic.support.touch ? ['touchstart', 'touchmove', 'touchend']:['mousedown','mousemove','mouseup']; ### 声明事件函数 ### pc端和移动端这3个函数是通用的。 var touchStart = function(e){}; var touchMove = function(e){}; var touchEnd = function(e){}; ### 初始化事件 ### var ic = this; var initEvent = function(){ var events = ic.support.touch ? ['touchstart', 'touchmove', 'touchend']: ['mousedown','mousemove','mouseup']; var transitionEndEvents = ['webkitTransitionEnd', 'transitionend', 'oTransitionEnd', 'MSTransitionEnd', 'msTransitionEnd']; for (var i = 0; i < transitionEndEvents.length; i++) { ic.addEvent(container, transitionEndEvents[i], transitionDurationEndFn, false); } ic.addEvent(container, events[0], touchStart, false); //默认阻止容器元素的click事件 if(ic.store.preventClicks) ic.addEvent(container, 'click', ic.preventClicks, false); if(!isInit){ ic.addEvent(document, events[1], touchMove, false); ic.addEvent(document, events[2], touchEnd, false); isInit = true; } }; `touchStart`和`transitionDurationEndFn`函数每个实例的容器都会绑定,但是所有实例共用`touchMove`和`touchEnd`函数,它们只绑定在`document`,并且只会绑定一次。使用事件委托有两个好处: 1. 减少了元素绑定的事件数,提高了性能。 2. 如果将`touchMove`和`touchEnd`也绑定在容器元素上,当鼠标移出容器元素时,我们会“失去控制”。在`document`上意味着可以“掌控全局”。 ## 过程分析 ## 不会把封装的函数的代码都一一列出来,但会说明它的作用。 ### 触碰瞬间 ### #### touchStart函数: #### 会在触碰的第一时间调用,基本都在初始化state的信息 var touchStart = function(e){ //mouse事件会提供which值, e.which为3时表示按下鼠标右键,鼠标右键会触发mouseup,但右键不允许移动滑块 if (!ic.support.touch && 'which' in e && e.which === 3) return; //获取起始坐标。TouchEvent使用e.targetTouches[0].pageX,MouseEvent使用e.pageX。 state.startX = e.type === 'touchstart' ? e.targetTouches[0].pageX : e.pageX; state.startY = e.type === 'touchstart' ? e.targetTouches[0].pageY : e.pageY; //时间戳 state.startTime = e.timeStamp; //绑定事件的元素 state.currentTarget = e.currentTarget; state.id = e.currentTarget.id; //触发事件的元素 state.target = e.target; //获取当前滑块的参数信息 state.currStore = mainStore[e.currentTarget.id]; //state的touchStart 、touchMove、touchEnd代表是否进入该函数 state.touchEnd = state.touchMove = false; state.touchStart = true; //表示滑块移动的距离 state.diffX = state.diffY = 0; //动画运行时的坐标与动画运行前的坐标差值 state.animatingX = state.animatingY = 0; }; ### 移动 ### 在移动滑块时,可能滑块正在动画中,这是需要考虑一种特殊情况。滑块的移动应该依据现在的位置计算。 如何知道动画运行中的信息呢,可以使用`window.getComputedStyle(element, [pseudoElt])`,它返回的样式是一个实时的`CSSStyleDeclaration`对象。用它取`transform`的值会返回一个 2D 变换矩阵,像这样`matrix(1, 0, 0, 1, -414.001, 0)`,最后两位就是x,y值。 简单封装一下,就可以取得当前动画translate的x,y值了。 var getTranslate = function(el){ var curStyle = window.getComputedStyle(el); var curTransform = curStyle.transform || curStyle.webkitTransform; var x,y; x = y = 0; curTransform = curTransform.split(', '); if (curTransform.length === 6) { x = parseInt(curTransform[4], 10); y = parseInt(curTransform[5], 10); } return { 'x': x,'y': y}; }; #### touchMove函数: #### 移动时会持续调用,如果只是点击操作,不会触发touchMove。 var touchMove = function(e){ // 1. 如果当前触发touchMove的元素和触发touchStart的元素不一致,不允许滑动。 // 2. 执行touchMove时,需保证touchStart已执行,且touchEnd未执行。 if(e.target !== state.target || state.touchEnd || !state.touchStart) return; state.touchMove = true; //取得当前坐标 var currentX = e.type === 'touchmove' ? e.targetTouches[0].pageX : e.pageX; var currentY = e.type === 'touchmove' ? e.targetTouches[0].pageY : e.pageY; var currStore = state.currStore; //触摸时如果动画正在运行 if(currStore.animating){ // 取得当前元素translate的信息 var animationTranslate = getTranslate(state.currentTarget); //计算动画的偏移量,currStore.translateX和currStore.translateY表示的是滑块最近一次稳定时的translate值 state.animatingX = animationTranslate.x - currStore.translateX; state.animatingY = animationTranslate.y - currStore.translateY; currStore.animating = false; //移除动画时间 removeTransitionDuration(currStore.container); } //如果轮播进行中,将定时器清除 if(currStore.autoPlayID !== null){ clearTimeout(currStore.autoPlayID); currStore.autoPlayID = null; } //判断移动方向是水平还是垂直 if(currStore.direction === 'x'){ //currStore.touchRatio是移动系数 state.diffX = Math.round((currentX - state.startX) * currStore.touchRatio); //移动元素 translate(currStore.container, state.animatingX + state.diffX + state.currStore.translateX, 0, 0); }else{ state.diffY = Math.round((currentY - state.startY) * state.currStore.touchRatio); translate(currStore.container, 0, state.animatingY + state.diffY + state.currStore.translateY, 0); } }; #### translate函数: #### 如果支持translate3d,会优先使用它,translate3d会提供硬件加速。有兴趣可以看看这篇blog[两张图解释CSS动画的性能][CSS] var translate = function(ele, x, y, z){ if (ic.support.transforms3d){ transform(ele, 'translate3d(' + x + 'px, ' + y + 'px, ' + z + 'px)'); } else { transform(ele, 'translate(' + x + 'px, ' + y + 'px)'); } }; ### 触摸结束 ### #### touchEnd函数: #### 在触摸结束时调用。 var touchEnd = function(e){ state.touchEnd = true; if(!state.touchStart) return; var fastClick ; var currStore = state.currStore; //如果整个触摸过程时间小于fastClickTime,会认为此次操作是点击。但默认是屏蔽了容器的click事件的,所以提供一个clickCallback参数,会在点击操作时调用。 if(fastClick = (e.timeStamp - state.startTime) < currStore.fastClickTime && !state.touchMove && typeof currStore.clickCallback === 'function'){ currStore.clickCallback(); } if(!state.touchMove) return; //如果移动距离没达到切换页的临界值,则让它恢复到最近的一次稳定状态 if(fastClick || (Math.abs(state.diffX) < currStore.limitDisX && Math.abs(state.diffY) < currStore.limitDisY)){ //在transitionend事件绑定的函数中判定是否重启轮播,但是如果transform前后两次的值一样时,不会触发transitionend事件,所以在这里判定是否重启轮播 if(state.diffX === 0 && state.diffY === 0 && currStore.autoPlay) autoPlay(currStore); //恢复到最近的一次稳定状态 recover(currStore, currStore.translateX, currStore.translateY, 0); }else{ //位移满足切换 if(state.diffX > 0 || state.diffY > 0) { //切换到上一个滑块 moveTo(currStore, currStore.index - 1); }else{ //切换到下一个滑块 moveTo(currStore, currStore.index + 1); } } }; #### transitionDurationEndFn函数: #### 动画执行完成后调用 var transitionDurationEndFn = function(){ //将动画状态设置为false ic.store.animating = false; //执行自定义的iceEndCallBack函数 if(typeof ic.store.iceEndCallBack === 'function') ic.store.iceEndCallBack(); //将动画时间归零 transitionDuration(container, 0); //清空state if(ic.store.id === state.id) state = Object.create(null); }; 至此,一个完整的滑动过程结束。 ## 实现轮播 ## 第一时间想到的是使用`setInterval`或者递归`setTimeout`实现轮播,但这样做并不优雅。 事件循环(EventLoop)中`setTimeout`或`setInterval`会放入`macrotask` 队列中,里面的函数会放入`microtask`,当这个`macrotask` 执行结束后所有可用的 `microtask`将会在同一个事件循环中执行。 我们极端的假设`setInterval`设定为200ms,动画时间设为1000ms。每隔200ms, `macrotask` 队列中就会插入`setInterval`,但我们的动画此时没有完成,所以用`setInterval`或者递归`setTimeout`的轮播在这种情况下是有问题的。 最佳思路是在每次动画结束后再将轮播开启。 动画结束执行的函数: var transitionDurationEndFn = function(){ ... //检测是否开启轮播 if(ic.store.autoPlay) autoPlay(ic.store); }; 轮播函数也相当简单 var autoPlay = function(store){ store.autoPlayID = setTimeout(function(){ //当前滑块的索引 var index = store.index; ++index; //到最后一个了,重置为0 if(index === store.childLength){ index = 0; } //移动 moveTo(store, index); },store.autoplayDelay); [swiper.js]: https://github.com/nolimits4web/swiper/ [ice-skating]: https://github.com/aooy/ice-skating [Link 1]: https://aooy.github.io/iceSkating/example/mobile.html [pc]: https://aooy.github.io/iceSkating/example/pc.html [861999-20170404155840738-1980032266.png]: /images/20220530/ca07e456bb6e4886bc55eec9d564ac0f.png [CSS]: https://github.com/ccforward/cc/issues/42
还没有评论,来说两句吧...