preloader
心得

Web Gesture: Zoom-Pan Transform Strategy | 網頁手勢縮放 Zoom-Pan Transform 策略學習筆記

Web Gesture: Zoom-Pan Transform Strategy | 網頁手勢縮放 Zoom-Pan Transform 策略學習筆記

為什麼 Transform 策略適合 img 等不可捲動元素

單張圖片沒有「溢出內容需要捲動」的需求。放大後只需要:

  1. 視覺上放大圖片
  2. 手指拖曳平移到圖片的不同區域

這正好是 CSS transform: scale3d() + translate3d() 擅長的事。


1. GPU 合成層與硬體加速

transform: scale3d(2, 2, 1) translate3d(100px, 50px, 0);

使用 scale3d / translate3d(而非 scale / translate)會觸發 GPU 合成層(compositing layer):

  • 瀏覽器將元素提升為獨立的 GPU 紋理(texture)
  • 縮放和平移只需移動/縮放紋理,不觸發重排(reflow)或重繪(repaint)
  • 動畫流暢度可達 60fps

相比之下,若改用 width/height 放大圖片,每一幀都要重新排版,效能極差。


2. will-change 預建合成層

will-change: transform;

在 directive 的 ngOnInit 中提前設定,告訴瀏覽器「這個元素即將被 transform」:

  • 瀏覽器預先建立 GPU 合成層

  • 避免首次 pinch 時才建立,導致卡頓(jank)

  • 注意:不要濫用,每個合成層都佔用 GPU 記憶體

  • MDN: will-change


3. 為什麼不需要原生捲動

圖片是一個固定尺寸的元素:

┌─────────────────┐
│                 │
│     <img>       │  ← 沒有子元素、沒有溢出內容
│                 │
└─────────────────┘
  • 放大前:圖片完整顯示在畫面中
  • 放大後:圖片的某個區域填滿畫面,其餘區域在畫面外

「畫面外的區域」不是 overflow(溢出),而是 transform 後的視覺位移。用 translate3d 平移就能看到任何區域,不需要 scrollbar。

對比長文件(如報表):

┌─────────────────┐
│  第 1 頁內容     │  ← 可見區域
├─────────────────┤
│  第 2 頁內容     │  ← overflow,需要捲動才能看到
├─────────────────┤
│  第 3 頁內容     │
└─────────────────┘

長文件放大後,需要捲動到所有頁面,這是 transform 策略做不到的(transform 只移動整個元素,不處理內部溢出)。


4. Touch Action 的角色

/* 未放大時 */
touch-action: manipulation;  /* 允許原生捲動和 pinch,禁用 double-tap-to-zoom */

/* 放大後 */
touch-action: none;          /* 全部手勢由 Hammer JS 處理 */

放大後設為 none 的原因:

  • 瀏覽器的原生 pan 會和 Hammer 的 translate3d 平移衝突
  • 兩套系統同時移動元素會導致畫面跳動
  • 因此放大後必須讓 Hammer 完全接管

未放大時設為 manipulation

  • 允許頁面正常捲動(如 照片燈箱元件 裡的 Swiper 左右滑動)

  • 禁用瀏覽器的 double-tap-to-zoom,避免和 tap 手勢衝突

  • MDN: touch-action


5. 平移邊界計算

max_pos_x = Math.ceil(((scale - 1) * elm.clientWidth) / 2);
max_pos_y = Math.ceil(((scale - 1) * elm.clientHeight) / 2);

為什麼是 (scale - 1) * size / 2

scale = 2, 元素寬度 = 400px

放大後視覺寬度 = 800px
超出畫面的部分 = 800 - 400 = 400px
因為 transform-origin 預設在中心,左右各超出 200px

最大平移量 = 400 × (2-1) / 2 = 200px

這確保平移不會超出圖片邊緣,看到空白區域。


6. requestAnimationFrame 批次更新

const scheduleUpdate = () => {
  if (!this._rafId) {
    this._rafId = requestAnimationFrame(applyTransform);
  }
};

手指在螢幕上移動時,Hammer 每秒可能觸發數十到上百次事件。如果每次都直接更新 DOM:

  • 瀏覽器每幀只渲染一次(60fps = 每 16.6ms 一次)
  • 多餘的 DOM 更新被浪費,還可能造成 layout thrashing

requestAnimationFrame(rAF)確保每幀只更新一次。


7. 為什麼不用 CSS zoom

CSS zoom 會改變 layout,圖片放大後會推開周圍元素(如 Swiper 的其他 slide),導致版面錯亂。transform 不影響 layout,圖片在文件流中的位置和大小不變,只是視覺上放大。


建議學習順序

  1. GPU 合成層 — 理解為什麼 transform 能做到流暢的 60fps 動畫
  2. will-change — 理解預建合成層的時機和代價
  3. touch-action — 理解瀏覽器手勢和 JS 手勢如何分工與切換
  4. requestAnimationFrame — 理解如何避免過度渲染
  5. transform-origin 與邊界計算 — 理解平移範圍的數學

延伸資源

主題 資源
瀏覽器渲染流程 Inside look at modern web browser (Part 3)
合成層原理 Compositing in Blink and WebKit
觸控事件全貌 Touch Events - W3C
HammerJS hammerjs.github.io
Chrome DevTools Layers Layers panel — 可視化合成層