原创
陈夏杨 / 叫叫技术团队
基于 Antd Upload 实现拖拽(兼容低版本)
背景
哎呀!我想要的是边上传边调整位置可以拖拽的那种效果!哪种?就那种。(这句话是不是似曾相识,没错,到这里作为开发还没领悟那就要面壁思过了~哈哈。)话不多说,目前 Antd 的 Upload 组件并未支持拖拽排序功能,社区也没有发现可以借鉴的 demo,于是我们调研后采用 react-dnd 和 react-sortable-hoc 实现"就那种"效果。
技术分析
其实这个需求之前我们已经有一些基于 react-dnd 技术的沉淀,但是都是基于 html 的 dom 元素进行 ref 绑定操作,并没有搭配 Upload 组件。如下 demo
以上是基于 react-dnd 实现的场景拖拽,直接上核心代码
tsx
const [, dragPreview] = useDrag({
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging()
}),
// item 中包含 index 属性,则在 drop 组件 hover 和 drop 是可以根据第一个参数获取到 index 值
item: { type: 'page', index }
});
const [, drop] = useDrop({
accept: 'page',
hover(item: { type: string; index: number }, monitor: any) {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// 拖拽元素下标与鼠标悬浮元素下标一致时,不进行操作
if (dragIndex === hoverIndex) {
return;
}
// 确定屏幕上矩形范围
const hoverBoundingRect = ref.current!.getBoundingClientRect();
// 获取中点垂直坐标
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// 确定鼠标位置
const clientOffset = monitor.getClientOffset();
// 获取距顶部距离
const hoverClientY = (clientOffset as any).y - hoverBoundingRect.top;
/**
* 只在鼠标越过一半物品高度时执行移动。
* 当向下拖动时,仅当光标低于50%时才移动。
* 当向上拖动时,仅当光标在50%以上时才移动。
* 可以防止鼠标位于元素一半高度时元素抖动的状况
*/
// 向下拖动
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// 向上拖动
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// 执行 move 回调函数
moveCard(dragIndex, hoverIndex);
/**
* 如果拖拽的组件为 Box,则 dragIndex 为 undefined,此时不对 item 的 index 进行修改
* 如果拖拽的组件为 Card,则将 hoverIndex 赋值给 item 的 index 属性
*/
if (item.index !== undefined) {
item.index = hoverIndex;
}
}
});
dragPreview(drop(ref));
因为 Upload 组件上传文件是通过自身 fileList api 底层消化处理的,所以处理起来比较麻烦,还好 Antd 4.16.0 版本Upload 提供的 itemRender 解决了这个问题。但是对于低于这个版本的后续也有解决方案。
注意:如果 Antd 用的是最新的版本 5.x.x,其实官网也提供了 集成 dnd-kit 来实现对上传列表拖拽排序
技术选型
市面上可以实现拖拽排序的库有很多,比如 SortableJS、react-dnd、react-beautiful-dnd、react-sortable-hoc 等。 我列了一个表格:
优点 | 缺点 | |
---|---|---|
SortableJS | 足够轻量级,而且功能齐全 | React 中使用起来并不是太方便,而且它的配置项写起来实在不太符合 React 的思维 |
react-dnd | 库小,贴合 react 拖拽场景 | 多行拖拽不理想,react-beautiful-dnd 库比较大赖于HTML5 拖放 API,这有一些严重的限制 |
react-beautiful-dnd | 动画效果和细节非常完美 | |
react-sortable-hoc | 多行拖拽优势很明显(相比其他库大多依赖于HTML5拖放API ,这有一些严重的限制。例如,如果你需要支持触摸设备,如果你需要锁定拖动到一个轴上,或者想在节点排序时设置动画,事情就会变得很棘手。React-sortablehoc 旨在提供一组简单的 higher-order 组件来填补这些空白。如果您正在寻找一种 dead-simple ,mobile-friendly 的方式来向列表中添加可排序功能,那么您就在正确的位置了。) | 列表过长,需要滑动的列表中拖拽时,滑动后位置不匹配会发生偏移 |
实践中还会有一些踩坑:
- reat-dnd 在项目中快速拖拽时一直报错,"Invariant Violation: Expected targetIds to be registered. "在他的 issue 中也有很多人反应这个问题,虽然有修复过但是并没有完全修复,在 overStack 中也并没有找到好的解决方案。
- 这里以我们实践结论,从 react-dnd、react-sortable-hoc 两个库进行讲解
react-dnd
概念
react dnd 是一组 react 高阶组件,使用的时候只需要使用对应的 API 将目标组件进行包裹,即可实现拖动或接受拖动元素的功能。 在拖动的过程中,不需要开发者自己判断拖动状态,只需要在传入的配置对象中各个状态属性中做对应处理即可,因为react-dnd 使用了 redux 管理自身内部的状态。 值得注意的是,react-dnd 并不会改变页面的视图,它只会改变页面元素的数据流向,因此它所提供的拖拽效果并不是很炫酷的,我们可能需要写额外的视图层来完成想要的效果,但是这种拖拽管理方式非常的通用,可以在任何场景下使用,非常适合用来定制。
安装
json
npm i react-dnd
核心 API
介绍实现拖拽和数据流转的核心 API ,这里以 hook 为例。
DndProvider
使用 react-dnd 需要最外层元素加 DndProvider ,DndProvider 的本质是一个由 React.createContext 创建一个上下文的容器(组件),用于控制拖拽的行为,数据的共享,类似于 react-redux 的 Provider。
tsx
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
<DndProvider backend={HTML5Backend}>组建模块</DndProvider>;
Backend
react dnd 将 DOM 事件相关的代码独立出来,将拖拽事件转换为 react dnd 内部的 redux action。由于拖拽发生在 H5 的时候是 ondrag,发生在移动设备的时候是由 touch 模拟,react dnd 将这部分单独抽出来,方便后续的扩展,这部分就叫做 backend。它是 dnd 在 DOM 层的实现。
- react-dnd-html5-backend : 用于控制 html5 事件的 backend
- react-dnd-touch-backend : 用于控制移动端 touch 事件的 backend
- react-dnd-test-backend : 用户可以参考自定义 backend
useDrag
让 DOM 实现拖拽能力的构子
tsx
import React from 'react';
import { useDrag } from 'react-dnd';
export default function Player() {
// 第一个返回值是一个对象,主要放一些拖拽物的状态。后面会介绍,先不管
// 第二个返回值:顾名思义就是一个Ref,只要将它注入到DOM中,该DOM就会变成一个可拖拽的DOM
const [_, dragRef] = useDrag(
{
type: 'Player', // 给拖拽物命名,后面用于分辨该拖拽物是谁,支持string和symbol
item: { id: 1 } // 拖拽物所携带的数据,让后面一些事件可以拿到数据,已达到交互的目的
},
[]
);
// 注入Ref,现在这个DOM就可以拖拽了
return <div ref={dragRef} />;
}
返回三个参数
第一个返回值是一个对象 表示关联在拖拽过程中的变量,需要在传入 useDrag 的规范方法的 collect 属性中进行映射绑定,比如:isDraging, canDrag 等
第二个返回值 代表拖拽元素的 ref
第三个返回值 代表拖拽元素拖拽后实际操作到的 dom
传入两个参数
- 第一个参数,是一个对象,是用于描述了drag 的配置信息,常用属性
type | 指定元素的类型,只有类型相同的元素才能进行 drop 操作 |
item | 元素在拖拽过程中,描述该对象的数据,如果指定的是一个方法,则方法会在开始拖拽时调用,并且需要返回一个对象来描述该元素。 |
end(item, monitor) | 拖拽结束的回调函数,item 表示拖拽物的描述数据,monitor 表示一个 DragTargetMonitor 实例 |
isDragging(monitor) | 判断元素是否在拖拽过程中,可以覆盖Monitor对象中的 isDragging方法,monitor 表示一个 DragTargetMonitor 实例 |
canDrag(monitor) | 判断是否可以拖拽的方法,需要返回一个 bool 值,可以覆盖 Monitor 对象中的 canDrag 方法,与 isDragging 同理,monitor 表示一个 DragTargetMonitor 实例 |
collect | 它应该返回一个描述状态的普通对象,然后返回以注入到组件中。它接收两个参数,一个 DragTargetMonitor 实例和拖拽元素描述信息item |
- 第二个参数是一个数组,表示对方法更新的约束,只有当数组中的参数发生改变,才会重新生成方法,基于react 的 useMemo 实现
useDrop
实现拖拽物放置的钩子
tsx
import { useDrop } from 'react-dnd';
export const Dustbin = () => {
const [_, dropRef] = useDrop({
accept: ['Player'], // 指明该区域允许接收的拖放物。可以是单个,也可以是数组
// 里面的值就是useDrag所定义的type
// 当拖拽物在这个拖放区域放下时触发,这个item就是拖拽物的item(拖拽物携带的数据)
drop: (item) => {}
});
// 将ref注入进去,这个DOM就可以处理拖拽物了
return <div ref={dropRef}></div>;
};
返回两个参数
第一个返回值是一个对象 表示关联在拖拽过程中的变量,需要在传入 useDrag 的规范方法的 collect 属性中进行映射绑定。
第二个返回值 代表拖拽元素的 ref 传入一个参数 用于描述drop的配置信息,常用属性
accept | 指定接收元素的类型,只有类型相同的元素才能进行 drop 操作 |
drop(item, monitor) | 有拖拽物放置到元素上触发的回调方法,item 表示拖拽物的描述数据,monitor 表示 DropTargetMonitor 实例,该方法返回一个对象,对象的数据可以由拖拽物的 monitor.getDropResult 方法获得 |
hover(item, monitor) | 当拖住物在上方 hover 时触发,item 表示拖拽物的描述数据,monitor表示 DropTargetMonitor 实例,返回一个 bool 值 |
canDrop(item, monitor) | 判断拖拽物是否可以放置,item 表示拖拽物的描述数据,monitor 表示 DropTargetMonitor 实例,返回一个 bool 值 |
API 数据流转
react-sortable-hoc
概念
react-sortable-hoc 是一个基于 React 的拖拽排序组件,它可以让你轻松地实现拖拽排序功能。它提供了一系列的 API,可以让你自定义拖拽排序的行为。它支持拖拽排序的单个列表和多个列表,以及拖拽排序的可视化。
安装
shell
npm install react-sortable-hoc
引入
typescript
import { SortableContainer, SortableElement, arrayMove, SortableHandle } from 'react-sortable-hoc';
核心 API
- sortableContainer 是所有可排序元素的容器
- sortableElement 是每个可渲染元素的容器
- sortableHandle 是定义拖拽手柄的容器
- arrayMove 主要用于将移动后的数据排列好后返回
SortableContainer HOC
Property | Type | Default | Description |
---|---|---|---|
axis | String | y | 项目可以水平、垂直或网格排序。可能值:x、y 或 xy |
lockAxis | String | 如果您愿意,可以在排序时将移动锁定在轴上。这不是 HTML5 拖放所能做到的。可能值:x 或 y。 | |
helperClass | String | 您可以提供一个要添加到 sortable helper 的类,以向其添加一些样式 | |
transitionDuration | Number | 300 | 元素移动位置时转换的持续时间。{ 39d 要禁用 @661 } |
keyboardSortingTransitionDuration | Number | transitionDuration | 在键盘排序期间移动辅助对象时转换的持续时间。如果要禁用键盘排序助手的转换,请将其设置为 0。如果未定义,则默认为 transitionDuration 设置的值 |
keyCodes | Array | {lift: [32],drop: [32],cancel: [27],up: [38, 37],down: [40, 39]} | 一个包含每个 keyboard-accessible 操作的键码数组的对象。 |
pressDelay | Number | 0 | 如果您希望元素只在按下一段时间后才可排序,请更改此属性。mobile 的一个合理的默认值是 200。不能与 distance 属性一起使用。 |
pressThreshold | Number | 5 | 忽略冲压事件之前要容忍的移动像素数。 |
distance | Number | 0 | 如果您希望元素只在被拖动一定数量的像素之后才变得可排序。不能与 pressDelay 属性一起使用。 |
shouldCancelStart | Function | Function | 此函数在排序开始前调用,可用于在排序开始前以编程方式取消排序。默认情况下,如果事件目标是 input、textarea、select 或 option,它将取消排序。 |
updateBeforeSortStart | Function | 在排序开始之前调用此函数。它可以返回一个 promise,允许您在排序开始之前运行异步更新(比如 setState )。function ({ node, index, collection, isKeySorting }, event ) | |
onSortStart | Function | 开始排序时调用的回调。function({ node, index, collection, isKeySorting }, event ) | | |
onSortMove | Function | 当光标移动时在排序期间调用的回调。function ( event )| | |
onSortOver | Function | 在向上移动时调用的回调。function ({ index, oldIndex, newIndex, collection, isKeySorting }, e ) | |
onSortEnd | Function | 排序结束时调用的回调。function ({ oldIndex, newIndex, collection, isKeySorting }, e ) | |
useDragHandle | Boolean | false | 如果您使用的是SortableHandleHOC,请将其设置为true |
useWindowAsScrollContainer | Boolean | false | 如果需要,可以将window设置为滚动容器 |
hideSortableGhost | Boolean | true | 是否 auto-hide 重影元素。默认情况下,为了方便起见,React Sortable List 将自动隐藏当前正在排序的元素。如果要应用自己的样式,请将此设置为 false。 |
lockToContainerEdges | Boolean | false | 您可以将可排序元素的移动锁定到其父元素 SortableContainer |
lockOffset | OffsetValue*|[OffsetValue*,OffsetValue*] | "50%" | 当 lockToContainerEdges 设置为 true 时,这将控制可排序辅助对象与其父对象 SortableContainer 的上/下边缘之间的偏移距离。百分比值相对于当前正在排序的项的高度。如果您希望指定不同的行为来锁定容器的顶部和底部,您还可以传入 array(例如:["0%", "100%"])。 |
getContainer | Function | 返回可滚动容器元素的可选函数。此属性默认为 SortableContainer 元素本身或(如果 useWindowAsScrollContainer 为真)窗口。使用此函数指定一个自定义容器对象(例如,这对于与某些第三方组件(如 FlexTable )集成非常有用)。这个函数被传递给一个参数(即 wrappedInstanceReact 元素),它应该返回一个 DOM 元素。 | |
getHelperDimensions | Function | Function | 可选的function ({ node, index, collection }),它应该返回 SortableHelper 的计算维度。有关详细信息,请参见默认实现 | |
helperContainer | HTMLElement | 函数 | document.body | 默认情况下,克隆的可排序帮助程序将附加到文档正文。使用此属性可指定要附加到可排序克隆的其他容器。接受 HTMLElement 或返回 HTMLElement 的函数,该函数将在排序开始之前调用 |
disableAutoscroll | Boolean | false | 拖动时禁用自动滚动 |
如何使用
直接上demo
tsx
import React from 'react';
import { arrayMove, SortableContainer, SortableElement } from 'react-sortable-hoc';
// 需要拖动的元素的容器
const SortableItem = SortableElement((value) => <div>{value}</div>);
// 整个元素排序的容器
const SortableList = SortableContainer((items) => {
return items.map((value, index) => {
return <SortableItem key={`item-${index}`} index={index} value={value} />;
});
});
// 拖动排序组件
class SortableComponnet extends React.Component {
state = {
items: ['1', '2', '3']
};
onSortEnd = ({ oldIndex, newIndex }) => {
this.setState(({ items }) => {
arrayMove(items, oldIndex, newIndex);
});
};
render() {
return (
<div>
<SortableList
distance={5}
axis={'xy'}
items={this.state.items}
helperClass={style.helperClass}
onSortEnd={this.onSortEnd}
/>
</div>
);
}
}
export default SortableComponnet;
在上面的示例中,我们使用 SortableContainer 组件容纳了一组可拖拽排序的元素,使用 SortableElement 组件包裹了每个元素,并且实现了 onSortEnd 回调函数,以便在拖拽排序完成后更新状态。
效果展示
踩坑
解决:这种报错的解决方法都是 SortableElement 和 SortableContainer 返回组件时外面都要单独在包一个 html 容器标签, 例子是包了个 <div>
结果导向
Antd 版本 4.16.0及以上
使用 react-dnd 搭配 Upload 上传组件 itemRender api实现
注:这里主要基于 react-dnd 实现,Antd 5.x.x 官网有基于 dnd-kit 来实现对上传列表拖拽排序。
tsx
const Box: React.FC<BoxProps> = ({ children, index, className, onClick, moveCard }) => {
const ref = useRef<HTMLDivElement>(null);
const [, dragPreview] = useDrag({
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging()
}),
// item 中包含 index 属性,则在 drop 组件 hover 和 drop 是可以根据第一个参数获取到 index 值
item: { type: 'page', index }
});
const [, drop] = useDrop({
accept: 'page',
hover(item: { type: string; index: number }) {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// 自定义逻辑处理
// 执行 move 回调函数
moveCard(dragIndex, hoverIndex);
}
});
dragPreview(drop(ref));
return (
<div ref={ref} className={className} onClick={onClick}>
{children}
</div>
);
};
使用
使用 Antd Upload itemRender
Antd 版本低于 4.16.0
使用 react-sortable-hoc 库实现
- SortableItem
tsx
const SortableItem = SortableElement((params: SortableItemParams) => (
<div>
<UploadList
locale={{ previewFile: '预览图片', removeFile: '删除图片' }}
showDownloadIcon={false}
showRemoveIcon={params?.props?.disabled}
listType={params?.props?.listType}
onPreview={params.onPreview}
onRemove={params.onRemove}
items={[params.item]}
/>
</div>
));
- SortableList
tsx
const SortableList = SortableContainer((params: SortableListParams) => {
return (
<div className='sortableList'>
{params.items.map((item, index) => (
<SortableItem
key={`${item.uid}`}
index={index}
item={item}
props={params.props}
onPreview={params.onPreview}
onRemove={params.onRemove}
/>
))}
{/* 这里是上传组件,设置最大限制后超出隐藏 */}
<Upload {...params.props} showUploadList={false} onChange={params.onChange}>
{params.props.children}
</Upload>
</div>
);
});
- DragHoc
tsx
const DragHoc: React.FC<Props> = memo(
({ onChange: onFileChange, axis, onPreview, onRemove, ...props }) => {
const fileList = props.fileList || [];
const onSortEnd = ({ oldIndex, newIndex }: SortEnd) => {
onFileChange({ fileList: arrayMove(fileList, oldIndex, newIndex) });
};
const onChange = ({ fileList: newFileList }: UploadChangeParam) => {
onFileChange({ fileList: newFileList });
};
return (
<>
<SortableList
// 当移动 1 之后再触发排序事件,默认是0,会导致无法触发图片的预览和删除事件
distance={1}
items={fileList}
onSortEnd={onSortEnd}
axis={axis || 'xy'}
helperClass='SortableHelper'
props={props}
onChange={onChange}
onRemove={onRemove}
onPreview={onPreview}
/>
</>
);
}
);
使用方式
可直接替换掉 Upload 组件,props 不变。 如果项目中有已经封装好的 Upload 上传组件,尽量不改变原有逻辑代码前提下,更希望以插件的形式按需加载?方案:
可以剔除掉 SortableList 中 SortableContainer 包裹的 Upload 组件(这一步经过实践是可行的,说 Upload UploadList 都要被 SortableContainer 包裹,否走会重复上传和拖拽失败?目前我是没遇到,重复上传是因为 Upload 组件 showUploadList 拖拽场景下必须是 false )
使用案例:( isDrag 表示需要拖拽场景,继而加载)
tsx
{isDrag && (
<DragHoc
accept={accept}
axis={axis}
showUploadList={{ showRemoveIcon }}
fileList={fileList}
onChange={(e) => {
if (onChange) {
onChange(e);
}
}}
onPreview={preview}
onRemove={remove}
listType={listType}
/>
)}
注:当然也可以用 react-dnd 来实现,只是多行拖拽流畅性较差。感兴趣也可以试试
踩坑
图片按钮点击无效
在 Antd 的 Upload 组件中,图片墙上会有「预览」、「删除」等按钮,但是在 react-sortable-hoc 的逻辑中,只要我点击了图片,就会触发图片的拖拽函数,无法触发图片上的各种按钮,所以需要在 SortableList 上重新设置一下 distance 属性,设置成 1 即可。
官网:
If you'd like elements to only become sortable after being dragged a certain number of pixels. Cannot be used in conjunction with the pressDelay prop.
(如果您希望元素仅在拖动一定数量的像素后才可排序。不能与 pressDelay 道具一起使用。默认为 0)
上传图片一直 uploading
原因:当图片列表发生变化,整个 sortable 容器被删除并重新渲染,导致请求失效。
解决方案:
需要将 SortableItem,SortableList 写在 React.FC 外面,每次组件内部 state 发生变化,不会重新执行 SortableContainer 和 SortableElement 方法,就可以让可排序容器里面的元素自动只更新需要改变的 DOM 元素,而不会整个删除并重新渲染了。
图片的 disabled 状态失效
原因:SortableContainer 包裹的组件对 Upload 图片和上传进行了拆分处理,所以需要单独去控制预览和删除按钮
tsx
const SortableItem = SortableElement((params: SortableItemParams) => (
<div>
<UploadList
locale={{ previewFile: '预览图片', removeFile: '删除图片' }}
showDownloadIcon={false}
showRemoveIcon={params?.props?.disabled} //这里需要单独控制
listType={params?.props?.listType}
onPreview={params.onPreview}
onRemove={params.onRemove}
items={[params.item]}
/>
</div>
));