一步步實現網頁圖片的手勢拖拽與縮放
首先,需要了解 CSS3 的 transform
,用 transform
進行元素的變換,這是實現的關鍵。
transform
最常用的形式像這樣:
// 放大 2 倍 transform: scale(2); // 向左平移 100px transform: translate(100px); // rotate,skew,perspective 等其他變換 複製程式碼
實際上,上面的寫法可以算作 CSS 提供的語法糖。瞭解計算機圖形學的同學可能知道,計算機完成影象變換實際上使用的實現是矩陣。
如果使用以下 JavaScript 程式碼更改並查詢一個 div 元素的 CSS transform 屬性:
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 用到的引數即可。

但這絕對不是 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
屬性,以及 0
、 1
這樣的 key 值 ,可以通過 Array.prototype.slice
轉為陣列。也可以使用 touches['0']
這樣的語法直接從 touches 中取出觸控點物件。
需要了解
-
觸控事件傳入的引數是組合物件,因此如果使用了 React 框架,最好不要向非同步方法,如 setTimeout、Promise、async / await 區域中傳遞該引數。可以先使用變數獲取需要使用的值,再進行傳遞。如果一定要傳入,可以使用
e.persist()
將物件持久化。 -
區別於點選事件,無法從觸控事件中直接獲得 offsetX offsetY。因此需要自己計算這兩個值。
拖拽的實現
網上找 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
獲取的位置不正確,自己實現了一下 )
至於“所選的螢幕位置“,取雙指中點的位置。這裡選取 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) 時,情況如下:

可以看到,A' 點的座標變為了 (5, 3)。其實現在從數值上已經可以看出一點端倪了,但是讓我們來做一點抽象歸納。
首先,將基點 O 設為 。此時如果點 A 座標為 ,縮放倍率為 s 。用向量來表示點 A 到基點的距離就是:
那麼此時點 A' 到基點的距離正是 的 s 倍:
點 A' 的座標即 的值加上點 O 的座標:
如果我們移動基點 O,現在點 O 的座標變為了: 。我們沒有改變座標系參考點,點 A 的座標仍是 ,此時點 A 到基點 O 的距離為:
而新由點 A 變換得到的點 A'' 到基點 O 的距離( 的 s 倍)就變成了:
此時點的座標, 加上點 O 的座標:
也就是說,**將基點 O 從 移動到 ,記增量為 ,導致了點 A 的變換結果,從 ,變成了 **。計算一下 A 點因為元素基點而改變的值,也就是點 A'' 到點 A' 的距離:
可以帶入上面的真實座標值進行驗證,結果是符合預期的。
消除修改 origin 位置帶來的影響
這樣就解釋得通了,在 雙指落下的一瞬間,origin 座標變化了 ,而任一點 A 則變化了 。觀察發現,這個值與點 A 自身的座標 沒有任何關係,是 origin 移動距離決定了的一個“定值”;也就是說,元素上的所有的點,同時產生了這個座標位移效果。反映到介面上來,就是雙指接觸到元素的瞬間,元素立刻“瞬移”一下。而隨著手指不斷改變位置,origin 不斷被重設,於是造成了縮放元素完全不受控制的局面。
要消除修改 origin 帶來的負面影響,有 2 點需要做:
- 修改 origin 的同時修改位移,使得目標點的位移效果被抵消
- 減少 origin 的修改次數,可以減少不必要的計算量
進行修正
之前的計算中,我們得到了元素髮生了 的平移,於是只需要在修改 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。