H5实现一个移动端手势库系列——H5手势库的实现

还没有关注公众号的朋友,请扫描二维码,或者识别图片二维码,码字不易,关注一波哦~

公众号郑重承诺:只推送原创内容

image.png

移动端的多点触控对用户来说非常方便,用户能放大、缩小、拖拽、旋转等操作。

但是对于程序来说又极其复杂,程序怎么来判定用户的一个操作是放大而不是旋转呢,怎么判断一个操作是拖拽而不是长按呢?

任何便利的背后都有艰辛的付出。

这篇文章针对H5平台,接上两篇文章,这篇文章我们就来实现一个手势库。

上两篇文章:

《移动端的touch事件》https://www.lizhiqianduan.com/blog/index.php/2019/08/27/mobile-multi-touch/

《手势的判断条件》https://www.lizhiqianduan.com/blog/index.php/2019/08/26/condition-of-guesture/,看了这两篇文章的读者朋友或许已经自己写出了一个手势库了。

这里是我写的一个示例链接:http://www.lizhiqianduan.com/products/ycc/examples/multi-touch/,此链接需在移动设备上查看。

友情提示:此篇文章代码量巨大,不怎么关心实现过程的朋友,请直接下拉至文章底部查看总结。

新建Gesture类绑定一个HTML元素

看过之前博客的读者,都应该知道,手势其实就是通过某个HTML元素的touchstart、touchmove、touchend事件来模拟的。

而这些事件的回调大多都包含Touch对象,Touch对象有一个target属性,这个属性是用来表明当前触摸的HTML元素的。

所以,我们这个手势库需要一个HTML进行绑定,之后所有的手势都是在这个HTML元素上触发的。大致如下:

/**
 *
 * @param option
 * @param option.target 手势触发的HTML对象
 * @extends Ycc.Listener
 * @constructor
 */
Ycc.Gesture = function (option) {
   Ycc.Listener.call(this);
   
   this.option = option;      
};
Ycc.Gesture.prototype = new Ycc.Listener()

这个Ycc可以理解为一个命名空间,防止变量污染,这里我们的Gesture类继承了Listerner类。

这个Listener类主要功能是事件的监听和触发,便于我们的Gesture类监听和触发Gesture的自定义事件。

这样定义之后,我们监听手势触发就非常方便了。只需要如下即可:

var demo = new Ycc.Gesture({target:document.body});
demo.ontap = function (touch) {
   //todo ...
};
demo.ondoubletap = function (touch) {
   //todo ...
};
demo.onzoom = function (touch) {
   //todo ...
};
demo.onrotate = function (touch) {
   //todo ...
};

Gesture类初始化

有了我们的类之后,我们需要类的初始化函数,我设计的大致结构如下:

/**
 *  初始化函数
 * @private
 */
Ycc.Gesture.prototype._init = function () {
   var self = this;
   var tracer = new Ycc.TouchLifeTracer(
       {target:this.option.target}
   );
   tracer.onlifestart = function (life){
       // todo ...
   };
   tracer.onlifechange = function (life){
       // todo ...
   };
   tracer.onlifeend = function (life){
       // todo ...
   };
};

这里有个Ycc.TouchLifeTracer,它是一个触摸点生命周期的一个追踪模块。

它的主要功能是对接触HTML元素的每个触摸点,从开始接触到接触结束的跟踪。

它的实现,我们在前面的文章中也已经提到了,这里不清楚的读者请翻看一下本文开头的两篇文章。

接下来,我就来简单讲解各手势的实现。

tap手势的实现-单击

有了上面的这个追踪模块,我们的Gesture类的实现会容易得多。

只需要在各个生命周期内根据手势的判断条件触发事件即可。

tap手势的判断条件如下:

1、触摸过程中只有一个接触点
2、触摸时间小于某个阈值,一般是300ms
3、触摸过程中不能存在移动事件

那么对应在我们的追踪器tracer中实现即可,那么初始化函数_init里的内容大致如下:

Ycc.Gesture.prototype._init = function () {
	// Gesture引用
	var self = this;
	// 是否阻止事件触发
	var prevent = {
		tap:false
	};

	tracer.onlifestart = function (life) {
	  // 条件1:多个触摸点的情况,不触发tap事件
	  if(tracer.currentLifeList.length>1){
		  prevent.tap = true;
	  };
	};
	tracer.onlifechange = function (life) {
	  // 条件1:多个触摸点的情况,不触发tap事件
	  if(tracer.currentLifeList.length>1){
		  prevent.tap = true;
		};
	  
	  // 条件2:触摸过程中存在移动事件,且大于10px,则不触发tap
	  var firstMove = life.startTouchEvent;
		var lastMove = Array.prototype.slice.call(life.moveTouchEventList,-1)[0];
	  if(Math.abs(lastMove.pageX-firstMove.pageX)>10 || Math.abs(lastMove.pageY-firstMove.pageY)>10){
		prevent.tap=true;
	  }   
	   
	};
	tracer.onlifeend = function (life) {
	  // 条件1:接触结束,个数为0
		if(tracer.currentLifeList.length===0){
			// 条件3:tap的时间不能超过300ms
		   if(!prevent.tap && life.endTime-life.startTime<300){
			   // 触发tap
			   self.triggerListener('tap',life.endTouchEvent);
			}
		}
	};
};

doubletap手势的实现-双击

doubletap手势的判断条件如下:

1、存在两次tap事件
2、两次tap事件的x、y坐标必须在某个阈值内,一般是10px
3、两次tap事件的时间间隔必须在某个阈值内,一般是300ms

它是建立在tap之上的,只需要在触发tap的时候判断doubletap条件即可。

所以其初始化函数_init里的内容大致如下:

Ycc.Gesture.prototype._init = function () {
	// Gesture引用
	var self = this;
	// 是否阻止事件触发
	var prevent = {
	  tap:false
	};
	// 两次点击的生命周期
	var preLife,curLife;

	tracer.onlifestart = function (life) {
	  if(tracer.currentLifeList.length>1){
		prevent.tap = true;
	  };
	};
	tracer.onlifechange = function (life) {
	  if(tracer.currentLifeList.length>1){
		prevent.tap = true;
	  };
	  
	  var firstMove = life.startTouchEvent;
	  var lastMove = Array.prototype.slice.call(life.moveTouchEventList,-1)[0];
	  if(Math.abs(lastMove.pageX-firstMove.pageX)>10 || Math.abs(lastMove.pageY-firstMove.pageY)>10){
		prevent.tap=true;
	  }   
	   
	};
	tracer.onlifeend = function (life) {
		if(tracer.currentLifeList.length===0){
		   if(!prevent.tap && life.endTime-life.startTime<300){
			  // 触发tap
			  self.triggerListener('tap',life.endTouchEvent);

			  // 只需在这里进行处理
			  // 两次点击在300ms内,并且两次点击的范围在10px内,则认为是doubletap事件
						if(preLife 
							&& life.endTime-preLife.endTime<300 
							&& Math.abs(preLife.endTouchEvent.pageX-life.endTouchEvent.pageX)<10
							&& Math.abs(preLife.endTouchEvent.pageY-life.endTouchEvent.pageY)<10)
						{
						   // 触发doubletap
						   self.triggerListener('doubletap',life.endTouchEvent);
						   preLife = null;
						   return this;
						}
						preLife = life;      
				  }
		}
	};
};

longtap手势的实现-长按

longtap手势的判断条件如下:

1、触摸过程中只有一个接触点
2、触摸时间大于某个阈值,一般是750ms
3、触摸过程中不能存在移动事件(此条件为理想化条件,实际不可能实现)

它是建立在tap之上的,只需要在触发tap的时候判断750ms的触发条件即可,实现代码如下:

Ycc.Gesture.prototype._init = function () {
	var self = this;
	var tracer = new Ycc.TouchLifeTracer({target:this.option.target});
	// 上一次触摸、当前触摸
	var preLife,curLife;
	tracer.onlifestart = function (life) {
		//750ms后出发长按事件
		this._longTapTimeout = setTimeout(function () {
			self.triggerListener('longtap',self._createEventData(life.startTouchEvent,'longtap'));
		},750);
	};
	tracer.onlifechange = function (life) {
		// 只有一个触摸点的情况
		if(life.moveTouchEventList.length===1){
			var firstMove = life.startTouchEvent;
			var lastMove = Array.prototype.slice.call(life.moveTouchEventList,-1)[0];
			// 如果触摸点按下期间存在移动行为,且移动距离大于10,则认为该操作不是longtap
			if(Math.abs(lastMove.pageX-firstMove.pageX)>10 || Math.abs(lastMove.pageY-firstMove.pageY)>10){
				clearTimeout(this._longTapTimeout);
			}
		}
		
	};
	tracer.onlifeend = function (life) {};
	
};

swipe手势的实现-滑动

swipe手势的判断条件如下:

1、触摸过程中只有一个接触点
2、触摸时间小于某个阈值,一般是300ms
3、触摸过程中必须存在移动事件,且移动距离必须大于某个阈值,一般是30px

根据触发条件来看,其实现代码如下:

Ycc.Gesture.prototype._init = function () {
	var self = this;
	var tracer = new Ycc.TouchLifeTracer({target:this.option.target});
	// 上一次触摸、当前触摸
	var preLife,curLife;
	// 是否阻止事件
	var prevent = {
		swipe:false
	};
	tracer.onlifestart = function (life) {
		prevent.swipe = false;
	};
	tracer.onlifechange = function (life) {
		// 多个触摸点时不触发滑动事件
		if(tracer.currentLifeList.length>1){
			prevent.swipe=true;
			return this;
		}
	};
	tracer.onlifeend = function (life) {
		if(tracer.currentLifeList.length===0){
			// 如果触摸点按下期间存在移动行为,且移动范围大于30px,触摸时间在200ms内,则认为该操作是swipe
			if(!prevent.swipe && life.endTime-life.startTime<300 ){
				console.log('swipe');
				var firstMove = life.startTouchEvent;
				var lastMove = Array.prototype.slice.call(life.moveTouchEventList,-1)[0];
				if(Math.abs(lastMove.pageX-firstMove.pageX)>30 || Math.abs(lastMove.pageY-firstMove.pageY)>30){
					// _getSwipeDirection方法主要是判断swipe的方向,left、right、up、down
					var type = 'swipe'+self._getSwipeDirection(firstMove.pageX,firstMove.pageY,lastMove.pageX,lastMove.pageY);
					self.triggerListener(type,self._createEventData(life.endTouchEvent,type));
				}
				return this;
			}
		}
	};
	
};

旋转rotate和缩放zoom手势的实现

它们的判断条件一样,这里放在一起说

1、触摸过程中至少有两个接触点,实际中也是取最先接触的两个触摸点进行计算
2、触摸过程中存在移动

初始化函数_init里的内容大致如下:

Ycc.Gesture.prototype._init = function () {
	// Gesture引用
	var self = this;
	// 是否阻止事件触发
	var prevent = {
	  tap:false
	};
	// 两次点击的生命周期
	var preLife,curLife;

	tracer.onlifestart = function (life) {
		// 条件1:存在多个接触点
	  if(tracer.currentLifeList.length>1){
		prevent.tap = true;
		
		// 缩放、旋转只取最先接触的两个点
		preLife = tracer.currentLifeList[0];
		curLife = tracer.currentLifeList[1];
		return this;
	  };
	};
	tracer.onlifechange = function (life) {
		// 条件2:多个点存在移动
	  if(tracer.currentLifeList.length>1){
		prevent.tap = true;
		// 获取旋转角度和缩放比例
		var rateAndAngle = self.getZoomRateAndRotateAngle(preLife,curLife);

		// 触发zoom事件
		if(Ycc.utils.isNum(rateAndAngle.rate)){
		   self.triggerListener('zoom',rateAndAngle.rate);
		}
		// 触发rotate事件
		if(Ycc.utils.isNum(rateAndAngle.angle)){
		   self.triggerListener('rotate',rateAndAngle.angle);
		}
	  };   
	};
	tracer.onlifeend = function (life) {
		// 与lifeend无关
	};
};

上面代码中,最神奇的或许是getZoomRateAndRotateAngle函数了。

它主要功能是根据两个接触点的位置信息,获取旋转角度和缩放比例,它只不过是用到了一些数学上的方法。如下:

缩放比例=当前距离/初始距离

旋转角度=初始向量和当前向量的夹角

其实现代码如下:

Ycc.Gesture.prototype.getZoomRateAndRotateAngle = function (preLife, curLife) {
   
   // 初始坐标
   var x0=preLife.startTouchEvent.pageX,
      y0=preLife.startTouchEvent.pageY,
      x1=curLife.startTouchEvent.pageX,
      y1=curLife.startTouchEvent.pageY;
   
   var preMoveTouch = preLife.moveTouchEventList.length>0?preLife.moveTouchEventList[preLife.moveTouchEventList.length-1]:preLife.startTouchEvent;
   var curMoveTouch = curLife.moveTouchEventList.length>0?curLife.moveTouchEventList[curLife.moveTouchEventList.length-1]:curLife.startTouchEvent;

   // 当前坐标
   var x0move=preMoveTouch.pageX,
      y0move=preMoveTouch.pageY,
      x1move=curMoveTouch.pageX,
      y1move=curMoveTouch.pageY;
   
   // 初始向量
   var vector0 = new Ycc.Math.Vector(x1-x0,y1-y0),
   // 当前向量
      vector1 = new Ycc.Math.Vector(x1move-x0move,y1move-y0move);
   
   // 计算夹角
   var angle = Math.acos(vector1.dot(vector0)/(vector1.getLength()*vector0.getLength()))/Math.PI*180;
   
   return {
      // 计算缩放比例
      rate:vector1.getLength()/vector0.getLength(),

      // 向量叉乘,判断夹角正负号
      angle:angle*(vector1.cross(vector0).z>0?-1:1)
   };
};

当初没好好上学的朋友,是不是突然觉得数学还是有那么一点用了呢?

总结与扩展

文中提到的实现过程都是单一手势的实现,对于多种手势的组合,多个事件的触发,需要一个完整的判断条件。

比如:

触发swipe之前,是否还应该触发tap事件呢?

触发doubleTap之前,是否也应该触发tap呢?

因为只有三个基本事件,所有的手势都是基于三个基本事件,所有这些组合手势都是必须考虑的。

另外,还有很多手势是我们这个库里没有的,读者可以根据这个思路自行扩展。只要判断条件明确,按照文章这个思路还是很好实现的。

此处思考一个问题,文中介绍的都是移动端的手势库,手势库系列第一篇也提到了PC端与移动端其实也差不多。只不过PC端的鼠标只有一个,而移动端有多个接触点。

那么,能否将这两个端的手势进行合并呢?

PC端的鼠标能否当做移动的一个接触点呢?

也不卖关子了,Ycc引擎中内置的手势库已经将PC和H5两端的手势进行了合并,整个Ycc引擎都是开源的,喜欢的朋友可以前往查看。

https://github.com/lizhiqianduan/ycc

不要忘了给个star哦~ 在此谢过!

(本文完)



打赏作者

H5实现一个移动端手势库系列——H5手势库的实现》有1个想法

发表评论

电子邮件地址不会被公开。 必填项已用*标注