首页 > 其他分享 >Sortable 拖拽排序组件实现原理

Sortable 拖拽排序组件实现原理

时间:2023-12-15 15:37:42浏览次数:26  
标签:ghost const value event 组件 Sortable draggingItem 拖拽

如果想要实现拖拽排序功能,有很多现成的库可以供使用,比如 Sortable.js(vuedraggable)、dnd-kit(react-dnd)等可以轻松帮助实现这一功能。

本文的目标不是介绍如何使用这些库,而是手动实现一个简单版的 Sortable 组件。通过本文的阅读,您将深入了解拖拽排序的核心原理。

使用模板

使用 Sortable 组件包裹要拖拽的列表项

<template>
  <van-sortable ghost-class="blue-background-class">
    <div v-for="num in 6" class="sort-item">Item {{ num }}</div>
  </van-sortable>
</template>

<style scoped>
.sort-item {
  position: relative;
  display: block;
  padding: 6px 15px;
  margin-bottom: -1px;
  background-color: #fff;
  border: 1px solid rgba(0, 0, 0, 0.125);
  color: #000;
}
.blue-background-class {
  background-color: #c8ebfb;
}
</style>

看看最终的效果

temp1.gif

Sortable 组件模板

<template>
  <div ref="sortableRef" :class="n()">
    <slot />
  </div>
</template>

<script lang="ts" setup>
import { ref } from "vue";
import { createNamespace } from "@vangle/utils";
import { SortableProps } from "./sortable";
import { useSortable } from "./use-sortable";
defineOptions({
  name: "VanSortable",
});

const props = defineProps(SortableProps);

const { n } = createNamespace("sortable");

const sortableRef = ref<HTMLElement | null>(null);

useSortable(props, sortableRef);
</script>

<style lang="less">
@import "./sortable.less";
</style>

核心就在 useSortable 钩子里

useSortable 实现

在 PC 端直接使用原生的拖拽即可,而在移动端使用 touch 事件模拟拖拽。下面两个函数用于区分是否是 touch 事件和获取鼠标点击的位置

// 获取鼠标点击的位置
function getXY(event: MouseEvent | TouchEvent) {
  if (isTouchEvent(event)) {
    const { clientX, clientY } = event.touches[0];
    return { clientX, clientY };
  } else {
    return { clientX: event.clientX, clientY: event.clientY };
  }
}

// 判断是否是触摸事件
function isTouchEvent(val: unknown): val is TouchEvent {
  const typeStr = Object.prototype.toString.call(val);
  return typeStr.substring(8, typeStr.length - 1) === "TouchEvent";
}

因此需要考虑这两种情况,先来看看 useSortable 的定义

import { onBeforeUnmount, onMounted, ref, Ref, ExtractPropTypes } from "vue";
import { SortableProps } from "./sortable";
export function useSortable(
  props: ExtractPropTypes<typeof SortableProps>,
  sortableRef: Ref<HTMLElement | null>
) {
  // 移动端模拟拖拽反馈图像 https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#setting_the_drag_feedback_image
  const ghost = ref<HTMLElement | null>(null);

  function dragStart(event: MouseEvent | TouchEvent) {}

  // 在组件挂载时添加事件监听器
  onMounted(() => {
    // 添加鼠标/触摸事件监听器
    on(sortableRef.value!, "mousedown", dragStart);
    on(sortableRef.value!, "touchstart", dragStart);
  });

  // 在组件销毁前移除事件监听器
  onBeforeUnmount(() => {
    off(sortableRef.value!, "touchstart", dragStart);
    off(sortableRef.value!, "dragstart", dragStart);
  });
}
  • useSortable 接收 props 和拖拽列表元素的 ref
  • ghost 主要用于移动端模拟拖拽效果
  • onMounted 和 onBeforeUnmount 中为列表元素注册移除事件

dragStart 实现

function dragStart(event: MouseEvent | TouchEvent) {
  const isTouch = isTouchEvent(event);
  // touch事件清除默认行为
  if (isTouch) event.preventDefault();

  // 得到 target
  const draggingItem = (
    isTouch ? event.targetTouches[0].target : event.target
  ) as HTMLElement;

  if (!draggingItem) return;

  // 设置拖拽元素类名和属性
  draggingItem.classList.add("dragging");
  if (props.ghostClass) {
    draggingItem.classList.add(props.ghostClass);
  }
  // 设置拖拽属性为 true
  draggingItem.setAttribute("draggable", "true");

  const rect = draggingItem.getBoundingClientRect();

  const { clientX, clientY } = getXY(event);
  // 计算鼠标相对于拖拽元素里的位置
  const downX = clientX - rect.left;
  const downY = clientY - rect.top;

  // 如果是移动端需要创建 ghost
  if (isTouch) {
    // 克隆当前元素
    ghost.value = draggingItem.cloneNode(true) as HTMLElement;

    // 设置ghost样式
    css(
      ghost.value,
      "transform",
      `translate(${draggingItem.offsetLeft}px, ${draggingItem.offsetTop}px)`
    );
    css(ghost.value, "opacity", "0.5");
    css(ghost.value, "position", "absolute");
    css(ghost.value, "left", 0);
    css(ghost.value, "top", 0);
    css(ghost.value, "width", rect.width);
    css(ghost.value, "height", rect.height);

    sortableRef.value!.appendChild(ghost.value);
  } else {
    ghost.value = null;
  }

  // 添加触摸事件
  on(document, "touchmove", onDrag);
  on(document, "touchend", onDragEnd);

  // 添加拖拽事件
  on(document, "dragover", onDrag);
  on(document, "dragend", onDragEnd);

  // 鼠标抬起事件
  on(document, "mouseup", onDragEnd);

  // 获取列表位置信息
  const parentRect = sortableRef.value!.getBoundingClientRect();

  function onDrag(event: MouseEvent | TouchEvent) {
    if (!draggingItem) return;
    const { clientX, clientY } = getXY(event);

    const afterElement = getDragAfterElement(sortableRef.value!, clientY);
    if (afterElement == null) {
      sortableRef.value!.appendChild(draggingItem);
    } else {
      sortableRef.value!.insertBefore(draggingItem, afterElement);
    }

    if (ghost.value) {
      const x = clientX - parentRect.left - downX;
      const y = clientY - parentRect.top - downY;
      // 更新幻影元素位置
      css(ghost.value, "transform", `translate(${x}px, ${y}px)`);
    }
  }

  function onDragEnd() {
    if (draggingItem) {
      draggingItem.classList.remove("dragging");
      if (props.ghostClass) {
        draggingItem.classList.remove(props.ghostClass);
      }

      // 移除触摸事件监听器
      off(document, "touchmove", onDrag);
      off(document, "touchend", onDragEnd);

      // 移除拖拽事件监听器
      off(document, "dragover", onDrag);
      off(document, "dragend", onDragEnd);

      // 移除幻影元素
      ghost.value && sortableRef.value!.removeChild(ghost.value!);
    }
  }
}

代码有点多,容我细细道来:

  1. 使用 isTouchEvent 函数来检测事件类型,判断是鼠标事件还是触摸事件
  2. 如果是触摸事件,则使用 event.targetTouches[0].target 来获取触摸事件的目标元素,否则使用 event.target 来获取鼠标事件的目标元素
  3. draggingItem 添加 'dragging' 标识,如果传入了 ghostClass 则相应添加上
  4. 计算坐标信息,downXdownY 是鼠标或触摸点相对于拖拽元素左上角的偏移量
  5. 如果是 touch 事件创建 ghost 并设置初始样式,添加到列表中
  6. 分别添加 touchmove、touchend,dragover、dragend 事件

onDrag

  1. 获取鼠标或触摸点的坐标,调用 getDragAfterElement 函数来获取距离拖拽点最近的兄弟元素, 并将其存储在 afterElementgetDragAfterElement 的实现后面会介绍
  2. 如果 afterElementnull,说明拖拽元素应该被插入到容器的末尾
  3. 否则说明拖拽元素应该插入到某个兄弟元素之前,使用 insertBefore 方法
  4. 如果 ghost 有值则更新其位置

getDragAfterElement

找到容器内与指定垂直坐标 y 最接近的一个兄弟元素

function getDragAfterElement(container: HTMLElement, y: number) {
  // 得到兄弟元素
  const siblingElements = Array.from(container.children).filter(
    (el) => !el.classList.contains("dragging")
  );

  const item = siblingElements.reduce(
    (closest, child) => {
      const box = child.getBoundingClientRect();
      const offset = y - box.top - box.height / 2;
      if (offset < 0 && offset > closest.offset) {
        return { offset, element: child };
      } else {
        return closest;
      }
    },
    { offset: Number.NEGATIVE_INFINITY }
  );

  return (item as any).element;
}

onDragEnd

在 onDragEnd 中移除拖拽开始为拖拽元素添加的类名,移除移动中和结束事件,如果创建了 ghost 则移除

下面是移动端的效果图

temp2.gif

上面用到的几个工具函数

// 添加事件监听器
function on(el: Element | Document, event: string, fn: Function) {
  el.addEventListener(event, fn as any);
}

// 移除事件监听器
function off(el: Element | Document, event: string, fn: Function) {
  el.removeEventListener(event, fn as any);
}

// 设置 CSS 样式
function css(el: HTMLElement, prop: string, val: number | string) {
  let style = el && el.style;

  if (!(prop in style) && prop.indexOf("webkit") === -1) {
    prop = "-webkit-" + prop;
  }

  (style as any)[prop] = val + (typeof val === "string" ? "" : "px");
}

最后

本文只是简单的实现了 Sortable 组件的核心功能,对于想了解实现原理的伙伴起到借鉴的作用。但在实际工作中推荐使用 Sortable.js、dnd-kit 等成熟的库

标签:ghost,const,value,event,组件,Sortable,draggingItem,拖拽
From: https://www.cnblogs.com/wp-leonard/p/17903449.html

相关文章

  • vue3Cron表达式组件
    npm安装no-vue3-cron引入报错,就直接把代码拿来自己改了no-vue3-cron仓库地址:https://github.com/wuchuanpeng/no-vue3-cronvue-cron.vue<stylelang="scss">.no-vue3-cron-div{.language{position:absolute;right:25px;z-index:1;}.el-tabs{......
  • 适配器模式揭秘:让不兼容的组件完美协同
    前言从这篇文章开始来盘一盘结构型设计模式,在开始之前先来简单回顾一下创建型的设计模式有哪些,如果有兴趣,就来一起学习吧:设计模式之简单工厂模式工厂方法模式:改变你对软件开发的认知_凡夫编程的技术博客_51CTO博客抽象工厂模式:角色解析与应用探索_凡夫编程的技术博客_51CTO博客设计......
  • Flutter 自带的搜索组件
    效果如下官方需要重写四个关键方法classsearchBarDelegateextendsSearchDelegate<String>{/*这个方法返回一个控件列表,显示为搜索框右边的图标按钮,这里设置为一个清除按钮,并且在搜索内容为空的时候显示建议搜索内容,使用的是showSuggestions(context)方法:*/@overrid......
  • vue2子组件拷贝父组件传递的参数
    在Vue2中,父组件向子组件传递参数时,如果参数是对象或数组(即非基本数据类型),那么子组件可以直接修改这个参数,这会影响到父组件的数据。如果你想避免这种情况,你可以让子组件对父组件的传参进行深度拷贝。这样,子组件就可以自由修改拷贝后的参数,而不会影响到父组件的数据。这是一个例......
  • C++ Qt开发:ProgressBar进度条组件
    Qt是一个跨平台C++图形界面开发库,利用Qt可以快速开发跨平台窗体应用程序,在Qt中我们可以通过拖拽的方式将不同组件放到指定的位置,实现图形化开发极大的方便了开发效率,本章将重点介绍ProgressBar进度条组件的常用方法及灵活运用。ProgressBar(进度条)是在Qt中常用的用户界面组件之一......
  • 界面组件DevExpress VCL v23.2新功能预览 - 支持RAD Studio 12.0
    本文即将发布DevExpressVCL 下一个主要更新(v23.2),在之前的文章中(点击这里回顾>>)我们为大家介绍了新的工具提示、图表空间中的标签重叠等,本文将主要介绍DevExpressVCLv23.2中将支持的RADStudio12.0、增强的图像选择器、字体和自定义图标包等。新版即将发布,敬请期待哦~获取De......
  • 【活动回顾】Databend 云数仓与 Databend Playground 扩展组件介绍
    2023年12月7日,作为KubeSphere的合作伙伴,Databend荣幸地受邀参与了KubeSphere社区主办的云原生技术直播活动。本次活动的核心议题为「Databend云数仓与DatabendPlayground扩展组件介绍」,此次分享由DatabendLabs的研发工程师尚卓燃担任主讲嘉宾,向与会者呈现了一场......
  • kettle更新组件(insert_update)
    2种装载方式:全量装载和增量装载插入更新与表到表区别:表到表:只追加数据,不管表里重不重复插入更新:对比关键字段,更新所有数据(不会删除)创建数据流:需求:表输入组件只是将数据追加装载到表中,并不是我们想要的更新数据:如下:插入/更新匹配关键字id=id保留关键字的字段,用来匹......
  • C++ Qt开发:ComboBox下拉组合框组件
    Qt是一个跨平台C++图形界面开发库,利用Qt可以快速开发跨平台窗体应用程序,在Qt中我们可以通过拖拽的方式将不同组件放到指定的位置,实现图形化开发极大的方便了开发效率,本章将重点介绍ComboBox下拉组合框组件的常用方法及灵活运用。在Qt中,ComboBox(组合框)是一种常用的用户界面控件,它......
  • 推荐一个小而全的第三方登录开源组件
    大家好,我是Java陈序员。我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就会大大的提高了工作量,那......