一周调试终于实现了类 slack 类别、频道拖动调整位置功能。
历经四个版本迭代。
实现了类似slack 类别、频道拖动调整功能
从vue->react ;更喜欢React的生态及编程风格,新项目用React来重构了。
1.zustand全局状态
2.DndKit 拖动
功能视频:
dndKit 实现类似slack 类别、频道拖动调整位置功能
React DndKit 实现类似slack 类别、频道拖动调整位置功能_哔哩哔哩_bilibili
1.ChannelList.tsx
javascript
// ChannelList.tsx
import React, { useState } from 'react';
import useChannelsStore from "@/Stores/useChannelListStore";
import { DndContext, closestCenter, DragOverlay, pointerWithin, ragEndEvent, DragOverEvent, DragStartEvent, DragMoveEvent, DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { ChevronDown, ChevronRight } from "lucide-react"; // 图标库
import { Channel } from "@/Stores/useChannelListStore"
interface ChannelProps {
id: string;
name: string;
selected: boolean
}
const ChannelItem: React.FC<ChannelProps> = ({ id, name, selected }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id, data: { type: 'channel' } });
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
}}>
<div className={` w-full rounded-lg pl-1 ${selected ? "bg-gray-300 dark:bg-gray-700 font-bold" : ""} `} >
# {name}
</div>
</div>
)
}
interface CategoryProps {
id: string;
name: string;
channels: Channel[];
channelIds: string[];
active: string | undefined;
}
const Category: React.FC<CategoryProps> = ({ id, name, channels, channelIds, active }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id, data: { type: 'category' }, });
const [collapsed, setCollapsed] = useState(true); // 控制折叠状态
const selectChannel = channels.find(channel => channel.id === active);
return (
<div
ref={setNodeRef}
{...attributes}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
}}>
<div className="flex flex-row text-nowrap group">
<div className=" flex flex-1 flex-row items-center cursor-pointer " onClick={() => setCollapsed(!collapsed)}>
{collapsed ? <ChevronDown size={22} /> : <ChevronRight size={22} />}
<div className="flex-1 ">{name}</div>
</div>
<div
{...listeners} // 绑定拖拽事件到这个点
style={{
cursor: "grab",
}}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 cursor-grab"
>
<GripVertical width={18} />
</div>
</div>
{
collapsed ? (
<SortableContext items={channelIds} strategy={verticalListSortingStrategy}>
{
channels.map((channel) => (
<div key={channel.id} className='ml-2 m-1 rounded-lg hover:dark:bg-gray-700 hover:bg-gray-300 cursor-pointer'>
<ChannelItem
id={channel.id}
name={channel.name}
selected={channel.id === active}
/>
</div>
))
}
</SortableContext>
) : (
channels.find(channel => channel.id === active) && (
<div className="pl-1 ml-2 mr-1 mt-1 font-bold rounded-lg dark:bg-gray-700 bg-gray-300 cursor-pointer">
# {selectChannel?.name}
</div>
))
}
</div>
)
}
const ChannelList: React.FC = () => {
const { categories, categoryIds, channelIds, setCategories } = useChannelsStore();
const [activeItem, setActiveItem] = useState<{ id: string; name: string; type: string } | undefined>();
const [moving, setMoving] = useState(false); // 移动时候渲染
const handleDragStart = (event: DragStartEvent) => {
//当前选中id ,以便组件中高亮显示
const activeData = event.active.data.current as { id: string; name: string } | undefined;
if (activeData) {
setActiveItem({
id: String(event.active.id),
name: activeData.name,
type: activeData?.type, // 类型
});
}
}
const handleDragOver = (event: DragOverEvent) => {
setMoving(false)
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// 处理类别排序
if (activeItem?.type === "category") {
setCategories((prevCategories) => {
const oldIndex = prevCategories.findIndex((cat) => cat.id === activeId);
const newIndex = prevCategories.findIndex((cat) => cat.id === overId);
if (oldIndex === -1 || newIndex === -1) return prevCategories;
return arrayMove([...prevCategories], oldIndex, newIndex);
});
return;
}
if (activeItem?.type === "channel") {
setCategories((prevCategories) => {
const newCategories = [...prevCategories];
const fromCategory = newCategories.find((cat) =>
cat.channels.some((ch) => ch.id === activeId)
);
const toCategory = newCategories.find((cat) =>
cat.channels.some((ch) => ch.id === overId)
);
if (!fromCategory || !toCategory) return prevCategories;
const fromCategoryId = fromCategory.id;
const toCategoryId = toCategory.id;
if (fromCategory !== toCategory) {
const fromCat = newCategories.find((cat) => cat.id === fromCategoryId);
const toCat = newCategories.find((cat) => cat.id === toCategoryId);
if (!fromCat || !toCat) return prevCategories;
const channelIndex = fromCat.channels.findIndex((ch) => ch.id === activeId);
if (channelIndex === -1) return prevCategories;
const [movedChannel] = fromCat.channels.splice(channelIndex, 1);
toCat.channels = [...toCat.channels, movedChannel];
return newCategories;
} else {
const fromCat = newCategories.find((cat) => cat.id === fromCategoryId);
if (!fromCat) return prevCategories;
const channelIndex = fromCat.channels.findIndex((ch) => ch.id === activeId);
const targetIndex = fromCat.channels.findIndex((ch) => ch.id === overId);
if (channelIndex !== targetIndex) {
fromCat.channels = [...arrayMove([...fromCat.channels], channelIndex, targetIndex)];
return newCategories;
}
return prevCategories;
}
});
}
}
const handleDragEnd = (event: DragEndEvent) => {
setMoving(false)
const { active, over } = event;
if (!over) return;
}
const handleDragMove = (event: DragEndEvent) => {
setMoving(true)
}
const renderDragOverlay = (activeItem: { id: string; name: string; type: string }) => {
switch (activeItem.type) {
case "category":
{
const category = categories.find(category => category.id === activeItem.id);
return (
category && (
<div>
<Category
key={category.id}
id={category.id}
name={category.name}
channels={category.channels}
channelIds={[]}
active={''} />
</div>)
)
}
case "channel":
{
const channel = categories.flatMap(category => category.channels).find(channel => channel.id === activeItem.id);
return (
channel && (
<div>
<ChannelItem id={channel.id} name={channel.name} selected={true} />
</div>
)
)
}
default:
return null;
}
};
return (
<DndContext
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragMove={handleDragMove}
>
<SortableContext items={categoryIds} strategy={verticalListSortingStrategy}>
{
categories.map((category) => (
<Category
key={category.id}
id={category.id}
name={category.name}
channels={category.channels}
channelIds={channelIds}
active={activeItem?.id} />
))
}
</SortableContext>
<DragOverlay>
{moving && activeItem?.type && renderDragOverlay(activeItem)}
</DragOverlay>
</DndContext>
);
};
export default ChannelList;
2.useChannelsStore.ts
javascript
//useChannelsStore.ts
import { create } from "zustand";
// 频道接口
export interface Channel {
id: string;
name: string;
}
// 频道类型接口
export interface Category {
id: string;
name: string;
channels: Channel[];
}
// 初始化频道类型
const initialChannelTypes: Category[] = [
{
id: "text",
name: "文字",
channels: [
{ id: "1", name: "文字频道1" },
{ id: "2", name: "文字频道2" },
],
},
{
id: "void",
name: "语音",
channels: [
{ id: "3", name: "语音频道1" },
{ id: "4", name: "语音频道2" },
],
},
{
id: "prv",
name: "私密",
channels: [
{ id: "5", name: "私密频道1" },
{ id: "6", name: "私密频道2" },
],
},
];
interface ChannelsStore {
categories: Category[];
channelIds: string[];
categoryIds: string[];
setCategories: (update: Category[] | ((prev: Category[]) => Category[])) => void;
}
const useChannelsStore = create<ChannelsStore>((set) => ({
categories: initialChannelTypes,
channelIds: initialChannelTypes.flatMap((channelType) =>
channelType.channels.map((channel) => channel.id)
),
categoryIds: initialChannelTypes.map((category) => category.id),
setCategories: (update) => set((state) => ({
categories: typeof update === "function" ? update(state.categories) : update,
}))
}));
export default useChannelsStore;