likes
comments
collection
share

用react-dnd做的可拖拽列表太生硬?这样加动画够丝滑了吧?

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

这篇文章将会结合两者,让用户在拖拽list时更丝滑。

最终效果是这样:

用react-dnd做的可拖拽列表太生硬?这样加动画够丝滑了吧?

第一步 绑定所有的refs

在实现draggable list后,ListItem组件中会有三个ref需要绑定:

// ListItem.tsx
const innerRef = useRef(null);

const [..., dragRef, ...] = useDrag(...);

const [..., dropRef] = useDrop(...);

绑定这些refs,当时我们用的react-dnd文档中介绍的方式:

// ListItem.tsx

const dragDropRef = dragRef(dropRef(innerRef))

return (
    <div ref={dragDropRef} ...>...</div>
);

而在实现带动画的list后,ListItem会接收forwardedRef

// ListItem.tsx
const ListItem = forwardRef((props: Props, outerRef) => {

  return (
    <div ref={outerRef} ...>...</div>
  );
});

export default ListItem;

于是我这样实现:

// ListItem.tsx
const ListItem = forwardRef((props: Props, outerRef) => {
    
    const innerRef = useRef(null);

    const [..., dragRef, ...] = useDrag(...);

    const [..., dropRef] = useDrop(...);
    
    const setRef = (node, ...refs) => {
        refs.forEach((ref) => {
            if (typeof ref === 'function') {
                ref(node);
            } else if (ref != null) {
                ref.current = node;
            }
        });
    }

    const mergeRefs = (...refs) => {
        return node => {
            setRef(node, ...refs);
        }
    }
  
    return (
        <div ref={mergeRefs(innerRef, dragRef, dropRef, outerRef)} ...>...</div>
    );
});

export default ListItem;

这样再看页面上,我们就既能看到动画,又能使用拖拽功能,只是现在动画的效果看上还有点奇怪,稍后解决:

用react-dnd做的可拖拽列表太生硬?这样加动画够丝滑了吧?

第二步 给useDrag加上依赖

现在拖拽效果还有问题,是因为在拖拽换位子时,ListItemindex变化了,而组件中useDrag里用的index还是之前的值,因此需要把index加到useDrag的依赖中,这样index变化,useDrag就会用最新的index

// ListItem.tsx
const [{isDragging}, dragRef] = useDrag((() => {
    return {
      type: 'listItem',
      collect: monitor => {
      return {
          isDragging: !!monitor.isDragging(),
        }
      },
      item: {index}
    }
  }), [index])  // <--- 把index加到依赖中

我把每个item的高度设高了一点,这样就能很明显的看到下面的效果了:

用react-dnd做的可拖拽列表太生硬?这样加动画够丝滑了吧?

接下来我们把拖拽时的list item样式改一下,这样更容易分辨。

第三步 拖拽时的样式

我们需要做得看上去这个item被拖走了,因此拖拽时,原本的位置需要是空的,而跟随鼠标的是ListItem。这里需要用到CustomDragLayerdragPreview

官方文档给了一个示例,效果如下:

用react-dnd做的可拖拽列表太生硬?这样加动画够丝滑了吧?

看上去是把那个长方形拖着移动,实际上拖住的组件是一个CustomDragLayer,这个组件可以跟随鼠标,然后CustomDragLayer里面是DragPreview,这个DragPrevie可以和原本的长方形一样,也可以定义不同的样式。而真正的长方形是不显示的。

因此在我们的案例中,我想要ListItem组件跟随鼠标,其实就是需要做一个跟ListItem一模一样的ListItemPreview,然后用CustomDragLayer包住,让页面上看上去是ListItem被拖动了,实际上拖动的是ListItemPreview。而真实的ListItem其实还是按原来的方式移动,样式上我们改成内容透明,只保留外部的边框。

由于我在做的是可拖拽组件中的列表组件,这是个通用组件,下面是我的文件结构:

  • Dnd
    • CustomDragLayer:考虑到CustomDragLayer是可以复用的,也就是可以被其他的拖拽组件使用,我单独用了一个文件夹
    • SortableLists:多个list之间拖拽,这篇文章暂不考虑,只关注单个list
      • SortableList.tsx:单个可拖拽list,也就是这篇张要做的组件
      • components
        • listItem
          • DraggableListItem.tsx:原本真实的组件,里面会包含ListItem.tsx
          • ListItem.tsx:最基础的ListItem容器
          • ListItemDragPreview.tsx:被拖拽的“假”ListItem
    • SortableGrid

第三步-1 ListItem容器组件

ListItem是一最基础的组件,这个组件需要知道是不是在被拖拽,是不是preview组件(跟随鼠标的那个复制体),由此生成不同的样式。

// ListItem.tsx
const ListItem = (props: Props) => {

  const { id, isDragging, preview, children } = props;

  return (
    <StyledDiv id={String(id)} isDragging={isDragging} preview={!!preview}>
      {children}
    </StyledDiv>
  );
}

第三步-2 DraggableListItem

这是之前的真实的移动的组件,也就是把之前的ListItem组件拆分成里外两个部分,里面再render ListItem

const DraggableListItem = <Item,>(props: Props<Item>, outerRef: React.ForwardedRef<HTMLElement | null>) => {
  const { item, index, renderItem, moveListItem } = props;

  const innerRef = useRef<HTMLElement>(null);

  const [{ isDragging }, dragRef, dragPreview] = useDrag((() => {
    return {
      type: 'listItem',
      item: { 
        ...item,
        index
      },
      collect: monitor => ({
          isDragging: !!monitor.isDragging(),
      }),
    }
  }), [item, index])

  useEffect(() => {
    dragPreview(getEmptyImage(), { captureDraggingState: true });
  }, [dragPreview]);

  // useDrop - the list item is also a drop area
  const dropRef = useDrop({
    accept: 'listItem',
    hover: (item: Item & { index: number }, monitor) => {
        const dragIndex = item.index;
        const hoverIndex = index;
          const hoverBoundingRect = innerRef.current?.getBoundingClientRect();
          const hoverMiddleY = ((hoverBoundingRect?.bottom ?? 0) - (hoverBoundingRect?.top ?? 0)) / 2;
          const hoverActualY = (monitor.getClientOffset()?.y ?? 0) - (hoverBoundingRect?.top ?? 0);

          // if dragging down, continue only when hover is smaller than middle Y
          if (dragIndex < hoverIndex && hoverActualY < hoverMiddleY) return;
          // if dragging up, continue only when hover is bigger than middle Y
          if (dragIndex > hoverIndex && hoverActualY > hoverMiddleY) return;

          moveListItem(dragIndex, hoverIndex);
          item.index = hoverIndex;
    },
  })[1];


  // Note: Join the multiple refs together into one (both draggable and can be dropped on, and also bind forwardedRef)
  const setRef = (node: HTMLElement | null, ...refs: Ref<HTMLElement>[]) => {
    refs.forEach((ref) => {
      if (typeof ref === 'function') {
        ref(node);
      } else if (ref != null) {
        ref.current = node;
      }
    });
  }

  const mergeRefs = (...refs: Ref<HTMLElement>[]) => {
    return (node: HTMLElement | null) => {
      setRef(node, ...refs);
    }
  }

  return (
    <StyledDiv ref={mergeRefs(innerRef, dragRef, dropRef, outerRef)} isDragging={isDragging} index={index}>
      <ListItem id={item.id} preview={false} isDragging={isDragging}>
        {renderItem({ item, index, isDragging })}
      </ListItem>
    </StyledDiv>
  )
}

export default forwardRef(DraggableListItem)

是不是很多代码基本和之前的ListItem一致。

第三步-3 ListItemDragPreview

只是拖拽时跟随鼠标的部分,长得跟之前的ListItem一模一样,当然为了显示它在最上层嘛,最好加上box-shadow

const ListItemDragPreview = (props: Props) => { 
  const { id, isDragging, children } = props;


  return <ListItem id={id} isDragging={isDragging} preview>{children}</ListItem>;
}

这个其实很简单,就是把ListItem包一层,因为我们不能直接把ListItem当作Preview组件。

第三步-4 CustomDragLayer

CustomDragLayer我直接复制的示例中的代码,稍微改了样式,然后render ListItemDragPreview

import { styled } from '@mui/material/styles';
import React from 'react';
import { useDragLayer, XYCoord } from 'react-dnd';
import ListItemDragPreview from '../SortableLists/components/listItem/ListItemDragPreview';
import { RenderItemProps, SortableListItem } from '../SortableLists/type';

const StyledDiv = styled('div', {
  shouldForwardProp: prop => !['initialOffset', 'currentOffset'].includes(prop as string)
})<{ initialOffset: XYCoord | null; currentOffset: XYCoord | null }>(({ initialOffset, currentOffset, theme }) => {
  if (!initialOffset || !currentOffset) {
    return {
      display: 'none',
    }
  }
  let { x, y } = currentOffset;
  const transform = `translate(${x}px, ${y}px)`
  return {
    transform,
    WebkitTransform: transform,
    borderRadius: 8,
    boxShadow: theme.boxShadow,
    backgroundColor: 'white',
    position: 'fixed',
    pointerEvents: 'none',
    zIndex: 100,
    left: 0,
    top: 0,
  }
});


type Props<Item> = {
  renderItemPreview: (props: RenderItemProps<Item>) => React.ReactNode | string;
}

const CustomDragLayer = <Item extends SortableListItem>(props: Props<Item>) => {
  const { renderItemPreview } = props;

  const { isDragging, item, initialOffset, currentOffset } =
    useDragLayer((monitor) => ({
      item: monitor.getItem(),
      itemType: monitor.getItemType(),
      initialOffset: monitor.getInitialSourceClientOffset(),
      currentOffset: monitor.getSourceClientOffset(),
      isDragging: monitor.isDragging(),
    }))

  if (!isDragging) {
    return null;
  }

  return (
    <StyledDiv initialOffset={initialOffset} currentOffset={currentOffset}>
        <ListItemDragPreview id={item.id} isDragging={isDragging}>{renderItemPreview({item, isDragging})}</ListItemDragPreview>
    </StyledDiv>
  )
}

export default CustomDragLayer;

第四步 修改样式

由于我们想要这样的效果,看上去是ListItem被拖走了。空位处显现出一个框,暗示用户这是空位子可以放。

用react-dnd做的可拖拽列表太生硬?这样加动画够丝滑了吧?

主要需要调整的是ListItem在不同场景下的样式。当被用在DraggableListItem里时(此时ListItem组件里状态参数为{ isDragging: true, preview: false }),需要是透明的,只保留边框,而被用在ListItemDragPreviewCustomDragLayer里时(此时ListItem组件里状态参数为{ preview: true }),是实心的。

相应的设置了背景色,边框,透明度等样式后,我们便可以看到下面这个漂亮的列表组件了。

用react-dnd做的可拖拽列表太生硬?这样加动画够丝滑了吧?

第五步 组件化

因为这个组件是个通用组件,我们不能要固定内容的ListItem,因此上面的代码里引入了renderItemrenderItemPreview方法,让使用这个组件的人可以自己定义ListItem在排列好的时候显示什么,在拖拽的时候显示什么,我们可以这样使用:

// Page.tsx
const renderItem = ({ item, index, isDragging } : RenderItemProps<PET>) => {
    return <div style={...}>某一些内容</div>
  }

  const renderItemPreview = ({ item, index, isDragging } : RenderItemProps<PET>) => {
    return <div style={...}>另一些内容</div>
  }
  
  return (
    <DndProvider backend={HTML5Backend}>
      <div style={{ backgroundColor: '#eaf1fa', width: 'fit-content', padding: 20 }}>
        <SortableListCmp<PET> list={PETS} renderItem={renderItem} renderItemPreview={renderItemPreview} />
      </div>

    </DndProvider>
  );

最终动画效果是这样:

总结

这个部分的难点就是利用好CustomDragLayerDragPreview,我实际做的时候也尝试了好久,如果你的项目卡在这里不要担心,可以试着把官网那个例子复制到自己项目中,在示例代码上一点点改就好了。

这个大需求是我要做一个用户偏好设置,里面有个功能是针对table组件,用户要能通过拖拽自定义列的顺序,期待后期的效果。