一步步實現網頁圖片的手勢拖拽與縮放

最終效果以下:javascript

能夠點擊查看 在線演示,以及 完整代碼css

CSS transform

首先,須要瞭解 CSS3 的 transform ,用 transform 進行元素的變換,這是實現的關鍵。html

transform 最經常使用的形式像這樣:java

// 放大 2 倍
transform: scale(2);

// 向左平移 100px
transform: translate(100px);

// rotate,skew,perspective 等其餘變換
複製代碼

實際上,上面的寫法能夠算做 CSS 提供的語法糖。瞭解計算機圖形學的同窗可能知道,計算機完成圖像變換實際上使用的實現是矩陣。git

若是使用如下 JavaScript 代碼更改並查詢一個 div 元素的 CSS transform 屬性:github

document.querySelector('div').style.transform = 'scale(1)';
console.log(window.getComputedStyle(document.querySelector('div'), null).getPropertyValue('transform'));

// 輸出 "matrix(1, 0, 0, 1, 0, 0)"
複製代碼

能夠看到此時 transform 的值並非「scale(1)」,而是一個矩陣表示。此處 matrix 中的 6 個參數,對應了 2D 仿射變換矩陣中起做用的 6 個值(完整的是 3*3 矩陣,可是有 3 個參數是固定的)——不過這跟本文的實現沒有太大關係。爲了簡單起見,只需知道在 matrix 用到的參數便可。web

簡單的matrix參數解釋

但這絕對不是 matrix 完整的正確用法數組

若是想要多瞭解一些關於變換矩陣的知識,請搜索「仿射變換」。瀏覽器

知乎上有一個很好的入門回答:如何通俗地講解「仿射變換」這個概念? - 馬同窗的回答微信

若是不肯意寫矩陣形式,也能夠將其等價地寫成:

transform: translate(200px, 100px) scale(3);
複製代碼

注意,書寫順序決定了變換順序,不能夠將 scale 放置在 translate 以前Is a css transform matrix equivalent to a transform scale, skew, translate

Touch 事件

在進行實現以前,須要先了解一點觸摸事件的處理。詳見 觸摸事件

這裏簡單介紹一下相關的事件:

touchstart:觸摸事件開始,表示一個觸摸點開始接觸。能夠經過傳入對象獲取 touches,即一 個 TouchList 對象,裏面含有當前全部的接觸點,即 touch 對象。下面 2 個事件傳入參數相同。

touchmove:觸摸點移動。

touchend:觸摸事件結束,表示一個觸摸點離開。

TouchList:是一種「類數組」對象,也就是和函數中拿到的 arguments 類似,不是數組,可是含有 length 屬性,以及 01 這樣的 key 值 ,能夠經過 Array.prototype.slice 轉爲數組。也可使用 touches['0'] 這樣的語法直接從 touches 中取出觸摸點對象。

須要瞭解

拖拽的實現

網上找 DOM 元素拖拽,一般的作法是使用相對定位與 top、left 屬性。可是結合縮放事件,本文將使用 transform 進行實現。

不過不管具體實現方式如何,移動元素的思想都是一致的:先計算兩次 move 事件中的觸摸位移,而後將這段位移應用到目標上。

HTML 部分:

<head>
  <meta charset="UTF-8">
  <!--一些方便實現的聲明-->
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0" />
  <title>Touch</title>
  <style> html, body { margin: 0; padding: 0; height: 100%; width: 100%; // 禁用頁面拖動刷新 overscroll-behavior: contain; } .board { width: 100%; height: 100%; } .board img { width: 260px; } </style>
</head>

<body>
  <div class="board">
    <!--盜了少數派的圖-->
    <img src="https://cdn.sspai.com/article/86c69914-4545-bc1c-1310-2975d4fe8d6b.jpg?imageMogr2/quality/95/thumbnail/!700x233r/gravity/Center/crop/700x233" alt="">
  </div>
</body>
複製代碼

JavaScript 部分:

let img = document.querySelector('img');

// 查詢 DOM 對象的 CSS 值
const getStyle = (target, style) => {
  let styles = window.getComputedStyle(target, null);
  return styles.getPropertyValue(style);
};

// 獲取並解析元素當前的位移量
const getTranslate = (target) => {
  let matrix = getStyle(target, 'transform');
  let nums = matrix.substring(7, matrix.length - 1).split(', ');
  let left = parseInt(nums[4]) || 0;
  let top = parseInt(nums[5]) || 0;
  return { left: left, top: top };
};

// 記錄前一次觸摸點的位置
let preTouchPosition = {};
const recordPreTouchPosition = (touch) => {
  preTouchPosition = {
    x: touch.clientX,
    y: touch.clientY
  };
};

// 應用樣式變換
const setStyle = (key, value) => { img.style[key] = value; };

// 添加觸摸移動的響應事件
img.addEventListener('touchmove', e => {
  let touch = e.touches[0];
  let translated = getTranslate(touch.target);
  // 移動後的位置 = 當前位置 + (此刻觸摸點位置 - 上一次觸摸點位置)
  let translateX = translated.left + (touch.clientX - preTouchPosition.x);
  let translateY = translated.top + (touch.clientY - preTouchPosition.y);

  let matrix = `matrix(1, 0, 0, 1, ${translateX}, ${translateY})`;
  setStyle('transform', matrix);

  // 完成一次移動後,要及時更新前一次觸摸點的位置
  recordPreTouchPosition(touch);
});

// 開始觸摸時記錄觸摸點的位置
img.addEventListener('touchstart', e => { recordPreTouchPosition(e.touches['0']); });
複製代碼

縮放的實現

初步實現

要進行縮放,就要知道縮放的倍數。進行縮放是雙指的動做,有 2 個觸摸點,而將觸摸點之間的距離變化對應到縮放倍率的變化,就能夠實現雙指縮放的效果。要得知縮放的變化,思路跟移動一致,也是要記錄上次的觸摸點距離。而後就能夠計算如今的縮放倍率。

let scaleRatio = 1;
// 從變量名就知道它的用途與用法
let preTouchesClientx1y1x2y2 = [];
img.addEventListener('touchmove', e => {
  let touches = e.touches;
  if (touches.length > 1) {
    // 即使同時落下 10 個手指,咱們只取前 2 個就好
    let one = touches['0'];
    let two = touches['1'];
    const distance = (x1, y1, x2, y2) => {
      let a = x1 - x2;
      let b = y1 - y2;
      return Math.sqrt(a * a + b * b);
    };
    // 新的縮放倍率 = (當前指間距離 ÷ 以前指間距離)× 以前縮放倍率
    // 沒有在 touchstart 中記錄最初的雙指位置,計算會獲得 NaN,對結果直接取 1
    scaleRatio = distance(one.clientX, one.clientY, two.clientX, two.clientY) / distance(...preTouchesClientx1y1x2y2) * scaleRatio || 1;
    let matrix = `matrix(${scaleRatio}, 0, 0, ${scaleRatio}, ${translateX}, ${translateY})`;
    setStyle('transform', matrix);
    // 及時更新雙指位置信息
    preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
  }
});
img.addEventListener('touchstart', e => {
  let touches = e.touches;
  // 雙指同時落下也是有前後順序的,當發現多指觸摸時進行記錄
  if (touches.length > 1) {
    let one = touches['0'];
    let two = touches['1'];
    preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
  }
  recordPreTouchPosition(touches['0']);
});
複製代碼

如今已經實現了基本的縮放功能,可是好像哪裏不太對……爲何感受縮放效果不是從手指中傳出的呢?彷佛無論在哪裏操做,都是從圖片中心開始的。

transform-origin

簡單介紹一個 CSS 屬性:transform-origin,詳細介紹見 MDN

此屬性規定元素基點,也就是是應用變換的原點。

// 元素基點設置爲 (50px, 50px),是元素上的相對座標
transform-origin: 50px 50px;
複製代碼

當圖形變換隻有位移時,transform-origin 不會有什麼影響。可是對於旋轉和縮放屬性來講,元素基點是重要的屬性。

而默認的 transform-origin 值是 50% 50%,也就是元素正中心。這也就是爲何每次進行縮放操做,都感受縮放從圖片中心點傳來。

若是想要感覺縮放效果從手指開始,就要將 transform-origin 設置在雙指中間的位置;或者,經過位移的計算,模擬出 origin 的變化。本文采用前一種更直觀的思路。

獲取觸摸的 offset

實際上,無論元素被變換成了什麼形狀,設置 origin 時都是採用相對元素變換前的偏移量。以前提到過 touch 事件中並無觸摸點相對於元素的 offset 值,所以須要本身來計算。

// 計算相對縮放前的偏移量,rect 爲當前變換後元素的四周的位置
const relativeCoordinate = (x, y, rect) => {
  let cx = (x - rect.left) / scaleRatio;
  let cy = (y - rect.top) / scaleRatio;
  return {
    x: cx,
    y: cy
  };
};
複製代碼

其實就是 (所選的屏幕位置 - 元素的屏幕位置) / 縮放比例,並不困難。rect 能夠直接使用 getBoundingClientRect 函數得到。(以前誤覺得 getBoundingClientRect 獲取的位置不正確,本身實現了一下。思路是利用父定位元素累加 offset,喜歡挑戰的朋友務必本身嘗試一下,有意外驚喜哦)

至於「所選的屏幕位置「,取雙指中點的位置。這裏選取 clientX 和 clientY 值計算,即距離瀏覽器的偏移量。

// 記錄變換基點
let scaleOrigin = {};
img.addEventListener('touchmove', e => {
  let touches = e.touches;
  if (touches.length > 1) {
    let one = touches['0'];
    let two = touches['1'];
    const distance = (x1, y1, x2, y2) => {
      let a = x1 - x2;
      let b = y1 - y2;
      return Math.sqrt(a * a + b * b);
    };
    scaleRatio = distance(one.clientX, one.clientY, two.clientX, two.clientY) / distance(...preTouchesClientx1y1x2y2) * scaleRatio || 1;
    // 移動基點
    let origin = relativeCoordinate((one.clientX + two.clientX) / 2, (one.clientY + two.clientY) / 2, img.getBoundingClientRect());
    scaleOrigin = origin;
    setStyle('transform-origin', `${origin.x}px ${origin.y}px`);
      
    let matrix = `matrix(${scaleRatio}, 0, 0, ${scaleRatio}, ${translateX}, ${translateY})`;
    setStyle('transform', matrix);
    preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
  }
});
複製代碼

彷佛完成了?上手試一下。emmm……多操做一下就能發現,每次縮放離手後再次進行縮放,目標對象徹底不受控制,甚至會瞬移。

修改 transform-origin 帶來的問題

稍微思考,咱們就能發現問題所在(不存在的,我 debug 很久):對於已經應用過縮放(或旋轉)的元素,修改 origin 位置時,會產生位置的忽然變化。

具體是怎麼回事呢?其實這是一個高中數學就可以解釋的問題。

一點高中數學

元素基點位於原點

以上是元素基點位於原點的狀況。此時縮放倍率爲 2,縮放前的點 A 座標爲 (3, 2),變換後 A' 爲 (6, 4)。

那麼,若是 origin 不在原點呢?將 origin 移動到 (1, 1) 時,狀況以下:

元素基點位於 1, 1

能夠看到,A' 點的座標變爲了 (5, 3)。其實如今從數值上已經能夠看出一點端倪了,可是讓咱們來作一點抽象概括。

首先,將基點 O 設爲 O = (o_x, o_y)。此時若是點 A 座標爲 (x, y) ,縮放倍率爲 s。用向量來表示點 A 到基點的距離就是:

\overrightarrow{OA}=(x-o_x,y-o_y)

那麼此時點 A' 到基點的距離正是 \overrightarrow{OA}s 倍:

\overrightarrow{OA'}=(s\cdot(x-o_x),s\cdot(y-o_y))

點 A' 的座標即 \overrightarrow{OA'} 的值加上點 O 的座標:

A'=(s\cdot(x-o_x)+o_x,s\cdot(y-o_y)+o_y)

若是咱們移動基點 O,如今點 O 的座標變爲了:O=(o_x',o_y')。咱們並無改變座標系參考點,點 A 的座標還是 (x, y),此時點 A 到基點 O 的距離爲:

\overrightarrow{OA}=(x-o_x',y-o_y')

而新由點 A 變換獲得的點 A'' 到基點 O 的距離(\overrightarrow{OA}s 倍)就變成了:

\overrightarrow{OA''}=(s\cdot(x-o_x'),s\cdot(y-o_y'))

此時點 A'' 的座標是 \overrightarrow{OA''} 加上點 O 的座標:

A''=(s\cdot(x-o_x')+o_x',s\cdot(y-o_y')+o_y')

也就是說,將基點 O 從 (o_x, o_y) 移動到 (o_x', o_y'),記增量爲 (\Delta{o_x},\Delta{o_y}),致使了點 A 的變換結果,從 (s\cdot(x-o_x)+o_x,s\cdot(y-o_y)+o_y) ,變成了(s\cdot(x-o_x')+o_x',s\cdot(y-o_y')+o_y') 。計算 A 點縮放後的圖像由於元素基點移動而變化了的值,也就是點 A'' 到點 A' 的距離:

\begin{equation}
\begin{aligned}
\overrightarrow{A'A''}&=(s\cdot(x-o_x')+o_x',s\cdot(y-o_y')+o_y')-(s\cdot(x-o_x)+o_x,s\cdot(y-o_y)+o_y)\\&=(s\cdot(o_x-o_x')+o_x'-o_x,s\cdot(o_y-o_y')+o_y'-o_y)\\&=((1-s)\cdot\Delta{o_x},(1-s)\cdot\Delta{o_y})
\end{aligned}
\end{equation}

能夠帶入上面圖片中的真實座標值進行驗證,結果是符合預期的。

消除修改 origin 位置帶來的影響

這樣就解釋得通了,在雙指落下的一瞬間,origin 座標變化了 (\Delta{o_x},\Delta{o_y}),而取任變換結果上的一點 X ,變化了 ((1-s)\cdot\Delta{o_x},(1-s)\cdot\Delta{o_y})。觀察發現,這個值與點 X 自身的座標沒有任何關係,是 origin 移動距離決定的一個「定值」;也就是說,元素上的全部的點,同時產生了這端位移的變換效果。反映到界面上來,就是雙指接觸元素的瞬間,元素馬上「瞬移」一下。而隨着手指不斷改變位置,origin 不斷被重設,因而形成了縮放元素徹底不受控制的局面。

要消除修改 origin 帶來的負面影響,有 2 點須要作:

  • 修改 origin 的同時修改位移,使得目標點的位移效果被抵消
  • 減小 origin 的修改次數,能夠減小沒必要要的計算量

進行修正

以前的計算中,咱們獲得了元素髮生了 ((1-s)\cdot\Delta{o_x},(1-s)\cdot\Delta{o_y}) 的平移,因而只須要在修改 origin 位置的同時,將位移量提早減去這個值便可。另外,咱們將 origin 的修改頻率從每一個 touchmove 事件進行一次,減小到「完整的一次縮放交互」進行一次。

// 增長 originHaveSet 全局變量,每次設置 origin 位置後設爲 true
img.addEventListener('touchmove', e => {
  // ...
  if (!originHaveSet) {
    originHaveSet = true;
    // 移動視線中心
    let origin = relativeCoordinate((one.clientX + two.clientX) / 2, (one.clientY + two.clientY) / 2, 
  img.getBoundingClientRect());
    // 修正視野變化帶來的平移量,別忘了加上以前已有的位移值啊!
    translateX = (scaleRatio - 1) * (origin.x - scaleOrigin.x) + translateX;
    translateY = (scaleRatio - 1) * (origin.y - scaleOrigin.y) + translateY;
    setStyle('transform-origin', `${origin.x}px ${origin.y}px`);
    scaleOrigin = origin;
  }
  // ...
});
img.addEventListener('touchstart', e => {
  let touches = e.touches;
  if (touches.length > 1) {
    // ... 開始縮放事件時,將標誌置爲 false
    originHaveSet = false;
  } //...
});
複製代碼

這時再看一下效果,不由流下了感動的淚水。終於可以正常縮放了,這完美的跟手效果,這順滑的縮放體驗……

稍等,縮放後拿開手指,爲何圖片有時候仍是會跳動啊。

一點尾巴

仔細檢查一下代碼,定位發現是單手 touchmove 的問題:雙手縮放後移開,有時會觸發單手的一個 touchmove 邏輯;而以前拖拽實現時用到的上一次接觸點位置並無及時更新,致使了計算出的圖片移動距離與實際不符。

那麼在 touchend 與 touchcancel 中加入一個更新邏輯便可:

img.addEventListener('touchend', e => {
  let touches = e.touches;
  if (touches.length === 1) {
    recordPreTouchPosition(touches['0']);
  }
});
// touchcancel 一樣
複製代碼

完整的代碼能夠在個人 github 上得到:html-drag-scale-demo


仍是太年輕——關於瀏覽器拖拽的兼容處理的補充

通過評論區朋友提醒,我發現以前使用的 overscroll-behavior: contain; 屬性對於微信 X5 等有中國特點的瀏覽器(如夸克瀏覽器,nothing personal)沒有做用,仍然會出現下拉刷新等問題。

若是僅需補充對圖片拖拽時的瀏覽器固定,添加一句代碼足矣:

img.addEventListener('touchmove', e => {
  // ...
  e.preventDefault();
};
複製代碼

但若是還想要屏蔽瀏覽器自定義的下拉事件,那就要費一番力氣了。下面的代碼實現了對頁面所有 touchmove 事件的阻止,這其中也包括了長頁面的滾動事件。

// 檢查是否支持 options 寫法
let passiveSupport = false;
try {
  let option = Object.defineProperty({}, 'passive', {
    get: () => {
      passiveSupport = true;
    }
  });
  window.addEventListener('passivetest', null, option);
} catch (err) {}

document.body.addEventListener('touchmove', (e) => {
  e.preventDefault();
}, passiveSupport ? { passive: false } : false);
複製代碼

若是對於 passive 的寫法感到奇怪,請移步此文:關於passive event listener的一次踩坑;以及 addEventListener 的官方文檔:EventTarget.addEventListener(),還有谷歌對於 passive 合理性的解釋:Improving Scroll Performance with Passive Event Listeners

我嘗試了在 body 位於頁面頂部並繼續下拉的時刻調用 e.preventDefault(),這在直接向下拉動頁面時有效。但瀏覽器有一項規則:不容許打斷一次連續的 scroll。也就是說,若是先向上,再向下拉動頁面,頁面仍然有機會進行默認的下拉表現。

簡單來講,若是想要同時禁止下拉事件,並完美兼容 scroll 動做,現階段多是作不到的。(若是有高手作到了,務必請不吝賜教

另外,上面的代碼其實能夠用一句 CSS 代碼代替,若是你不在乎 Safari 全系列不兼容 的話:

html { touch-action: none; }
複製代碼
相關文章
相關標籤/搜索