畫布就是一切(二) — 實現元素拖拉拽

w4ngzhen發表於2021-11-28

在《畫布就是一切(一) — 基礎入門》中,我們介紹了利用畫布進行UI程式設計的基本模式以及利用基本模式,分析瞭如何實現滑鼠懸浮在元素上,元素變色的功能。在本文中,我們依然利用畫布程式設計的基本模式進行程式設計,但這一次我們將會提升一定的難度,實現元素拖拉拽的效果。

使用過流程圖或是圖形繪製軟體的同學都見到過這樣的場景對於矩形拖拉拽的場景:

010-rect-drag

本文將以上述的場景為需求,結合畫布程式設計的基本模式來複現一個類似的效果。本文的程式碼已經提交至GitHub倉庫,在倉庫根目錄/02_drag目錄中。

canvas-is-everything/02_drag at main · w4ngzhen/canvas-is-everything (github.com)

狀態

我們首先分析這個場景下的狀態有哪些。滑鼠在矩形元素上按下後,滑鼠可以拖動矩形元素,滑鼠鬆開後,矩形不再跟隨滑鼠移動。那麼對於UI來說,最基本的就是矩形的位置和大小,同時我們還需要一個狀態來表示矩形元素是否被選中:

  • 矩形位置position
  • 矩形大小size
  • 矩形是否被選中selected

輸入與更新

在這個場景中,更新點主要在於當滑鼠點選在元素上時,矩形selected會修改為true;當滑鼠移動的時候,只要有元素被選中且滑鼠的左鍵處於點選的狀態,那麼就會修改矩形元素的position。而造成更新的原因就是滑鼠的行為輸入(點選以及移動)。

渲染

實際上,在該場景下,渲染是最簡單的部分,根據上一篇文章的介紹,我們只需要canvas的context不斷的畫矩形即可。

流程梳理

讓我們再次對流程進行梳理。初始情況下,滑鼠在畫布上移動進而產生移動事件。我們引入一個輔助變數lastMousePosition(預設值為null),來表示上一次滑鼠移動事件的所在位置。在滑鼠移動事件觸發中,我們得到此刻滑鼠的位置,並與上一次滑鼠位置做向量差,進而得到位移差offset。對於offset我們將其應用在矩形的移動上。此外,當滑鼠按下的時候,我們判斷是否選中矩形,進而將矩形的selected置為true或false。當滑鼠抬起的時候,我們直接設定矩形selected為false即可。

基礎拖拽程式碼編寫與分析

1)工具方法

定義常用的工具方法:

  • 獲取滑鼠在canvas上的位置。

  • 檢查某個點是否位於某個矩形中。

// 1 定義常用工具方法
const utils = {

  /**
   * 工具方法:獲取滑鼠在畫布上的position
   */
  getMousePositionInCanvas: (event, canvasEle) => {
    // 移動事件物件,從中解構clientX和clientY
    let {clientX, clientY} = event;
    // 解構canvas的boundingClientRect中的left和top
    let {left, top} = canvasEle.getBoundingClientRect();
    // 計算得到滑鼠在canvas上的座標
    return {
      x: clientX - left,
      y: clientY - top
    };
  },

  /**
   * 工具方法:檢查點point是否在矩形內
   */
  isPointInRect: (rect, point) => {
    let {x: rectX, y: rectY, width, height} = rect;
    let {x: pX, y: pY} = point;
    return (rectX <= pX && pX <= rectX + width) && (rectY <= pY && pY <= rectY + height);
  },

};

2)狀態定義

// 2 定義狀態
let rect = {
  x: 10,
  y: 10,
  width: 80,
  height: 60,
  selected: false
};

根據前文,在矩形一般的屬性上位置和大小上,我們還新增了屬性selected,用於表示矩形是否被選中。

3)獲取Canvas元素物件

// 3 獲取canvas元素,準備在步驟
let canvasEle = document.querySelector('#myCanvas');

呼叫API,獲取Canvas元素物件,用於後續的事件監聽。

4)滑鼠按下事件

// 4 滑鼠按下事件
canvasEle.addEventListener('mousedown', event => {
  // 獲取滑鼠按下時位置
  let {x, y} = utils.getMousePositionInCanvas(event, canvasEle);
  // 矩形是否被選中取決於點選時候的滑鼠是否在矩形內部
  rect.selected = utils.isPointInRect(rect, {x, y});
});

獲取當前滑鼠按下的位置,並通過工具函式來判斷是否需要將矩形選中(selected置為true/false)。

5)滑鼠移動處理

// 5 滑鼠移動處理
// 5.1 定義輔助變數,記錄每一次移動的位置
let mousePosition = null;
canvasEle.addEventListener('mousemove', event => {

  // 5.2 記錄上一次的滑鼠位置
  let lastMousePosition = mousePosition;

  // 5.3 更新當前滑鼠位置
  mousePosition = utils.getMousePositionInCanvas(event, canvasEle);

  // 5.4 判斷是否滑鼠左鍵點選且有矩形被選中
  // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
  let buttons = event.buttons;
  if (!(buttons === 1 && rect.selected)) {
    // 不滿足則不處理
    return;
  }

  // 5.5 獲取滑鼠偏移
  let offset;
  if (lastMousePosition === null) {
    // 首次記錄,偏移dx和dy為0
    offset = {
      dx: 0,
      dy: 0
    };
  } else {
    // 曾經已經記錄了位置,則偏移則為當前位置和上一次位置做向量差
    offset = {
      dx: mousePosition.x - lastMousePosition.x,
      dy: mousePosition.y - lastMousePosition.y
    };
  }

  // 5.6 改動rect位置
  rect.x = rect.x + offset.dx;
  rect.y = rect.y + offset.dy;

});

這一部分的程式碼略長。但是邏輯並不難理解。

5.1 定義輔助變數mousePosition使用該變數記錄滑鼠在每一次移動過程中的位置。

5.2 記錄臨時變數lastMousePosition將上一次事件記錄的mousePosition賦給該變數,用於後續進行偏移offset計算。

5.3 更新mousePosition

5.4 判斷是否滑鼠左鍵點選且有矩形被選中。在滑鼠移動的過程中,我們是可以通過事件物件中的buttonbuttons屬性的數值來判斷當前滑鼠的點選情況(MDN)。當buttonsbutton為1的時候,表示移動的過程中滑鼠左鍵是按下的狀態。通過判斷滑鼠左鍵是否被按下來表示是否處於拖拽中,但是拖拽並不意味就選中了矩形在拖拽,還需要確定當前的矩形是否選中,所以需要(buttons === 1rect.selected === true)兩個條件共同決定。

5.5 獲取滑鼠偏移。這一部分需要解釋一下什麼是滑鼠偏移(offset)。在滑鼠移動的每時每刻都會有一個位置,我們利用mousePosition記錄了該位置。然後利用lastMousePositionmousePosition,我們將此刻的位置和上一次位置的x和y對應進行差(向量差),進而得到滑鼠一小段的偏移量。但需要注意的是,如果是首次的移動事件,那麼上一次的位置是lastMousePosition是null,那麼我們認為這個偏移0。

020-mouse-offset-desc

5.6 改動矩形位置。將滑鼠偏移值應用到矩形的位置上,讓矩形也位移對應的距離。

在滑鼠移動的處理中,我們完成了由滑鼠移動offset作為輸入,修改了被點中的矩形的位置。

6)滑鼠按鍵抬起事件

// 6 滑鼠抬起事件
canvasEle.addEventListener('mouseup', () => {
  // 滑鼠抬起時,矩形就未被選中了
  rect.selected = false;
});

滑鼠按鍵的抬起後,我們認為不再需要對矩形進行推拽,所以將矩形的selected置為false。

7)渲染處理

// 7 渲染
// 7.1 從Canvas元素上獲取context
let ctx = canvasEle.getContext('2d');
(function doRender() {
  requestAnimationFrame(() => {

    // 7.2 處理渲染
    (function render() {
      // 先清空畫布
      ctx.clearRect(0, 0, canvasEle.width, canvasEle.height);
      // 暫存當前ctx的狀態
      ctx.save();
      // 設定畫筆顏色:黑色
      ctx.strokeStyle = rect.selected ? '#F00' : '#000';
      // 矩形所在位置畫一個黑色框的矩形
      ctx.strokeRect(rect.x - 0.5, rect.y - 0.5, rect.width, rect.height);
      // 恢復ctx的狀態
      ctx.restore();
    })();

    // 7.3 遞迴呼叫
    doRender();

  });
})();

渲染部分的程式碼,總的來說就是三個要點:

  1. 獲取Canvas元素的context物件。
  2. 使用requestAnimationFrameAPI並構造遞迴結構來讓瀏覽器排程渲染流程。
  3. 在渲染流程編寫畫布操作的程式碼(清空、繪製)。

拖拽效果演示

至此,我們已經實現了元素拖動的樣例,效果如下:

030-drag-show-case

對於當前效果的完整程式碼在專案根目錄/02_drag目錄中,對應git提交為:02_drag: 01_基礎效果

效果提升

對於上述效果,其實還是不完美的。因為當滑鼠懸浮在矩形上的時候,並沒有任何UI上的資訊,點選的矩形進行拖拽的時候,滑鼠指標也是普通的。於是我們優化程式碼,將滑鼠懸浮的呈現的效果以及拖拽時候的滑鼠指標效果做出來。

我們設定,當滑鼠懸浮在矩形上的時候,矩形會改變對應的顏色為帶有50%透明的紅色(rgba(255, 0, 0, 0.5),並且滑鼠的指標修改為pointer。那麼首先需要給矩形加上我們在第一章中提到的屬性hover

let rect = {
  x: 10,
  y: 10,
  width: 80,
  height: 60,
  selected: false,
  // hover效果
  hover: false,
};

在渲染中,我們不再像上一節中進行簡單的處理,而是需要對selected、hover以及一般狀態都進行考慮。

    // 7.2 處理渲染
    (function render() {
        
	  // ...

      // 被點選選中:正紅色,指標為 'move'
      // 懸浮:帶50%透明的正紅色,指標為 'pointer'
      // 普通下為黑色,指標為 'default'
      if (rect.selected) {
        ctx.strokeStyle = '#FF0000';
        canvasEle.style.cursor = 'move';
      } else if (rect.hover) {
        ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
        canvasEle.style.cursor = 'pointer';
      } else {
        ctx.strokeStyle = '#000';
        canvasEle.style.cursor = 'default';
      }

	  // ...
        
    })();

接下來就是在滑鼠移動事件中,修改hover:

canvasEle.addEventListener('mousemove', event => {

  // 5.2 記錄上一次的滑鼠位置
  // ... ...

  // 5.3 更新當前滑鼠位置
  mousePosition = utils.getMousePositionInCanvas(event, canvasEle);

  // 5.3.1 判斷滑鼠是否懸浮在矩形
  rect.hover = utils.isPointInRect(rect, mousePosition);

  // 5.4 判斷是否滑鼠左鍵點選且有矩形被選中
  // ... ...

});

整體演示

至此,我們豐富了我們的拖拽樣例,結果如下:

040-drag-show-case-perfect

程式碼倉庫與說明

本文所在的程式碼倉庫地址為:

canvas-is-everything/02_drag at main · w4ngzhen/canvas-is-everything (github.com)

兩次提交:

  1. 02_drag: 01_基礎效果(優化前)
  2. 02_drag: 02_懸浮與點選效果提升(優化後)

相關文章