列表的拖动排序动画原理

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

列表的拖动排序动画原理

列表的拖动排序动画原理

前端甜甜已关注

分享点赞在看

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

视频详情

拖拽排序是一个常见的交互模式,本文将深入浅出地讲解拖动时动画效果的实现原理,并通过 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 即可,translateY2 = dy,这就是常规的拖拽。

向上移动同理,translateY 的值始终应该设置listactiveIndex 和 listactiveIndex + 1 的差,或 listactiveIndex 和 listactiveIndex - 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()
}
相关推荐
huangdong_1 天前
淘宝商品SKU图自动分类技术深度解析:从DOM解析到智能归档
开发语言·javascript·ecmascript
xiaofeichaichai1 天前
ES 新特性九年速览:从 ES2016 到 ES2024
前端·javascript·es6
放下华子我只抽RuiKe51 天前
FastAPI 全栈后端(四):认证与授权
开发语言·前端·javascript·python·深度学习·react.js·fastapi
如果超人不会飞1 天前
WebMCP:当浏览器学会和 AI「说人话」,你的网页就成了智能体的游乐场
javascript
整点可乐1 天前
cesium实现全景图加载
javascript·cesium
dualven_in_csdn1 天前
一键起飞调用示例
android·java·javascript
meilindehuzi_a1 天前
通俗易懂掌握树与二叉树:定义、核心概念与JS实现遍历
javascript·ecmascript
胡志辉1 天前
深入浅出理解浏览器事件循环:从一道输出题讲到 Chrome 源码
前端·javascript·面试
gz-郭小敏1 天前
优化横向滚动展示大量数据的时候数据晃动问题
前端·javascript·html·css3