首页 > 技术文章 > 画布就是一切(二) — 实现元素拖拉拽

w4ngzhen 2021-11-28 15:45 原文

在《画布就是一切(一) — 基础入门》中,我们介绍了利用画布进行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_悬浮与点击效果提升(优化后)

推荐阅读