用react-dnd做的可拖拽列表太生硬?这样加动画够丝滑了吧?
这篇文章将会结合两者,让用户在拖拽list时更丝滑。
最终效果是这样:
第一步 绑定所有的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;
这样再看页面上,我们就既能看到动画,又能使用拖拽功能,只是现在动画的效果看上还有点奇怪,稍后解决:
第二步 给useDrag
加上依赖
现在拖拽效果还有问题,是因为在拖拽换位子时,ListItem
的index
变化了,而组件中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
的高度设高了一点,这样就能很明显的看到下面的效果了:
接下来我们把拖拽时的list item样式改一下,这样更容易分辨。
第三步 拖拽时的样式
我们需要做得看上去这个item被拖走了,因此拖拽时,原本的位置需要是空的,而跟随鼠标的是ListItem
。这里需要用到CustomDragLayer
和dragPreview
。
官方文档给了一个示例,效果如下:
看上去是把那个长方形拖着移动,实际上拖住的组件是一个CustomDragLayer
,这个组件可以跟随鼠标,然后CustomDragLayer
里面是DragPreview
,这个DragPrevie
可以和原本的长方形一样,也可以定义不同的样式。而真正的长方形是不显示的。
因此在我们的案例中,我想要ListItem
组件跟随鼠标,其实就是需要做一个跟ListItem
一模一样的ListItemPreview
,然后用CustomDragLayer
包住,让页面上看上去是ListItem
被拖动了,实际上拖动的是ListItemPreview
。而真实的ListItem
其实还是按原来的方式移动,样式上我们改成内容透明,只保留外部的边框。
由于我在做的是可拖拽组件中的列表组件,这是个通用组件,下面是我的文件结构:
Dnd
CustomDragLayer
:考虑到CustomDragLayer
是可以复用的,也就是可以被其他的拖拽组件使用,我单独用了一个文件夹SortableLists
:多个list之间拖拽,这篇文章暂不考虑,只关注单个listSortableList.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
被拖走了。空位处显现出一个框,暗示用户这是空位子可以放。
主要需要调整的是ListItem
在不同场景下的样式。当被用在DraggableListItem
里时(此时ListItem
组件里状态参数为{ isDragging: true, preview: false }
),需要是透明的,只保留边框,而被用在ListItemDragPreview
和CustomDragLayer
里时(此时ListItem
组件里状态参数为{ preview: true }
),是实心的。
相应的设置了背景色,边框,透明度等样式后,我们便可以看到下面这个漂亮的列表组件了。
第五步 组件化
因为这个组件是个通用组件,我们不能要固定内容的ListItem
,因此上面的代码里引入了renderItem
和renderItemPreview
方法,让使用这个组件的人可以自己定义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>
);
最终动画效果是这样:
总结
这个部分的难点就是利用好CustomDragLayer
和DragPreview
,我实际做的时候也尝试了好久,如果你的项目卡在这里不要担心,可以试着把官网那个例子复制到自己项目中,在示例代码上一点点改就好了。
这个大需求是我要做一个用户偏好设置,里面有个功能是针对table组件,用户要能通过拖拽自定义列的顺序,期待后期的效果。
转载自:https://juejin.cn/post/7348003004123037733