likes
comments
collection
share

低代码 - 可视化拖拽技术分析(2)

作者站长头像
站长
· 阅读数 18

前言

上一讲 - 低代码 - 可视化拖拽技术分析(1) 中,我们实现了编辑器画布的拖拽移动基础操作,接先来从使用体验角度,完善一下画布编辑区的拖动,包含以下两个方向:

  • 为拖动的 block 显示辅助线;
  • 让拖动的 block 具备吸附功能;

描述

1、关于辅助线: 当画布中存在两个或多个 block,拖动其中一个 block 至另一个 block(参照物)周围时,能够显示出上下,或者居中的参考线,便于对两个 block 进行对齐排版。

2、关于吸附: 当拖动 block 的移动位置非常接近另一个 block 的位置时(比如两个 block 的相差距离 < 5px),能够直接让它们紧挨在一起(吸附),而非手动去拖动实现精准对齐。这是一个很好的体验!

思路

辅助线显示的规则是拿选中的 blocks其余未选中的 blocks,在拖动移动过程中进行位置比对

在拖动一个或多个 block(focus blocks)前,画布上其余的 block(unfocused blocks)需要提供自身周围的参考线(即辅助线,下文中的 lines);

每次拖动 focus blocks 时,会根据当前移动到的位置,与 unfocused blocks 提供的辅助线集合(lines)中的位置进行比较,若满足临近条件,显示其辅助线;

每一个 unfocused blocks 周围都可能存在10种辅助线,横向、纵向分别各5条。

下面绘制了两张图便于理解这里的思路:

  • 水平横向辅助线 5 种情况:

低代码 - 可视化拖拽技术分析(2)

  • 垂直纵向辅助线 5 种情况:

低代码 - 可视化拖拽技术分析(2)

下面我们从代码层面实现辅助线。

辅助线实现

渲染 DOM 元素

首先,我们需要两条线:水平位置线和垂直位置线,插入到 DOM 树上。

<div className="editor-container">
  <div
    ...
    id="canvas-container" style={{ ...schema.container }}>
    ...
    {markLine.x !== null && <div className="editor-line-x" style={{ left: markLine.x }}></div>}
    {markLine.y !== null && <div className="editor-line-y" style={{ top: markLine.y }}></div>}
  </div>
</div>

.editor-line-x{
  position: absolute;
  top: 0;
  bottom: 0;
  border-left: 1px dashed #1890ff;
}

.editor-line-y{
  position: absolute;
  left: 0;
  right: 0;
  border-top: 1px dashed #1890ff;
}

定义 Hooks

这里需要用到两个 Hook

  • currentBlockIndex:useRef,记录当前选中拖动的 block 索引;
  • markLine:useState,记录水平、垂直辅助线显示的位置,因为涉及到视图更新,这里使用 state 存储。
// 记录当前选中拖动的 block 索引
const currentBlockIndex = useRef(-1);
// 水平、垂直辅助线显示的位置
const [markLine, setMarkLine] = useState({ x: null, y: null });

currentBlockIndex 的记录时机,发生在选中 block 时,这样当在拖动移动时,可以从 schema.blocks 中拿到对应的 block

{schema.blocks.map((block, index) => (
  <Block key={index} block={block} onMouseDown={e => handleMouseDown(e, block, index)}></Block>
))}

const handleMouseDown = (e, block, index) => {
  ...
  currentBlockIndex.current = index;
  // 进行移动
  handleBlockMove(e);
}

markLine 的记录时机发生在拖动移动过程中,下文介绍。

记录 block 宽高

由于下面 收集 lines 时需要用到 block 尺寸信息,所以我们在 block 被渲染在画布上时,保存其 widthheight 信息。

// src/Block.js
useEffect(() => {
  const { offsetWidth, offsetHeight } = blockRef.current;
  const { style } = block;
  // block 初渲染至画布上时,记录一下尺寸大小,用于辅助线显示
  style.width = offsetWidth;
  style.height = offsetHeight;
  ...
}, [block]);

注意,有时候拖拽元素的宽高尺寸带有小数点,如果使用 offsetWidth 只能拿到向下取整的整数,因为这一点偏差,导致拖拽到画布上后,元素会出现换行现象。

针对这种情况,可以换成 getBoundingClientRect 获取元素的准确尺寸,保留两位小数或者是向上取整:

useEffect(() => {
  let { width, height } = blockRef.current.getBoundingClientRect();
  const offsetWidth = Math.ceil(width), offsetHeight = Math.ceil(height);
  const { style } = block;
  // block 初渲染至画布上时,记录一下尺寸大小,用于辅助线显示
  style.width = offsetWidth;
  style.height = offsetHeight;
  ...
}, [block]);

收集 lines

这里我们可以拟定两个“对象”(非 JS 对象,只是称呼):

  • B:代表了当前选中拖动的 block,即 currentBlockIndex block
  • A:代表了画布中剩余未选中的 blocks,每一个未选中的 block 都代表一个 A

那么,接下来 lines 的收集,就是将每一个 A 所在位置的 10种 辅助线进行存储。(这里可以结合代码与上文中的绘图,一起结合理解

const handleBlockMove = (e) => {
  const { focus, unfocused } = blocksFocusInfo();
  const lastSelectBlock = schema.blocks[currentBlockIndex.current];
  // 我们声明:B 代表最近一个选中拖拽的元素,A 则是对应的参照物,对比两者的位置
  const { width: BWidth, height: BHeight, left: BLeft, top: BTop } = lastSelectBlock.style;

  dragState.current = {
    // 用于实现 block 在画布上进行移动
    startX: e.clientX,
    startY: e.clientY,
    startPos: focus.map(({ top, left }) => ({ top, left })),
    
    // 用于实现 block 在画布上的辅助线
    startLeft: BLeft,
    startTop: BTop,
    
    // 找到其余 A block(unfocused)作为参照物时,参照物周围可能出现的 lines
    lines: (() => {
      const lines = { x: [], y: [] }; // 计算横线的位置使用 y 存放;纵线的位置使用 x 存放。
      
      unfocused.forEach(block => {
        const { top: ATop, left: ALeft, width: AWidth, height: AHeight } = block.style;
        // liney.showTop: (水平位置)辅助线显示位置;
        // liney.top: 拖拽元素 top 显示位置;
        // linex.showLeft: (垂直位置)辅助线显示位置;
        // linex.left: 拖拽元素 left 显示位置。

        // 水平横线显示的 5 种情况:
        lines.y.push({ showTop: ATop, top: ATop }); // 情况一:A和B 顶和顶对其。拖拽元素和A元素top一致时,显示这跟辅助线,辅助线的位置时 ATop
        lines.y.push({ showTop: ATop, top: ATop - BHeight }); // 情况二:A和B 顶对底
        lines.y.push({ showTop: ATop + AHeight / 2, top: ATop + AHeight / 2 - BHeight / 2 }); // 情况三:A和B 中对中
        lines.y.push({ showTop: ATop + AHeight, top: ATop + AHeight }); // 情况四:A和B 底对顶
        lines.y.push({ showTop: ATop + AHeight, top: ATop + AHeight - BHeight }); // 情况四:A和B 底对底

        // 垂直纵线显示的 5 种情况:
        lines.x.push({ showLeft: ALeft, left: ALeft }); // A和B 左对左
        lines.x.push({ showLeft: ALeft + AWidth, left: ALeft + AWidth }); // A和B 右对左
        lines.x.push({ showLeft: ALeft + AWidth / 2, left: ALeft + AWidth / 2 - BWidth / 2 }); // A和B 中对中
        lines.x.push({ showLeft: ALeft + AWidth, left: ALeft + AWidth - BWidth }); // A和B 右对右
        lines.x.push({ showLeft: ALeft, left: ALeft - BWidth }); // A和B 左对右
      });
      
      // 假如画布上 unfocused 有两个,那么 lines.x 和 linex.y 分别存储了 10 条线位置信息
      return lines;
    })()
  }

  ...
}

查找 line

有了 lines 集合(所有可能会被渲染的辅助线 list),就可以顺势而下根据当前移动的位置,去比对附近是否有 line 要被显示;

line 满足显示条件(比如:接近 5 像素距离时显示辅助线),调用 setMarkLine 去更新 markLine,将 DOM 上的辅助线渲染在视图上。

const handleBlockMove = (e) => {
  ...

  const blockMouseMove = (e) => {
    let { clientX: moveX, clientY: moveY } = e;

    // 计算鼠标拖动后,B block 最新的 left 和 top 值
    let left = moveX - dragState.current.startX + dragState.current.startLeft;
    let top = moveY - dragState.current.startY + dragState.current.startTop;
    let x = null, y = null;

    // 将当前 B block 移动的位置,和上面记录的 lines 进行一一比较,如果移动到的范围内有 A block 存在,显示对应的辅助线
    for (let i = 0; i < dragState.current.lines.x.length; i ++) {
      const { left: l, showLeft: s } = dragState.current.lines.x[i];
      if (Math.abs(l - left) < 5) { // 接近 5 像素距离时显示辅助线
        x = s;
        break;
      }
    }
    for (let i = 0; i < dragState.current.lines.y.length; i ++) {
      const { top: t, showTop: s } = dragState.current.lines.y[i];
      if (Math.abs(t - top) < 5) { // 接近 5 像素距离时显示辅助线
        y = s;
        break;
      }
    }

    setMarkLine({ x, y });

    const durX = moveX - dragState.current.startX;
    const durY = moveY - dragState.current.startY;

    focus.forEach((block, index) => {
      block.style.top = dragState.current.startPos[index].top + durY;
      block.style.left = dragState.current.startPos[index].left + durX;
    })
    
    forceUpdate();
  }

  const blockMouseUp = () => {
    ...
  }

  document.addEventListener('mousemove', blockMouseMove);
  document.addEventListener('mouseup', blockMouseUp);
}

此时,画布辅助线功能实现完成!

画布中心点的辅助线

如果想支持:拖拽一个物料组件(block)到画布的中央点,我们也可以将画布看作是 A block

lines: (() => {
  const lines = { x: [], y: [] };
  
  [...unfocused, {
    // 画布中心辅助线
    style: {
      top: 0,
      left: 0,
      width: schema.container.width,
      height: schema.container.height,
    }
  }].forEach(block => {
    ...
  });
  
  return lines;
})()

重置 Hooks

当然,别忘记了在特定时机对记录做初始化操作。

点击画布的空白处时,重置 currentBlockIndex

const handleClickCanvas = event => {
  event.stopPropagation();
  currentBlockIndex.current = -1;
  cleanBlocksFocus(true);
}

在拖动结束后,重置 markLine

const blockMouseUp = () => {
  document.removeEventListener('mousemove', blockMouseMove);
  document.removeEventListener('mouseup', blockMouseUp);
  setMarkLine({ x: null, y: null });
}

吸附实现

吸附的实现思路可以理解为:当移动 B 非常接近 A 时,立刻让 B 去贴近 A,从而达到吸附效果。

比如我们可以定义相差 <= 5px 时,让拖动元素和另一个参照元素贴紧。

有了上面的 辅助线实现,只需要添加两行代码,将以 B 为目标的鼠标移动位置(moveX、moveY),改成以 A 为参考目标。

const handleBlockMove = (e) => {
  const { width: BWidth, height: BHeight, left: BLeft, top: BTop } = lastSelectBlock.style;
  
  dragState.current = {
    // 用于实现 block 在画布上进行移动
    startX: e.clientX,
    startY: e.clientY,
    startPos: focus.map(({ style: { top, left } }) => ({ top, left })),

    // 用于实现 block 在画布上的辅助线
    startLeft: BLeft,
    startTop: BTop,
  }

  const blockMouseMove = (e) => {
    let { clientX: moveX, clientY: moveY } = e;
    
    for (let i = 0; i < dragState.current.lines.x.length; i ++) {
      const { left: l, showLeft: s } = dragState.current.lines.x[i];
      if (Math.abs(l - left) < 5) { // 接近 5 像素距离时显示辅助线
        x = s;
        // 实现吸附
        moveX = dragState.current.startX - dragState.current.startLeft + l;
        break;
      }
    }
    for (let i = 0; i < dragState.current.lines.y.length; i ++) {
      const { top: t, showTop: s } = dragState.current.lines.y[i];
      if (Math.abs(t - top) < 5) { // 接近 5 像素距离时显示辅助线
        y = s;
        // 实现吸附
        moveY = dragState.current.startY - dragState.current.startTop + t;
        break;
      }
    }
    
    ...
  }
}

最后

到这里,我们优化了编辑器拖拽移动体验:实现辅助线和吸附功能。下节我们实现撤销重做功能。

如有不足之处,欢迎指正 👏 。