滑动效果的原理及实践一个滑动小插件

本文转载自blog

转载请注明出处

目录

  • 前言
  • 基本原理
  • html结构
  • 实践
  • 小结

前言

移动端,滑动是很常见的需求。很多同学都用过swiper.js,本文从原理出发,实践出一个类swiper的滑动小插件ice-skating

小插件的例子:

在写代码的过程中产生的一些思考:

  • 滑动的原理是什么
  • 怎么判断动画完成
  • 事件绑定到哪个元素,可否使用事件委托优化
  • pc端和移动端滑动有何不同
  • 正在进行的动画触摸时怎么取得当前样式
  • 如何实现轮播

基本原理

滑动就是用transform: translate(x,y)或者transform: translate3d(x,y,z)去控制元素的移动,在松手的时候判定元素最后的位置,元素的样式应用transform: translate3d(endx , endy, 0)transition-duration: time来达到一个动画恢复的效果。标准浏览器提供transitionend事件监听动画结束,在结束时将动画时间归零。

Note: 这里不讨论非标准浏览器的实现,对于不支持transformtransition的浏览器,可以使用position: absolute配合lefttop进行移动,然后用基于时间的动画的算法来模拟动画效果。

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-skatingexample文件中里查看完整的css。css代码并不是唯一的,简单说只要实现下图的结构就可以。

<div align=center >


</div>

从图中可以直观的看出,移动的是绿色的元素。className为ice-slide的元素的宽乘于当前索引(offsetWidth * index),就是每次稳定时的偏移量。例如最开始transform: translate3d(offsetWidth * 0, 0, 0),切换到slide2后,transform: translate3d(offsetWidth * 1, 0, 0),大致就是这样的过程。

实践

源码位于ice-skatingdist/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上的方法,因为我们不需要它们,例如toStringvalueOfhasOwnProperty之类的。

构造函数

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;
    }
};

touchStarttransitionDurationEndFn函数每个实例的容器都会绑定,但是所有实例共用touchMovetouchEnd函数,它们只绑定在document,并且只会绑定一次。使用事件委托有两个好处:

  1. 减少了元素绑定的事件数,提高了性能。
  2. 如果将touchMovetouchEnd也绑定在容器元素上,当鼠标移出容器元素时,我们会“失去控制”。在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动画的性能

    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)中setTimeoutsetInterval会放入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);     
};

小结

本文记录了我思考的过程,代码应该还有很多地方值得完善。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容