最终实现的效果如下,结尾有全部代码

列表的拖动排序动画原理
列表的拖动排序动画原理
前端甜甜已关注
分享点赞在看
已同步到看一看写下你的评论
拖拽排序是一个常见的交互模式,本文将深入浅出地讲解拖动时动画效果的实现原理,并通过 React 演示一个最小实现方案。
我们的列表有 5 个由内容决定高度的项目,核心思路是通过 transform 的 translateY 属性来控制每个项目的位置而不是直接操作 DOM 的顺序。
初始状态下,每个项目的 translateY 都是 0:
bash
translateY
0
0
0
0
0
初始值 translateY 记录的是元素位移
translateY 最终将赋值给 视图层每一项 div 的 style transform
先来看数据层
按下鼠标时,需要做两件事:
-
记录当前拖拽的项目(activeItem)
-
使用
getBoundingClientRect()获取所有列表项的位置信息(top、bottom、height等)
随着鼠标移动,我们需要判断当前拖拽的项目应该与哪个项目交换位置。这里采用中心点检测法,计算鼠标当前位置(clientY)与每个列表项矩形中心点的距离,距离最近的那个就是 overItem(目标项)。
开始拖动
假设鼠标按在第 2 项后开始拖动,以向下拖动为例
情况一
当拖动时碰撞到了第 3 项,然后就只需要将第 3 项向上移动即可,第三项的位移是 dy = 第 2 项的 rect.top - 第 3 项的 rect.top,
则 translateY = [0, 0, dy, 0, 0]。
rect 是鼠标按下时
getBoundingClientRect()的返回值。
情况二
当拖动时碰撞到了第 4 项,需要将第 3 项 和 第 4 项都向上移动,这两项的位移依然是 dy = 第 2 项 的 rect.top - 第 3 项的 rect.top,
则 translateY = [0, 0, dy, dy, 0]。
第 2 项 也需要移动,鼠标按下的时候记录一下 按下的 clientY, 在移动过程中 dy = move_clientY - down_clientY 即可,translateY[2] = dy,这就是常规的拖拽。
向上移动同理,translateY 的值始终应该设置list[activeIndex] 和 list[activeIndex + 1] 的差,或 list[activeIndex] 和 list[activeIndex - 1] 的差
视图层
对于 activeItem 项,要设置 transition 为 none
bash
const style: React.CSSProperties = {
transition: isActive ? 'none' : 'transform 0.3s',
transform: `translateY(${state.translateY[index]}px)`
}
至此,一个简单的拖动排序动画就完成了
留几个疑问
-
为什么不能在 鼠标移动的时候获取元素的位置呢?是单纯从性能考虑,还是有其他原因。
-
如果列表项比较多,所在的容器有滚动条的情况下,会不会有问题
-
如果鼠标拖动到容器边界的时候,让容器自动滚动,会不会有问题
-
如果列表使用了虚拟滚动,会不会有问题
关注我,后续我将依次展示每一个问题的具体原理。
全部代码
bash
import { cn } from'@/utils/cn'
import { configure } from'mobx'
import { observer, useLocalObservable } from'mobx-react-lite'
import { PointerEvent, useRef } from'react'
import { startDrag } from'rmst-design'
const textPool = [
'Design',
'Implement the user authentication module with OAuth support',
'Setup',
'Write API endpoints for data management including pagination, filtering and sorting capabilities across all resources',
'Deploy to production',
'Write comprehensive test suites covering both unit and integration scenarios to ensure system reliability',
'Review',
'Configure the CI/CD pipeline with automated testing, staging deployments, and production release workflows for multiple environments',
'Fix bugs',
'Refactor the database access layer to support multiple database backends and implement connection pooling for improved performance under heavy concurrent load',
'Update deps',
'Design and implement the real-time notification system supporting email, SMS, and push notifications with configurable user preference management and delivery tracking',
'Optimize queries',
'Build the admin dashboard with role-based access control, comprehensive activity logging, user management tools, and system health monitoring capabilities for operations team'
]
const initialItems = Array.from({ length: 5 }, (_, i) => ({
id: `a-${i + 1}`,
text: `${i + 1}. ${textPool[i % textPool.length]}`
}))
configure({ enforceActions: 'never' })
exportconstDragSortMy = observer(() => {
const state = useLocalObservable(() => {
return {
items: initialItems.slice(0, 5),
activeIndex: null,
overIndex: null,
translateY: []
}
})
const domsMapRef = useRef<Map<string, DOMRect>>(newMap())
const domRefs = useRef<Map<string, HTMLDivElement>>(newMap())
constsetDomRef = (id, el) => {
domRefs.current.set(id, el)
}
consthandlePointerDown = async (downEvt: PointerEvent, id: string, index: number) => {
calcPosition()
const down_clientY = downEvt.clientY
state.activeIndex = index
startDrag(downEvt, {
onDragMove: moveEvent => {
const move_clientY = moveEvent.clientY
const rects = Array.from(domsMapRef.current).map(([id, rect]) => ({ id, rect }))
// 寻找到最近的一个元素
const overIndex = rects.reduce(
(closest, item, i) => {
const center = item.rect.top + item.rect.height / 2
const distance = Math.abs(moveEvent.clientY - center)
return distance < closest.distance ? { index: i, distance } : closest
},
{ index: -1, distance: Infinity }
).index
state.overIndex = overIndex
const { activeIndex } = state
const translateY = []
if (overIndex > activeIndex) {
const offset = rects[activeIndex].rect.top - rects[activeIndex + 1].rect.top
for (let i = activeIndex + 1; i <= overIndex; i++) {
translateY[i] = offset
}
} elseif (overIndex < activeIndex) {
const offset = rects[activeIndex].rect.bottom - rects[activeIndex - 1].rect.bottom
for (let i = activeIndex - 1; i >= overIndex; i--) {
translateY[i] = offset
}
} elseif (overIndex === activeIndex) {
}
translateY[activeIndex] = move_clientY - down_clientY
state.translateY = [...translateY]
},
onDragEnd: () => {
if (state.overIndex !== state.activeIndex) {
}
reset()
},
onPointerUp: () => {
reset()
}
})
constreset = () => {
state.activeIndex = null
state.overIndex = null
state.translateY = []
}
}
constcalcPosition = () => {
for (const [id, el] of domRefs.current) {
const rect = el.getBoundingClientRect().toJSON()
domsMapRef.current.set(id, rect)
}
}
return (
<divclassName="rmstsd-dsm-c relative mt-10">
<divclassName="px-4 space-y-2">
{state.items.map((item, index) => {
const isActive = state.activeIndex === index
const style: React.CSSProperties = {
transition: isActive ? 'none' : 'transform 0.3s',
transform: `translateY(${state.translateY[index] ?? 0}px)`
}
return (
<div
key={item.id}
ref={el => setDomRef(item.id, el)}
style={style}
onPointerDown={evt => handlePointerDown(evt, item.id, index)}
className={cn(
'rounded-xl bg-slate-500 px-4 py-3 text-sm shadow-sm text-white select-none',
isActive ? 'ring-sky-500 shadow-lg bg-slate-800 relative z-10' : ''
)}
>
<divclassName="mt-2">{item.id}</div>
<divclassName="mt-2">{item.text}</div>
</div>
)
})}
</div>
</div>
)
})
startDrag 的代码
bash
interface DragEndData {
isCanceled: boolean
upEvt: PointerEvent
}
interface DragOptions {
onDragStart?: (downEvt: React.PointerEvent | PointerEvent) => void
onDragMove?: (moveEvt: PointerEvent) => void
onDragEnd?: ({ isCanceled, upEvt }: DragEndData) => void
onPointerUp?: (upEvt: PointerEvent) => void // 与 html 类似, 发生了 drag 后, 就不会触发 onPointerUp 事件
distanceThreshold?: number
}
let disableClick = false
document.addEventListener(
'click',
evt => {
if (disableClick) {
evt.stopPropagation()
}
},
{ capture: true }
)
export const startDrag = (downEvt: React.PointerEvent | PointerEvent, options: DragOptions) => {
const { onDragStart, onDragMove, onDragEnd, onPointerUp, distanceThreshold = 10 } = options
const abCt = new AbortController()
const target = downEvt.target as HTMLElement
let isMoved = false
document.addEventListener(
'pointermove',
moveEvt => {
const dis = Math.hypot(moveEvt.clientX - downEvt.clientX, moveEvt.clientY - downEvt.clientY)
if (!isMoved) {
if (dis < distanceThreshold) {
return
}
disableClick = true
clearWebSelection()
target.setPointerCapture(downEvt.pointerId)
onDragStart?.(downEvt)
isMoved = true
}
onDragMove?.(moveEvt)
},
{ signal: abCt.signal }
)
let _isCanceled = false
const cancel = (dee: DragEndData, isPointerEvent: boolean) => {
if (isPointerEvent) {
setTimeout(() => {
disableClick = false
})
}
if (_isCanceled) {
return
}
_isCanceled = true
abCt.abort()
if (isMoved) {
onDragEnd?.(dee)
} else {
onPointerUp?.(dee?.upEvt)
}
}
document.addEventListener('pointerup', evt => cancel({ isCanceled: false, upEvt: evt }, true), { signal: abCt.signal })
document.addEventListener('pointercancel', evt => cancel({ isCanceled: true, upEvt: evt }, true), { signal: abCt.signal })
document.addEventListener(
'keydown',
evt => {
if (evt.key === 'Escape') {
cancel({ isCanceled: true, upEvt: null }, false)
}
},
{ signal: abCt.signal }
)
}
export function clearWebSelection() {
const sel = window.getSelection()
if (sel.rangeCount > 0) sel.removeAllRanges()
}