Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
aooy opened this issue Apr 4, 2017 · 7 comments
Open

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

aooy opened this issue Apr 4, 2017 · 7 comments

Comments

@aooy
Copy link
Owner

aooy commented Apr 4, 2017

作者:杨敬卓

转载请注明出处

目录

  • 前言
  • 基本原理
  • 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代码并不是唯一的,简单说只要实现下图的结构就可以。

从图中可以直观的看出,移动的是绿色的元素。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';

})));

状态容器

用两个对象来存储信息

  • 一个页面可以实例化很多滑动对象,store存储的是每个对象的信息,比如宽高,配置参数之类的。
  • state存储的是触摸之类的临时信息,每次触摸后都会清空。
var state = Object.create(null);

function iceSkating(){
  this.store = { 
     ...
  };
}

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, true);
    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);		
};

小结

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

@aooy aooy added the javascript label Apr 4, 2017
@xrr2016
Copy link

xrr2016 commented Apr 11, 2017

赞一个

@iterry
Copy link

iterry commented Apr 12, 2017

@louyahui
Copy link

赞一个

@julyL
Copy link

julyL commented May 10, 2017

有待改进的几点:

  1. preventClicks: option.preventClicks || true
    autoplayDelay: option.autoplayDelay || 3000,
    这种写法 ,你会发现如果设置的值 为0、false 等假值时,会出现问题
    正确写法: attr:option.attr===undefined?默认值:option.attr
  2. mainStore变量是多余的
  3. if(ic.store.preventClicks) ic.addEvent(container, 'click', ic.preventClicks, false); 这里应该设置为true,在事件捕获阶段就禁止事件冒泡的发生。 否则如果container的事件注册发生在iceSkating()之前,preventClicks会不起作用

代码整体不错,多多交流啦 :)

@aooy
Copy link
Owner Author

aooy commented May 11, 2017

@julyL 看得非常仔细,谢谢指正。对于第三点,false确实不妥,true应该说能禁止大部分情况(冒泡阶段的事件和部分捕获阶段的事件),除了在iceSkating()之前给容器注册的捕获事件。

@iyang519
Copy link

赞一个

@muyi0327
Copy link

你如果transition时间设置成0,岂不是动画瞬间执行完毕?会闪烁不顺畅的吧?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants