列表的拖动排序动画原理

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

列表的拖动排序动画原理

列表的拖动排序动画原理

前端甜甜已关注

分享点赞在看

已同步到看一看写下你的评论

视频详情

拖拽排序是一个常见的交互模式,本文将深入浅出地讲解拖动时动画效果的实现原理,并通过 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)`
}

至此,一个简单的拖动排序动画就完成了


留几个疑问

  1. 为什么不能在 鼠标移动的时候获取元素的位置呢?是单纯从性能考虑,还是有其他原因。

  2. 如果列表项比较多,所在的容器有滚动条的情况下,会不会有问题

  3. 如果鼠标拖动到容器边界的时候,让容器自动滚动,会不会有问题

  4. 如果列表使用了虚拟滚动,会不会有问题

关注我,后续我将依次展示每一个问题的具体原理。

全部代码

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()
}
相关推荐
玩嵌入式的菜鸡22 分钟前
网页访问单片机设备---基于mqtt
前端·javascript·css
前端一小卒36 分钟前
我用 Claude Code 的 Superpowers 技能链写了个服务,部署前差点把服务器搞炸
前端·javascript·后端
kyriewen4 小时前
Next.js部署:从本地跑得欢,到线上飞得稳
前端·react.js·next.js
豹哥学前端5 小时前
用猜数字游戏,一口气掌握 JavaScript 核心知识点(附完整代码)
前端·javascript
忆往wu前5 小时前
从0到1一步步拆解搭建,梳理一个 Vue3 简易图书后台全开发流程
前端·javascript·vue.js
shao9185166 小时前
第3章(2)——使用Gradio JavaScript Client
javascript·node.js·cdn·gradio·job·events·playcode
光影少年6 小时前
大屏页面,一次多个请求,请求加密导致 点击 全局时间选择器 时出现卡顿咋解决(面板收起会延迟1~2秒)
前端·javascript·vue.js·学习·前端框架·echarts·reactjs
Mr.mjw6 小时前
vue中封装一个环形进度条组件,根据外部盒子大小自适应变化
前端·javascript·vue.js
无心使然6 小时前
Openlayers调用ArcGis影像服务之一动态地图、地图切片(/exportImage)
前端·javascript·数据可视化
像我这样帅的人丶你还6 小时前
前端监控体系与实践(二):全局监控
前端·javascript·vue.js