在现代 Web 应用中,拖拽交互已经成为提升用户体验的重要方式。无论是任务管理、列表排序还是元素归类,拖拽功能都能让操作更加直观高效。本文将详细介绍如何使用 Vue 结合 vuedraggable 插件,构建一个功能完善、交互流畅的双向拖拽列表组件,并深入解析其实现原理。

组件功能概述

我们实现的DndList组件(Drag-and-Drop List)是一个双向拖拽列表,具备以下核心功能:

  • 左右两个列表区域支持元素的跨列表拖拽
  • 列表项支持点击按钮快速转移(左侧列表项可点击删除按钮移至右侧,右侧列表项点击内容可移至左侧)
  • 拖拽或点击转移后实时同步列表状态并通知父组件
  • 空列表状态提示,提升用户体验
  • 丰富的视觉反馈(拖拽中样式变化、悬停效果等)

实现方案详解

技术选型

本组件基于 Vue 框架开发,主要依赖:

  • vuedraggable:基于 Sortable.js 的 Vue 拖拽组件,提供了丰富的拖拽相关 API
  • SCSS:用于编写组件样式,支持嵌套规则和变量,使样式结构更清晰

组件结构设计

组件采用左右分栏布局,每个栏目包含标题和拖拽区域两部分。下面是模板结构的关键设计:

<div class="dndList">
  <!-- 左侧列表 -->
  <div :style="{ width: width1 }" class="dndList-list">
    <h3>{{ list1Title }}</h3>
    <draggable
      :set-data="setData"
      :list="list1"
      group="article"
      class="dragArea"
      @end="handleDragEnd"
    >
      <!-- 列表项内容 -->
    </draggable>
  </div>
  
  <!-- 右侧列表 -->
  <div :style="{ width: width2 }" class="dndList-list">
    <!-- 结构与左侧类似 -->
  </div>
</div>

核心功能实现

1. 拖拽功能配置

vuedraggable 的核心配置项:

  • list:绑定的数据源数组
  • group:拖拽组名称,同组内的列表可以相互拖拽
  • @end:拖拽结束时的回调函数
  • set-data:自定义拖拽数据的方法
// 设置拖拽数据,避免默认行为干扰
setData(dataTransfer) {
  dataTransfer.setData('Text', '')
},

// 拖拽结束后通知父组件
handleDragEnd() {
  this.emitListChange()
}
2. 元素转移逻辑

除了拖拽,组件还提供了点击转移的功能,核心方法:

// 从列表1删除元素并添加到列表2
deleteEle(element) {
  const index = this.list1.findIndex((item) => item.id === element.id)
  if (index !== -1) {
    this.list1.splice(index, 1)
    // 确保元素不存在于列表2中才添加,避免重复
    if (this.isNotInList2(element)) {
      this.list2.unshift(element)
    }
    this.emitListChange()
  }
},

// 从列表2删除元素并添加到列表1
pushEle(element) {
  // 实现逻辑与deleteEle类似,方向相反
}
3. 状态同步机制

组件通过自定义事件list-change向父组件同步最新的列表状态:

emitListChange() {
  this.$emit('list-change', {
    list1: [...this.list1],
    list2: [...this.list2]
  })
}

使用扩展运算符 ([...this.list1]) 创建数组副本,确保父组件能正确检测到数组变化。

样式设计亮点

组件的样式设计注重用户体验和视觉反馈:

  1. 拖拽状态反馈

    • 拖拽中的元素使用is-dragging类添加特殊样式
    • 拖拽过程中通过sortable-chosensortable-ghost类提供视觉指引
  2. 交互反馈

    • 列表项悬停时添加边框高亮和阴影效果
    • 删除按钮 hover 时变色,增强可点击感
  3. 空状态处理

    • 当列表为空时显示友好提示,引导用户操作
    • 使用虚线边框和浅色调区分空状态区域
.list-complete-item {
  &:hover {
    border-color: #409eff;
    box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
  }
  
  &.is-dragging {
    background-color: #f0f7ff;
    border-color: #99d1ff;
  }
}

整体代码实现

<template>
  <div class="dndList">
    <div :style="{ width: width1 }" class="dndList-list">
      <h3>{{ list1Title }}</h3>
      <draggable
        :set-data="setData"
        :list="list1"
        group="article"
        class="dragArea"
        @end="handleDragEnd"
      >
        <div
          v-for="element in list1"
          :key="element.id"
          class="list-complete-item"
          :class="{ 'is-dragging': element.isDragging }"
        >
          <div class="list-complete-item-handle">
            {{ element.id }}[{{ element.author }}] {{ element.title }}
          </div>
          <div class="delete-btn">
            <i
              @click="deleteEle(element)"
              class="el-icon-delete"
              title="移至右侧列表"
            />
          </div>
        </div>
        <div v-if="list1.length === 0" class="empty-state">
          列表为空,可从右侧拖拽或点击元素添加
        </div>
      </draggable>
    </div>
    <div :style="{ width: width2 }" class="dndList-list">
      <h3>{{ list2Title }}</h3>
      <draggable
        :list="list2"
        group="article"
        class="dragArea"
        @end="handleDragEnd"
      >
        <div
          v-for="element in list2"
          :key="element.id"
          class="list-complete-item"
          :class="{ 'is-dragging': element.isDragging }"
        >
          <div
            class="list-complete-item-handle2"
            @click="pushEle(element)"
            title="移至左侧列表"
          >
            {{ element.id }} [{{ element.author }}] {{ element.title }}
          </div>
        </div>
        <div v-if="list2.length === 0" class="empty-state">
          列表为空,可从左侧拖拽或点击元素添加
        </div>
      </draggable>
    </div>
  </div>
</template>

<script>
import draggable from 'vuedraggable'

export default {
  name: 'DndList',
  components: { draggable },
  props: {
    list1: {
      type: Array,
      default: () => []
    },
    list2: {
      type: Array,
      default: () => []
    },
    list1Title: {
      type: String,
      default: '列表1'
    },
    list2Title: {
      type: String,
      default: '列表2'
    },
    width1: {
      type: String,
      default: '48%'
    },
    width2: {
      type: String,
      default: '48%'
    }
  },
  methods: {
    // 检查元素是否不在列表1中
    isNotInList1(element) {
      return !this.list1.some((item) => item.id === element.id)
    },
    // 检查元素是否不在列表2中
    isNotInList2(element) {
      return !this.list2.some((item) => item.id === element.id)
    },
    // 从列表1删除元素并添加到列表2
    deleteEle(element) {
      const index = this.list1.findIndex((item) => item.id === element.id)
      if (index !== -1) {
        this.list1.splice(index, 1)
        if (this.isNotInList2(element)) {
          this.list2.unshift(element)
        }
        this.emitListChange()
      }
    },
    // 从列表2删除元素并添加到列表1
    pushEle(element) {
      const index = this.list2.findIndex((item) => item.id === element.id)
      if (index !== -1) {
        this.list2.splice(index, 1)
        if (this.isNotInList1(element)) {
          this.list1.push(element)
        }
        this.emitListChange()
      }
    },
    // 设置拖拽数据
    setData(dataTransfer) {
      dataTransfer.setData('Text', '')
    },
    // 拖拽结束处理
    handleDragEnd() {
      this.emitListChange()
    },
    // 向父组件发射列表变化事件
    emitListChange() {
      this.$emit('list-change', {
        list1: [...this.list1],
        list2: [...this.list2]
      })
    }
  }
}
</script>

<style lang="scss" scoped>
.dndList {
  background: #fff;
  padding: 20px;
  border-radius: 4px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);

  &:after {
    content: "";
    display: table;
    clear: both;
  }

  .dndList-list {
    float: left;
    padding: 15px;
    background-color: #f5f7fa;
    border-radius: 4px;

    &:first-of-type {
      margin-right: 2%;
    }

    h3 {
      margin: 0 0 15px;
      padding-bottom: 10px;
      border-bottom: 1px solid #e4e7ed;
      font-size: 16px;
      color: #1f2d3d;
    }

    .dragArea {
      margin-top: 15px;
      min-height: 100px;
      padding-bottom: 30px;
    }
  }
}

.list-complete-item {
  cursor: pointer;
  position: relative;
  font-size: 14px;
  padding: 10px 12px;
  margin-top: 6px;
  border: 1px solid #bfcbd9;
  border-radius: 4px;
  background-color: #fff;
  transition: all 0.3s ease;

  &:hover {
    border-color: #409eff;
    box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
  }

  &.is-dragging {
    background-color: #f0f7ff;
    border-color: #99d1ff;
  }
}

.list-complete-item-handle,
.list-complete-item-handle2 {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.list-complete-item-handle {
  margin-right: 50px;
}

.delete-btn {
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%);
  color: #ff4949;
  cursor: pointer;
  transition: color 0.2s;

  &:hover {
    color: #f5222d;
  }
}

.empty-state {
  text-align: center;
  padding: 30px 10px;
  color: #8392a5;
  font-size: 14px;
  border: 1px dashed #c0ccda;
  border-radius: 4px;
  background-color: #fafafa;
}

// 拖拽相关动画
.list-complete-item.sortable-chosen {
  background: #e6f7ff;
  border-color: #91d5ff;
}

.list-complete-item.sortable-ghost {
  background: #f6ffed;
  border-color: #b7eb8f;
  opacity: 0.5;
}

.list-complete-enter,
.list-complete-leave-active {
  opacity: 0;
  transform: translateY(10px);
}
</style>

组件使用方式

该组件设计为可复用组件,通过 props 接收初始数据和配置,通过事件向父组件传递状态变化:

<dnd-list
  :list1="leftList"
  :list2="rightList"
  list1Title="待选文章"
  list2Title="已选文章"
  @list-change="handleListChange"
/>

总结

本文介绍的DndList组件基于 Vue 和 vuedraggable 实现了双向拖拽功能,具有以下特点:

  1. 功能完备:支持拖拽和点击两种元素转移方式,满足不同操作习惯
  2. 用户体验佳:提供丰富的视觉反馈和状态提示,操作直观清晰
  3. 高可扩展性:通过 props 配置列表宽度、标题等,适应不同场景
  4. 代码规范:结构清晰,逻辑分明,便于维护和二次开发

该组件可广泛应用于需要进行元素分类、筛选的场景,如文章选集、任务分配、权限配置等。通过本文的解析,希望能帮助开发者更好地理解 Vue 拖拽组件的实现原理,从而构建出更优秀的交互体验。

在实际项目中,还可以根据需求进一步扩展,如添加拖拽排序功能、限制最大拖拽数量、增加元素搜索过滤等,使组件功能更加完善。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐