Создайте мобильную библиотеку жестов шаг за шагом

JavaScript API

Мобильный уже предоставил намtouchstart,touchmove,touchcancelа такжеtouchendЧетыре родных сенсорных события. Но в целом эти события редко используются напрямую, такие как события длительного нажатия и т.п., все нужно реализовывать самому. Многие проекты с открытым исходным кодом также реализуют эти функции, такие какСенсорный модуль для zeptoтак же какhammer.js.本文将一步讲解常见移动端事件和手势的实现思路和实现方法,封装一个简单的移动端手势库。实现后的几个例子效果如下:

Пример списка чатов

Комплексный пример

Если вы хотите увидеть эффект масштабирования и вращения, вы можете щелкнуть ссылку выше или отсканировать QR-код с помощью мобильного телефона, чтобы просмотреть эффект.

Общие события и жесты

tap: событие щелчка, что-то вродеclickсобытие и роднойtouchstartсобытие или событие, которое срабатывает между этими двумя событиями во времени.

longtap: Событие длительного нажатия, срабатывающее после нажатия пальцем в течение определенного периода времени, обычное, например, длительное нажатие для сохранения изображения.

dbtap: Событие двойного щелчка, палец быстро щелкает дважды, общий метод, такой как двойной щелчок, уменьшает масштаб изображения.

move/drag: Жест смахивания/перетаскивания, означающий, что палец нажимается и перемещается без отрыва, аналогично родномуtouchmoveСобытия, такие как AssistiveTouch для мобильных iphone.

swipe(Right/Left/Up/Down): это также скользящий жест.В отличие от перемещения, событие запускается, когда палец поднимается после перемещения и достигает определенного расстояния перемещения. По разным направлениям его можно разделить наswipeLeft,swipeRight,swipeUpа такжеswipeDown.

pinch/zoom: сведение пальцев, жест масштабирования, относится к двум пальцам для выполнения жестов сжимания и масштабирования, обычно используемых для увеличения и уменьшения изображения.

rotate: Жест вращения, который относится к жесту вращения двумя пальцами, который обычно используется для операции вращения изображения.

нужно

Зная вышеперечисленные распространенные события и жесты, библиотека жестов, которую мы наконец реализовали, должна соответствовать следующим требованиям.

  • Реализовать все вышеперечисленные события и жесты
  • Сохраняйте обратные вызовы четырех основных событий, которые являются нативными.
  • Поддержка связанных вызовов
  • Поддержите несколько обратных вызовов обработки для того же события и жеста
  • Поддержка делегирования событий
  • Не зависит от сторонних библиотек

Идеи реализации и код

1. Базовая структура кода

Здесь указано название библиотекиGesture,существуетwindowsВыставленное имяGT. Ниже приведена базовая структура кода.

;(function(){
	function Gesture(target){
		//初始化代码
	}
    Gesture.prototype = {
        //实现各种手势的代码
    }
	Gesture.prototype.constructor = Gesture;
	if (typeof module !== 'undefined' && typeof exports === 'object') {
	    module.exports = Gesture;
	 } else if (typeof define === 'function' && define.amd) {
	    define(function() { return Gesture; });
	 } else {
	    window.GT = Gesture;
	 }
})()

в,targetЭто целевой элемент, привязанный при создании экземпляра, поддерживает передачу строк и элементов HTML.

2. Реализация конструктора

Вещи, с которыми должен иметь дело конструктор, включают: получение целевого элемента, инициализацию конфигурации и других параметров, которые необходимо использовать, и привязку основных событий, за исключением того, что вам нужно обратить внимание здесьthisВне точки объекта другие относительно просты.Основной код выглядит следующим образом:

  function Gesture(target) {
    this.target = target instanceof HTMLElement ? target : typeof target === "string" ? document.querySelector(target) : null; //获取目标元素
    if(!this.target) return ; //获取不到则不实例化
	//这里要实例化一些参数,后面需要用到哪些参数代码都往这里放
	//...

	//绑定基本事件,需要注意this的指向,事件的处理方法均在prototype实现
    this.target.addEventListener('touchstart',this._touch.bind(this),false);
    this.target.addEventListener('touchmove',this._move.bind(this),false);
    this.target.addEventListener('touchend',this._end.bind(this),false);
    this.target.addEventListener('touchcancel',this._cancel.bind(this),false);
  }

Следующий контент посвященprototypeреализации соответственно_touch,_move,_endа также_cancel.

3. События и жесты с одним пальцем

События и жесты одним пальцем включают:tap,dbtap,longtap,slide/move/dragа такжеswipe.

  • идеи

Когда палец начинает касаться, срабатывает роднойtouchstartсобытие, получить параметры, относящиеся к пальцу, исходя из требований, в это время должен быть выполнен нативtouchstartОбратный вызов, это первый шаг, затем должно произойти следующее:

(1) После того, как палец не уходит и не перемещается (или перемещается на очень небольшое расстояние) в течение определенного периода времени (здесь установлено значение 800 мс), он должен срабатывать.longtapмероприятие;

(2) Палец не отходит и совершает неправильные движения, в это время родной должен срабатывать первым.touchmoveОбратный вызов события, которое затем запускает пользовательское скользящее событие (названное здесьslide), в то же время следует отменитьlongtapзапуск событий;

(3) Палец отрывается от экрана и должен вызвать роднойtouchendОбратный вызов события, отмененный в то же времяlongtapСобытие срабатывает.После определенного периода времени (здесь установите 300 мс) расстояние до пальца изменяется за пределы определенного диапазона (здесь установите значение 30 пикселей), затем срабатываетswipeОбратный вызов для жеста, иначе он должен срабатывать, если палец снова не опущен.tapСобытие, если палец вниз и снял снова, он должен вызватьdbtapсобытие, которое должно быть отменено одновременноtapтриггер события

  • Код

Сначала добавьте в конструктор следующие параметры:


this.touch = {};//记录刚触摸的手指
this.movetouch = {};//记录移动过程中变化的手指参数
this.pretouch = {};//由于会涉及到双击,需要一个记录上一次触摸的对象
this.longTapTimeout = null;//用于触发长按的定时器
this.tapTimeout = null;//用于触发点击的定时器
this.doubleTap = false;//用于记录是否执行双击的定时器
this.handles = {};//用于存放回调函数的对象

Ниже приведен код и описание для достижения вышеуказанной идеи:


_touch: function(e){
      this.params.event = e;//记录触摸时的事件对象,params为回调时的传参
      this.e = e.target; //触摸的具体元素
      var point = e.touches ? e.touches[0] : e;//获得触摸参数
      var now = Date.now(); //当前的时间
	  //记录手指位置等参数
      this.touch.startX = point.pageX; 
      this.touch.startY = point.pageY;
      this.touch.startTime = now;
	  //由于会有多次触摸的情况,单击事件和双击针对单次触摸,故先清空定时器
      this.longTapTimeout && clearTimeout(this.longTapTimeout);
      this.tapTimeout && clearTimeout(this.tapTimeout);
	  this.doubleTap = false;
      this._emit('touch'); //执行原生的touchstart回调,_emit为执行的方法,后面定义
      if(e.touches.length > 1) {
        //这里为处理多个手指触摸的情况
      } else {
        var self= this;
        this.longTapTimeout = setTimeout(function(){//手指触摸后立即开启长按定时器,800ms后执行
          self._emit('longtap');//执行长按回调
          self.doubleTap = false;
          e.preventDefault();
        },800);
		//按照上面分析的思路计算当前是否处于双击状态,ABS为全局定义的变量 var ABS = Math.abs;
        this.doubleTap = this.pretouch.time && now - this.pretouch.time < 300 && ABS(this.touch.startX -this.pretouch.startX) < 30  && ABS(this.touch.startY - this.pretouch.startY) < 30 && ABS(this.touch.startTime - this.pretouch.time) < 300; 
        this.pretouch = {//更新上一个触摸的信息为当前,供下一次触摸使用
          startX : this.touch.startX,
          startY : this.touch.startY,
          time: this.touch.startTime
        };
      }
    },
    _move: function(e){
		var point = e.touches ? e.touches[0] :e;
	    this._emit('move');//原生的touchmove事件回调
	    if(e.touches.length > 1) {//multi touch
	       //多个手指触摸的情况
	    } else {
          var diffX = point.pageX - this.touch.startX,
              diffY = point.pageY - this.touch.startY;//与手指刚触摸时的相对坐标
			  this.params.diffY = diffY;
              this.params.diffX = diffX; 
          if(this.movetouch.x) {//记录移动过程中与上一次移动的相对坐标
            this.params.deltaX = point.pageX - this.movetouch.x;
            this.params.deltaY = point.pageY - this.movetouch.y;
          } else {
			this.params.deltaX = this.params.deltaY = 0;
          }
          if(ABS(diffX) > 30 || ABS(diffY) > 30) {//当手指划过的距离超过了30,所有单手指非滑动事件取消
            this.longTapTimeout &&  clearTimeout(this.longTapTimeout);
            this.tapTimeout && clearTimeout(this.tapTimeout);
  		    this.doubleTap = false;
          }
          this._emit('slide'); //执行自定义的move回调
         //更新移动中的手指参数
          this.movetouch.x = point.pageX;
          this.movetouch.y = point.pageY;
      }
    },
    _end: function(e) {
      this.longTapTimeout && clearTimeout(this.longTapTimeout); //手指离开了,就要取消长按事件
      var timestamp = Date.now();
      var deltaX = ~~((this.movetouch.x || 0)- this.touch.startX),
          deltaY = ~~((this.movetouch.y || 0) - this.touch.startY);
	  var direction = '';
      if(this.movetouch.x && (ABS(deltaX) > 30 || this.movetouch.y !== null && ABS(deltaY) > 30)) {//swipe手势
        if(ABS(deltaX) < ABS(deltaY)) {
          if(deltaY < 0){//上划
            this._emit('swipeUp')
            this.params.direction = 'up';
          } else { //下划
            this._emit('swipeDown');
            this.params.direction = 'down';
          }
        } else {
          if(deltaX < 0){ //左划
            this._emit('swipeLeft');
            this.params.direction = 'left';
          } else { // 右划
            this._emit('swipeRight');
            this.params.direction = 'right';
          }
        }
        this._emit('swipe'); //划
      } else {
        self = this;
        if(!this.doubleTap && timestamp - this.touch.startTime < 300) {//单次点击300ms内离开,触发点击事件
          this.tapTimeout = setTimeout(function(){
            self._emit('tap');
            self._emit('finish');//事件处理完的回调
          },300)
        } else if(this.doubleTap){//300ms内再次点击且离开,则触发双击事件,不触发单击事件
          this._emit('dbtap');
          this.tapTimeout && clearTimeout(this.tapTimeout);
          this._emit('finish');
        } else {
          this._emit('finish');
        }
      }
      this._emit('end'); //原生的touchend事件
    },

  • Привязка и выполнение событий

Определено выше в параметре конструктораhandles = {}Обработчик обратного вызова для хранения событий, определенных в прототипе_emitметод используется для выполнения обратного вызова. Поскольку функция обратного вызова передается при ее использовании, необходимо предоставитьonметод. Вот первоначальные требования:

  • Один и тот же жест и событие поддерживают передачу в несколько обработчиков.
  • Поддержка связанных вызовов

следовательно,onа также_emitОпределяется следующим образом:


 _emit: function(type){
      !this.handles[type] && (this.handles[type] = []);
      for(var i = 0,len = this.handles[type].length; i < len; i++) {
        typeof this.handles[type][i] === 'function' && this.handles[type][i](this.params);
      }
      return true;
    },
on: function(type,callback) {
  !this.handles[type] && (this.handles[type] = []);
  this.handles[type].push(callback);
  return this; //实现链式调用
},

Пока что, за исключением некоторых мелких деталей, базовая обработка однопальцевых событий завершена. Просто создайте его с помощью кода, подобного следующему:


new GT('#target').on('tap',function(){
  console.log('你进行了单击操作');
}).on('longtap',function(){
  console.log('长按操作');
}).on('tap',function(params){
  console.log('第二个tap处理');
  console.log(params);
})

4. Жесты несколькими пальцами

Обычные жесты несколькими пальцами — это жесты масштабирования.pinchи повернуть жестrotate.

  • идеи

При касании нескольких пальцев получить информацию, такую ​​как два пальца, рассчитать начальное расстояние и т. д., рассчитать новые параметры при перемещении и подъеме, вычислить многократное увеличение или уменьшение и угол поворота вращения спереди и сзади. задние параметры. Здесь задействовано больше математических знаний, можно искать конкретные математические знания,портал). В основном:

(1) Вычислить расстояние между двумя точками (модуль вектора)

(2) Вычислить угол между двумя векторами (внутреннее произведение векторов и их геометрические и алгебраические определения)

(3) Вычислить направление угла между двумя векторами (внешнее произведение векторов)

Определение геометрии:

Алгебраическое определение:

в

Замени там,

В двух измерениях,г₁а такжег₂равно 0, получить

  • Кодовая реализация нескольких алгоритмов

//向量的模
var calcLen = function(v) {
  //公式
  return  Math.sqrt(v.x * v.x + v.y * v.y);
}

//两个向量的角度(含方向)
var calcAngle = function(a,b){
  var l = calcLen(a) * calcLen(b),cosValue,angle;
  if(l) {
    cosValue = (a.x * b.x + a.y * b.y)/l;//得到两个向量的夹角的余弦值
    angle = Math.acos(Math.min(cosValue,1))//得到两个向量的夹角
    angle = a.x * b.y - b.x * a.y > 0 ? -angle : angle; //得到夹角的方向(顺时针逆时针)
    return angle * 180 / Math.PI;
  }
  return 0;
}

  • Код для реализации жестов несколькими пальцами
    _touch: function(e){
      //...
      if(e.touches.length > 1) {
        var point2 = e.touches[1];//获取第二个手指信息
        this.preVector = {x: point2.pageX - this.touch.startX,y: point2.pageY - this.touch.startY};//计算触摸时的向量坐标
        this.startDistance = calcLen(this.preVector);//计算向量的模
      } else {
        //...
      }
    },
    _move: function(e){
      var point = e.touches ? e.touches[0] :e;
      this._emit('move');
      if(e.touches.length > 1) {
        var point2 = e.touches[1];
        var v = {x:point2.pageX - point.pageX,y:point2.pageY - point.pageY};//得到滑动过程中当前的向量
        if(this.preVector.x !== null){
          if(this.startDistance) {
            this.params.zoom = calcLen(v) / this.startDistance;//利用前后的向量模比计算放大或缩小的倍数
            this._emit('pinch');//执行pinch手势
          }
          this.params.angle = calcAngle(v,this.preVector);//计算角度
          this._emit('rotate');//执行旋转手势
        }
		//更新最后上一个向量为当前向量
        this.preVector.x = v.x;
        this.preVector.y = v.y;
      } else {
        //...
      }
    },
    _end: function(e) {
      //...
      this.preVector = {x:0,y:0};//重置上一个向量的坐标
    }

После прояснения идеи реализация жеста касания несколькими пальцами относительно проста. На этом основные вещи всей библиотеки жестов в основном завершены. Согласно требованиям, оставшаяся точкаПоддержка делегирования событий, это в основном в_emitМетоды и конструкторы немного изменены.

//增加selector选择器
function Gesture(target,selector) {
  this.target = target instanceof HTMLElement ? target : typeof target === "string" ? document.querySelector(target) : null;
  if(!this.target) return ;
  this.selector = selector;//存储选择器
  //...
}
var isTarget = function (obj,selector){
  while (obj != undefined && obj != null && obj.tagName.toUpperCase() != 'BODY'){
    if (obj.matches(selector)){
      return true;
    }
    obj = obj.parentNode;
}
return false;
  }
Gesture.prototype. _emit =  function(type){
  !this.handles[type] && (this.handles[type] = []);
  //只有在触发事件的元素为目标元素时才执行
  if(isTarget(this.e,this.selector) || !this.selector) {
    for(var i = 0,len = this.handles[type].length; i < len; i++) {
      typeof this.handles[type][i] === 'function' && this.handles[type][i](this.params);
    }
  }
  return true;
}

5. Идеальные детали

  • touchcancelПерезвоните

оtouchcancel, текущий код выглядит следующим образом:


 _cancel: function(e){
  this._emit('cancel');
  this._end();
},

Я не очень уверен, неуместно ли выполнять обратный вызов end при отмене, или есть ли другие способы справиться с этим, надеюсь, знающие студенты дадут совет.

  • touchendсбросить после

При нормальных обстоятельствах,touchendПосле выполнения обратного вызова события следует сбросить различные параметры экземпляра, включая параметры, информацию о касании и т. д., и записать настройки параметров в функцию _init и заменить часть конструктора.this._init()

_init: function() {
  this.touch = {};
  this.movetouch = {}
  this.params = {zoom: 1,deltaX: 0,deltaY: 0,diffX: 0,diffY:0,angle: 0,direction: ''};
}
_end: function(e) {
 //...
 this._emit('end');
 this._init();
}
  • добавить другие события

В процессе поиска информации увидел еще одну библиотеку жестовAlloyFinger, производится Tencent. Библиотека других прошла большую практику, поэтому я проверил исходный код и сделал сравнение, и обнаружил, что идеи реализации схожи, но помимо поддержки жестов, реализованных в этой статье, она также предоставляет другие жесты. Основные сравнения заключаются в следующем:

  • Обратный вызов события может быть передан через параметры во время создания экземпляра или вы можете использоватьonМетод последующего связывания
  • Он обеспечивает соответствующий обратный вызов выгрузкиoffспособы и способы уничтожения объектовdestroy
  • Сцепленные вызовы не поддерживаются
  • Делегирование событий не поддерживается
  • Различные параметры изменения жестов расширены в родномeventНа объекте проходимость относительно высокая (но это вроде хорошо или плохо?)
  • Рассчитывается при движении пальцаdeltaXа такжеdeltaY, но безdiffXа такжеdiffY, может быть, эти два параметра не очень полезны на самом деле
  • события касания разбиты наtap,singletap,doubletaР иlongtap, он также срабатывает после длительного нажатияsingletapмероприятие,swipeБез подразделения, но предоставляет параметр направления
  • Собственные события добавляют обратные вызовы касания несколькими пальцамиtwoFingerPressMove,multipointStart,multipointEnd

После сравнения было решено увеличить обратный вызов события касания несколькими пальцами. соответственноmultitouch,multimove, и увеличитьoffа такжеdestroyМетод следующим образом:

_touch: function(e) {
	//...
  if(e.touches.length > 1) {
    var point2 = e.touches[1];
    this.preVector = {x: point2.pageX - this.touch.startX,y: point2.pageY - this.touch.startY}
    this.startDistance = calcLen(this.preVector);
    this._emit('multitouch');//增加此回调
  }
},
_move: function(e) {
  //...
  this._emit('move');
  if(e.touches.length > 1) {
    //...
    this._emit('multimove');//增加此回调
    if(this.preVector.x !== null){
      //...
    }
    //...
  }
}
off: function(type) {
   this.handles[type] = [];
},
destroy: function() {
  this.longTapTimeout && clearTimeout(this.longTapTimeout);
  this.tapTimeout && clearTimeout(this.tapTimeout);
  this.target.removeEventListener('touchstart',this._touch);
  this.target.removeEventListener('touchmove',this._move);
  this.target.removeEventListener('touchend',this._end);
  this.target.removeEventListener('touchcancel',this._cancel);
  this.params = this.handles = this.movetouch = this.pretouch = this.touch = this.longTapTimeout =  null;
  return false;
},

Примечание. Все события привязки должны быть уничтожены при уничтожении объекта.При использовании removeEventListener вам необходимо передать ссылку на исходную функцию привязки, а сам метод привязки вернет новую функцию, поэтому необходимо внести следующие изменения. в конструкторе:

  function Gesture(target,selector) {
    //...
    this._touch = this._touch.bind(this);
    this._move = this._move.bind(this);
    this._end = this._end.bind(this);
    this._cancel = this._cancel.bind(this);
    this.target.addEventListener('touchstart',this._touch,false);
    this.target.addEventListener('touchmove',this._move,false);
    this.target.addEventListener('touchend',this._end,false);
    this.target.addEventListener('touchcancel',this._cancel,false);
  }

  • Добавить конфигурацию

В реальном использовании могут быть особые требования к параметрам по умолчанию, например, событие, определяемое длительным нажатием, составляет 1000 мс вместо 800 мс, а расстояние для выполнения движения смахивания составляет 50 пикселей вместо 30. Таким образом, интерфейс настройки доступен для несколько специальных значений.Также поддерживает цепные вызовы. Соответствующее значение в логике изменяется на соответствующий параметр.


set: function(obj) {
  for(var i in obj) {
    if(i === 'distance') this.distance = ~~obj[i];
    if(i === 'longtapTime') this.longtapTime  = Math.max(500,~~obj[i]);
  }
  return this;
}

Инструкции:


new GT('#target').set({longtapTime: 700}).tap(function(){})

  • Решение конфликта

После проверки на конкретных примерах установлено, что процесс скольжения пальцев (в т.ч.move,slide,rotate,pinchи т. д.) будет конфликтовать с жестом прокрутки окна браузера.e.preventDefault()чтобы предотвратить поведение браузера по умолчанию. библиотека через_emitКогда метод выполняет обратный вызовparams.eventявляется собственным объектом события, но сparams.event.preventDefault()предотвратить поведение по умолчанию невозможно. Поэтому необходимо отрегулировать_emitМетод, позволяющий ему получать параметры множества собственных объектов событий, выполняется наибольший диапазон параметров обратного вызова, и при использовании выбирается некоторое поведение по умолчанию. После доработки выглядит следующим образом:

_emit: function(type,e){
  !this.handles[type] && (this.handles[type] = []);
  if(isTarget(this.e,this.selector) || !this.selector) {
    for(var i = 0,len = this.handles[type].length; i < len; i++) {
      typeof this.handles[type][i] === 'function' && this.handles[type][i](e,this.params);
    }
  }
  return true;
}

Вызов в отвечающей библиотеке необходимо изменить наthis._emit('longtap',e)форма.

После модификации его можно использоватьe.preventDefault()чтобы предотвратить поведение по умолчанию, например.


new GT(el)..on('slide',function(e,params){
  el.translateX += params.deltaX;
  el.translateY += params.deltaY;
  e.preventDefault()
})

6. Окончательные результаты

Окончательный эффект показан в начале статьи, вы можете щелкнуть следующую ссылку, чтобы просмотреть

Мобильный телефон Нажмите здесь, чтобы просмотреть подробные примеры

Мобильный Нажмите здесь, чтобы увидеть пример списка чатов

Чтобы просмотреть масштабирование и вращение, вы можете отсканировать QR-код с помощью своего мобильного телефона или щелкнуть ссылку с подробным примером, чтобы просмотреть эффект.

Весь исходный код и документацию по использованию библиотек вы можетенажмите здесь, чтобы посмотреть

Все идеи и коды по решению проблем предназначены для справки и обсуждения Добро пожаловать, чтобы указать на существующие проблемы и области, которые можно улучшить.

Кроме того, мои статьи о Nuggets будут синхронизированы с моим github, а контент будет постоянно обновляться. Если вы считаете, что это полезно для вас, спасибо за то, что поставили звезду. Если у вас есть какие-либо вопросы, добро пожаловать в общение. Ниже приведены несколько адресов синхронизированных статей.

1. Подробное объяснение некоторых свойств и приемов CSS.

2. Связанный с Javscript и некоторые идеи разработки инструментов/библиотек и интерпретация исходного кода