表格拖拽排序与上下移动功能实现指南
先看效果
概述
本文档详细介绍了如何在React项目中实现表格的拖拽排序功能,同时提供上下移动、批量删除等操作。该实现基于Ant Design Table组件和@dnd-kit拖拽库。
功能特性
- ✅ 拖拽排序:支持通过鼠标拖拽调整表格行顺序
- ✅ 按钮移动:提供上移、下移、移到顶部、移到底部按钮
- ✅ 批量操作:支持多选行进行批量删除
- ✅ 实时更新:支持立即更新模式和确认更新模式
- ✅ 用户体验:防误触设计,流畅的拖拽动画效果
技术栈
{
"dependencies": {
"@dnd-kit/core": "^6.0.0",
"@dnd-kit/sortable": "^7.0.0",
"@dnd-kit/utilities": "^3.2.0",
"antd": "^5.0.0",
"react": "^18.0.0"
}
}
核心实现
1. 拖拽行组件(DraggableRow)
const DraggableRow = ({ children, ...props }: any) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props['data-row-key'],
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform),
transition,
cursor: 'grab',
...(isDragging ? {
position: 'relative',
zIndex: 9999,
cursor: 'grabbing'
} : {}),
};
// 防止交互元素触发拖拽
const dragListeners = {
...listeners,
onMouseDown: (e: MouseEvent) => {
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest('input, button, .ant-checkbox, .ant-btn, [role="button"]');
if (!isInteractiveElement) {
listeners?.onMouseDown?.(e);
}
},
};
return (
<tr
{...props}
{...attributes}
{...dragListeners}
ref={setNodeRef}
style={style}
className="draggable-table-row"
>
{children}
</tr>
);
};
2. 拖拽上下文配置
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 拖拽距离阈值,避免误触
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 拖拽结束处理
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (active.id !== over?.id) {
setLocalDataSource((prev) => {
const oldIndex = prev.findIndex((item) => item.ID === active.id);
const newIndex = prev.findIndex((item) => item.ID === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}
};
3. 按钮移动功能
// 上移
const moveUp = (index: number) => {
if (index === 0) return;
const newDataSource = [...localDataSource];
[newDataSource[index], newDataSource[index - 1]] =
[newDataSource[index - 1], newDataSource[index]];
setLocalDataSource(newDataSource);
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
// 下移
const moveDown = (index: number) => {
if (index === localDataSource.length - 1) return;
const newDataSource = [...localDataSource];
[newDataSource[index], newDataSource[index + 1]] =
[newDataSource[index + 1], newDataSource[index]];
setLocalDataSource(newDataSource);
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
// 移到顶部
const moveToTop = (index: number) => {
if (index === 0) return;
const newDataSource = [...localDataSource];
const item = newDataSource.splice(index, 1)[0];
newDataSource.unshift(item);
setLocalDataSource(newDataSource);
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
// 移到底部
const moveToBottom = (index: number) => {
if (index === localDataSource.length - 1) return;
const newDataSource = [...localDataSource];
const item = newDataSource.splice(index, 1)[0];
newDataSource.push(item);
setLocalDataSource(newDataSource);
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
4. 表格配置
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<Table
columns={columns}
dataSource={localDataSource}
rowKey="ID"
pagination={false}
rowSelection={rowSelection}
components={{
body: {
row: DraggableRow,
},
}}
scroll={{ y: 580 }}
size="middle"
rowClassName="draggable-row"
/>
</SortableContext>
</DndContext>
样式优化
.draggable-row td {
height: 60px !important;
vertical-align: middle !important;
padding: 12px 16px !important;
}
.draggable-row:hover td {
background-color: #f5f5f5;
}
.draggable-table-row {
user-select: none;
}
.draggable-table-row:hover {
background-color: #f5f5f5 !important;
}
.draggable-table-row:active {
background-color: #e6f7ff !important;
}
/* 保持交互元素的正常鼠标指针 */
.draggable-table-row .ant-checkbox,
.draggable-table-row .ant-btn,
.draggable-table-row button,
.draggable-table-row input {
cursor: pointer !important;
}
/* checkbox列对齐 */
.ant-table-selection-column {
text-align: center !important;
padding-left: 16px !important;
padding-right: 8px !important;
}
.ant-table-thead .ant-table-selection-column {
text-align: center !important;
}
/* 序号列对齐 */
.ant-table-tbody tr td:nth-child(2) {
text-align: center !important;
padding-left: 8px !important;
padding-right: 8px !important;
}
.ant-table-thead tr th:nth-child(2) {
text-align: center !important;
padding-left: 8px !important;
padding-right: 8px !important;
}
使用方法
import PaperSortDeleteModal from './components/PaperSortDeleteModal';
const MyComponent = () => {
const [visible, setVisible] = useState(false);
const [dataSource, setDataSource] = useState([]);
const handleConfirm = (newList) => {
setDataSource(newList);
console.log('更新后的数据:', newList);
};
return (
<>
<Button onClick={() => setVisible(true)}>
排序管理
</Button>
<PaperSortDeleteModal
visible={visible}
onCancel={() => setVisible(false)}
onConfirm={handleConfirm}
dataSource={dataSource}
immediateUpdate={false} // 是否立即更新
/>
</>
);
};
接口说明
Props
参数 | 类型 | 默认值 | 说明 |
---|---|---|---|
visible | boolean | false | 模态框显示状态 |
onCancel | () => void | - | 取消回调 |
onConfirm | (newList: QuestionData[]) => void | - | 确认回调 |
dataSource | QuestionData[] | [] | 数据源 |
immediateUpdate | boolean | false | 是否立即更新模式 |
数据结构
interface QuestionData {
ID: string;
TITLE: string;
// ... 其他字段
}
最佳实践
1. 性能优化
- 使用
useMemo
缓存计算结果 - 合理设置拖拽激活距离,避免误触
- 大数据量时考虑虚拟滚动
2. 用户体验
- 提供清晰的拖拽视觉反馈
- 防止交互元素触发拖拽
- 支持键盘操作
- 添加操作确认提示
3. 错误处理
const handleDragEnd = (event: any) => {
try {
const { active, over } = event;
if (active.id !== over?.id) {
// 拖拽逻辑
}
} catch (error) {
console.error('拖拽操作失败:', error);
openNotification('错误', '排序操作失败', 'error');
}
};
注意事项
- 唯一ID:确保每行数据都有唯一的ID作为拖拽标识
- 状态管理:使用本地状态管理拖拽过程中的数据变化
- 事件冲突:防止拖拽事件与其他交互事件冲突
- 浏览器兼容性:@dnd-kit库已处理大部分兼容性问题
- 移动端适配:考虑移动端的触摸操作体验
扩展功能
- 分组拖拽:支持跨分组拖拽
- 条件限制:添加拖拽条件限制
- 撤销重做:实现操作历史记录
- 批量移动:支持批量选择后整体移动
实际应用示例
基于提供的 PaperSortDeleteModal
组件,以下是一个完整的使用示例:
import React, { useState } from 'react';
import { Button, Space } from 'antd';
import PaperSortDeleteModal from './components/PaperSortDeleteModal';
interface QuestionData {
ID: string;
TITLE: string;
TYPE?: string;
DIFFICULTY?: string;
}
const PaperDesignPage: React.FC = () => {
const [modalVisible, setModalVisible] = useState(false);
const [questionList, setQuestionList] = useState<QuestionData[]>([
{ ID: '1', TITLE: '这是第一道题目', TYPE: 'single', DIFFICULTY: 'easy' },
{ ID: '2', TITLE: '这是第二道题目', TYPE: 'multiple', DIFFICULTY: 'medium' },
{ ID: '3', TITLE: '这是第三道题目', TYPE: 'judge', DIFFICULTY: 'hard' },
]);
const handleSortConfirm = (newList: QuestionData[]) => {
setQuestionList(newList);
console.log('排序后的结果:', newList);
};
return (
<div style={{ padding: '20px' }}>
<Space>
<Button
type="primary"
onClick={() => setModalVisible(true)}
>
排序与管理
</Button>
</Space>
<PaperSortDeleteModal
visible={modalVisible}
onCancel={() => setModalVisible(false)}
onConfirm={handleSortConfirm}
dataSource={questionList}
immediateUpdate={false}
/>
</div>
);
};
export default PaperDesignPage;
import React, { useState, useMemo } from 'react';
import { Modal, Table, Button, Space, message, Popconfirm, Tooltip } from 'antd';
import { DeleteOutlined, DragOutlined, UpOutlined, DownOutlined, VerticalAlignTopOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { openNotification } from "@/services/NotificationService";
import { QuestionData } from '../PaperDesignProvider';
interface PaperSortDeleteModalProps {
visible: boolean;
onCancel: () => void;
onConfirm: (newList: QuestionData[]) => void;
dataSource: QuestionData[];
/** 是否立即更新模式,true:操作后立即更新外部数据,false:需要点击确定按钮才更新 */
immediateUpdate?: boolean;
}
// 拖拽行组件
const DraggableRow = ({ children, ...props }: any) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props['data-row-key'],
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform),
transition,
cursor: 'grab',
...(isDragging ? {
position: 'relative',
zIndex: 9999,
cursor: 'grabbing'
} : {}),
};
// 过滤掉会阻止拖拽的事件处理器
const dragListeners = {
...listeners,
onMouseDown: (e: MouseEvent) => {
// 如果点击的是交互元素,不触发拖拽
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest('input, button, .ant-checkbox, .ant-btn, [role="button"]');
if (!isInteractiveElement) {
listeners?.onMouseDown?.(e);
}
},
};
return (
<tr
{...props}
{...attributes}
{...dragListeners}
ref={setNodeRef}
style={style}
className={`${props.className || ''} draggable-table-row`}
>
{React.Children.map(children, (child, index) => {
// if (index === 1) { // 第二列(序号列)添加拖拽图标作为视觉提示
// return React.cloneElement(child, {
// children: (
// <div style={{
// display: 'flex',
// alignItems: 'center',
// justifyContent: 'center',
// gap: 6,
// width: '100%'
// }}>
// <DragOutlined style={{ color: '#999', fontSize: '12px' }} />
// <span>{child.props.children}</span>
// </div>
// ),
// });
// }
return child;
})}
</tr>
);
};
const PaperSortDeleteModal: React.FC<PaperSortDeleteModalProps> = ({
visible,
onCancel,
onConfirm,
dataSource,
immediateUpdate = false,
}) => {
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [localDataSource, setLocalDataSource] = useState<QuestionData[]>(dataSource);
// 重置状态当模态框打开时
React.useEffect(() => {
if (visible) {
setLocalDataSource([...dataSource]);
setSelectedRowKeys([]);
}
}, [visible, dataSource]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 拖拽距离阈值,避免误触
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 拖拽结束处理
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (active.id !== over?.id) {
setLocalDataSource((prev) => {
const oldIndex = prev.findIndex((item) => item.ID === active.id);
const newIndex = prev.findIndex((item) => item.ID === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}
};
// 移动功能
const moveUp = (index: number) => {
if (index === 0) {
return;
}
const newDataSource = [...localDataSource];
[newDataSource[index], newDataSource[index - 1]] = [newDataSource[index - 1], newDataSource[index]];
setLocalDataSource(newDataSource);
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
const moveDown = (index: number) => {
if (index === localDataSource.length - 1) {
return;
}
const newDataSource = [...localDataSource];
[newDataSource[index], newDataSource[index + 1]] = [newDataSource[index + 1], newDataSource[index]];
setLocalDataSource(newDataSource);
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
const moveToTop = (index: number) => {
if (index === 0) {
return;
}
const newDataSource = [...localDataSource];
const item = newDataSource.splice(index, 1)[0];
newDataSource.unshift(item);
setLocalDataSource(newDataSource);
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
const moveToBottom = (index: number) => {
if (index === localDataSource.length - 1) {
return;
}
const newDataSource = [...localDataSource];
const item = newDataSource.splice(index, 1)[0];
newDataSource.push(item);
setLocalDataSource(newDataSource);
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
// 批量删除
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
openNotification('提示', '请选择要删除的试题', 'warning');
return;
}
const newDataSource = localDataSource.filter(
(item) => !selectedRowKeys.includes(item.ID)
);
setLocalDataSource(newDataSource);
setSelectedRowKeys([]);
openNotification('成功', `已删除 ${selectedRowKeys.length} 道试题`, 'success');
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
// 单个删除
const handleSingleDelete = (id: string) => {
const newDataSource = localDataSource.filter((item) => item.ID !== id);
setLocalDataSource(newDataSource);
openNotification('成功', '删除成功', 'success');
if (immediateUpdate) {
onConfirm(newDataSource);
}
};
// 确认保存
const handleConfirm = () => {
onConfirm(localDataSource);
onCancel();
};
const columns: ColumnsType<QuestionData> = [
{
title: '序号',
width: 80,
dataIndex: 'dataIndex',
align: 'center',
render: (_, __, index) => index + 1,
},
{
title: '操作',
key: 'action',
width: 160,
align: 'left',
render: (_, record, index) => (
<Space size={4}>
{index > 0 && (
<Tooltip title="移到顶部">
<Button
type="text"
icon={<VerticalAlignTopOutlined />}
size="small"
onClick={() => moveToTop(index)}
/>
</Tooltip>
)}
{index > 0 && (
<Tooltip title="上移">
<Button
type="text"
icon={<UpOutlined />}
size="small"
onClick={() => moveUp(index)}
/>
</Tooltip>
)}
{index < localDataSource.length - 1 && (
<Tooltip title="下移">
<Button
type="text"
icon={<DownOutlined />}
size="small"
onClick={() => moveDown(index)}
/>
</Tooltip>
)}
{index < localDataSource.length - 1 && (
<Tooltip title="移到底部">
<Button
type="text"
icon={<VerticalAlignBottomOutlined />}
size="small"
onClick={() => moveToBottom(index)}
/>
</Tooltip>
)}
<Popconfirm
title="确定要删除这道试题吗?"
onConfirm={() => handleSingleDelete(record.ID)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button type="text" danger icon={<DeleteOutlined />} size="small">
</Button>
</Tooltip>
</Popconfirm>
</Space>
),
},
{
title: '试题标题',
dataIndex: 'TITLE',
key: 'TITLE',
ellipsis: true,
render:(text:string)=>{
return <div>这是测试内容,这是测试内容这是测试内容这是测试内容这是测试内容这是测试内容这是测试内容</div>
}
},
];
const rowSelection = {
selectedRowKeys,
onChange: (keys: React.Key[]) => {
setSelectedRowKeys(keys as string[]);
},
};
const items = useMemo(() => localDataSource.map(item => item.ID), [localDataSource]);
return (
<>
<style>
{`
.draggable-row td {
height: 60px !important;
vertical-align: middle !important;
padding: 12px 16px !important;
}
.draggable-row:hover td {
background-color: #f5f5f5;
}
.draggable-table-row {
user-select: none;
}
.draggable-table-row:hover {
background-color: #f5f5f5 !important;
}
.draggable-table-row:active {
background-color: #e6f7ff !important;
}
/* 保持交互元素的正常鼠标指针 */
.draggable-table-row .ant-checkbox,
.draggable-table-row .ant-btn,
.draggable-table-row button,
.draggable-table-row input {
cursor: pointer !important;
}
/* checkbox列对齐 */
.ant-table-selection-column {
text-align: center !important;
padding-left: 16px !important;
padding-right: 8px !important;
}
.ant-table-thead .ant-table-selection-column {
text-align: center !important;
}
/* 序号列对齐 */
.ant-table-tbody tr td:nth-child(2) {
text-align: center !important;
padding-left: 8px !important;
padding-right: 8px !important;
}
.ant-table-thead tr th:nth-child(2) {
text-align: center !important;
padding-left: 8px !important;
padding-right: 8px !important;
}
`}
</style>
<Modal
title="试题排序与删除"
open={visible}
onCancel={onCancel}
onOk={handleConfirm}
width={1200}
height={800}
okText="确定"
cancelText="取消"
destroyOnHidden
styles={{
body: {
padding: '20px',
height: '700px'
},
footer:{
paddingTop: '16px'
}
}}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span>共 {localDataSource.length} 道试题</span>
{selectedRowKeys.length > 0 && (
<span style={{ marginLeft: 16, color: '#1890ff' }}>
已选择 {selectedRowKeys.length} 项
</span>
)}
</div>
<Space>
<Popconfirm
title={`确定要删除选中的 ${selectedRowKeys.length} 道试题吗?`}
onConfirm={handleBatchDelete}
okText="确定"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
>
批量删除
</Button>
</Popconfirm>
</Space>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<Table
columns={columns}
dataSource={localDataSource}
rowKey="ID"
pagination={false}
rowSelection={rowSelection}
components={{
body: {
row: DraggableRow,
},
}}
scroll={{ y: 580 }}
size="middle"
rowClassName={() => 'draggable-row'}
style={{
'--row-height': '60px'
} as React.CSSProperties}
/>
</SortableContext>
</DndContext>
{/* <div style={{ color: '#666', fontSize: 12 }}>
<div>• 点击确定保存修改,取消则放弃修改</div>
</div> */}
</Space>
</Modal>
</>
);
};
export default PaperSortDeleteModal;
这个实现提供了完整的表格拖拽排序功能,同时兼顾了用户体验和代码可维护性。可以根据具体需求进行定制和扩展。