拖拽排序列表

拖拽排序是一个很常见的功能,在浏览器不支持原生拖拽功能的时代,人们只能使用鼠标的点击事件来模拟拖拽效果,手动控制拖拽元素的位置来进行移动操作。随着原生拖拽 API 的支持,在实现这个功能的时候就可以更简单一点了。

# 相关事件

首先,你要想拖拽某个元素的话必须设置属性 draggable="true" 才可以进行拖拽操作。然后进行拖拽操作主要有以下事件需要处理。

  1. onDragStart 当拖拽开始的时候触发
  2. onDragOver 当拖拽到某个元素内部的时候触发
  3. onDragEnter 当拖拽进入某个元素的时候触发
  4. onDragEnd 当鼠标松开,拖拽结束的时候触发
  5. onDrop 当拖拽到某个元素并松开鼠标的时候触发

由于我们使用拖拽进行排序,而不是拖拽元素放到别的元素里面,所以我们并不需要进行 drop 处理,当 dragOver 某个元素的时候也不需要额外的处理。所以这两个事件的代码最简单,我们只需要移除默认的事件响应。

const dragOverHandler = useCallback((event: React.DragEvent<HTMLElement>) => {
  event.preventDefault();
  event.dataTransfer.dropEffect = 'move';
  return false;
}, []);
const dropHandler = useCallback((event: React.DragEvent<HTMLElement>) => {
  event.stopPropagation();
  event.preventDefault();
  return false;
}, []);
1
2
3
4
5
6
7
8
9
10

dropEffect 有四种,默认的是 copy 我们需要改成 move 才符合需求。接下来再来看看其他三个事件:

const dragStartHandler = useCallback(
  (event: React.DragEvent<HTMLElement>, index: number) => {
    event.dataTransfer.effectAllowed = 'move';
    // set dataTransfer enable mobile drag
    event.dataTransfer.setData('text/plain', index.toString());
    dragItem.current = index;
    copyData.current = deepClone(sortedData);
    recordRect();
    onDragStart?.();
  },
  [sortedData, recordRect, onDragStart]
);
const dragEnterHandler = useCallback(
  (index: number) => {
    if (dragItem.current !== index && dragOverItem.current !== index) {
      dragOverItem.current = index;
      const newData = deepClone(copyData.current);
      const dragData = newData[dragItem.current];
      newData.splice(dragItem.current, 1);
      newData.splice(dragOverItem.current, 0, dragData);
      setSortedData(newData);
      onDragEnter?.();
    }
  },
  [onDragEnter]
);
const dragEndHandler = useCallback(() => {
  updateData?.(sortedData);
  onDragEnd?.();
}, [sortedData, updateData, onDragEnd]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  1. dragStart 的时候记录下当前的 index,并复制一份数组数据。
  2. dragEnter 的时候记录下拖拽进入的元素 index,然后重新排序数组会渲染一个新的列表
  3. dragEnd 的时候同步数据到上层,展示排序后的结果

完整的代码请看文章最后的链接。

# FLIP 动画

当进行拖拽排序的时候,我们想要加上元素移动的动画,可以更明显的感受到顺序的变化。 我们使用 FLIP 技术来完成这个效果。

  1. First: 记录当前元素的位置
  2. Last: 记录更新后元素的位置
  3. Invert: 计算元素在 X 方向和 Y 方向的偏移,然后使用 transform 进行移动
  4. Play: 播放 transform 动画
useEffect(() => {
  if (draggable && containerRef?.current) {
    Array.from(containerRef.current.querySelectorAll('[data-id]')).forEach(
      async node => {
        const dom = node as HTMLElement;
        const key = dom.dataset.id as string;
        const prevRect = prevRects.current[key];
        if (key) {
          const rect = dom.getBoundingClientRect();
          if (prevRect) {
            const dy = prevRect.y - rect.y;
            const dx = prevRect.x - rect.x;
            dom.style.pointerEvents = 'none';
            dom.animate(
              [
                {
                  transform: `translate(${dx}px, ${dy}px)`
                },
                { transform: 'translate(0, 0)' }
              ],
              {
                duration: TIMEOUT,
                easing: 'linear'
              }
            );
            await Promise.allSettled(
              node.getAnimations().map(animation => animation.finished)
            );
            dom.style.pointerEvents = '';
          }
          prevRects.current[key] = rect;
        }
      }
    );
  }
}, [draggable, sortedData, containerRef]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

上面是示例代码,注意这里的 FLIP 动画加了一些特殊处理,我们使用 pointerEvents 禁用动画过程中的事件响应,因为动画过程中触发任何 drag 事件会导致 index 顺序被改动,然后无限进行排序操作。 这不是我们期望的结果,然后在动画结束的时候再取消这个 CSS 属性。

# 总结

使用原生的拖拽 API 来实现排序功能更简单和易用,但目前手机浏览器还不支持这个功能。排序中的过渡动画是一个难点,特别是动画过程中移除事件的响应。如果可以使用 JS 的方式来临时禁用所有事件的响应,应该可以处理的更优雅。

# 参考

HTML Drag and Drop API (opens new window)

simple-drag-drop-sort-list (opens new window)

FLIP Your Animations (opens new window)

Using the HTML5 Drag and Drop API (opens new window)

如何实现虚拟滚动条

最近在做UI组件,发现下拉组件经常会有很多选项导致渲染的DOM过多,在低性能的设备上交互变的很卡。例如最常见的需求就是下拉选择所在国家或者地区,全世界有两百多个国家或者地区。 如果把所有国家或者地区都渲染出来必然会导致DOM过多,在低性能设备上滚动起来都会很卡。这时候就需要使用虚拟滚动条来提高渲染性能。

# 虚拟滚动条原理

虚拟滚动的原理是显示的容器高度固定,我们只渲染用户看到的选项。但是选项容器的高度还是设置成所有选项的高度,这样滚动条效果保持一样,不过实际渲染的DOM数量却大大减少。

# 虚拟滚动条的数学计算

目前虚拟滚动的主要限制是需要知道显示容器的高度,渲染的每个选项的高度以及渲染的选项的数量。我们可以通过用户的滚动距离来计算需要显示的内容以及需要偏移的距离。

细节请参考下面的代码:

const VirtualScroll: React.FC<IVirtualScrollProps> = ({
  itemCount, // 选项的数量
  height, // 显示容器的高度
  childHeight, // 选项的高度
  itemData // 选项元素
}) => {
  const wrapperRef = useRef<HTMLDivElement>(null);
  // 得高容器滚动了多少距离
  const scrollTop = useScrollAware(wrapperRef);
  // 计算渲染的选项的总高度
  const totalHeight = useMemo(
    () => childHeight * itemCount,
    [childHeight, itemCount]
  );
  // 根据滚动距离,计算要渲染的选项的起始位置
  const startIndex = useMemo(
    () => Math.floor(scrollTop / childHeight),
    [scrollTop, childHeight]
  );
  // 根据容器的高度计算要渲染的选项的数量
  const visibleItemCount = useMemo(
    () => Math.ceil(height / childHeight),
    [height, childHeight]
  );
  // 根据渲染的选项的起始位置,计算选项的偏移量
  const offsetY = useMemo(
    () => startIndex * childHeight,
    [childHeight, startIndex]
  );
  // 计算实际显示的选项元素
  const visibleItemData = useMemo(() => {
    return itemData.slice(startIndex, startIndex + visibleItemCount);
  }, [itemData, startIndex, visibleItemCount]);

  return (
    <div
      style={{
        height,
        overflow: 'auto'
      }}
      ref={wrapperRef}
    >
      <div
        style={{
          position: 'relative',
          overflow: 'hidden',
          willChange: 'transform',
          height: totalHeight
        }}
      >
        <div
          style={{
            willChange: 'transform',
            transform: `translate3d(0, ${offsetY}px, 0)`
          }}
        >
          {visibleItemData}
        </div>
      </div>
    </div>
  );
};

export default memo(VirtualScroll);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

完整的实现请参考 Simple UI - Select (opens new window)

# 总结

在知道虚拟滚动条的原理之后,发现并不难实现,重点要理解几个数值的计算,才能明白为什么这样能达到预期的效果。

# 参考

Build your Own Virtual Scroll (opens new window)

禁用页面滚动

在设计组件的时候,例如 Modal 弹窗组件,在显示组件的时候我们不希望页面可以滚动。这时候就需要临时禁用页面的滚动功能。接下来就讲解下禁用页面滚动的方法。

# 禁用滚动

document.body.style.setProperty('overflow', 'hidden');
1

禁用滚动的方法很简单,只需要在页面的 body 元素上设置 overflow 属性为 hidden,就可以禁用页面的滚动了。但是我们需要考虑到,如果我们的页面有滚动条,滚动条的消失会影响页面的显示效果。 所以完美的禁用滚动方式需要考虑到当前页面滚动条的宽度。

function getScrollbarWidth() {
  const scrollbarWidth =
    window.innerWidth - document.documentElement.clientWidth;
  return scrollbarWidth;
}

function lockBody() {
  const scrollbarWidth = getScrollbarWidth();
  if (scrollbarWidth) {
    document.body.style.setProperty('padding-right', `${scrollbarWidth}px`);
  }
  document.body.style.setProperty('overflow', 'hidden');
}
1
2
3
4
5
6
7
8
9
10
11
12
13

为了保证不影响页面的显示效果,我们需要计算出当前页面滚动条的宽度,然后设置 body 的 padding-right 属性,这样就可以保证页面的显示效果。

# 恢复滚动

function unlockBody() {
  document.body.style.removeProperty('overflow');
  document.body.style.removeProperty('padding-right');
}
1
2
3
4

恢复滚动只需要把添加到 CSS 属性删除即可。

# 总结

如果一直使用 Mac 的触摸板来编程的话,有时候很难意识到滚动条的存在,但是对于大多数的 Windows 用户来说,滚动条会一直存在的。

# 参考

use-scroll-lock.ts (opens new window)