当前位置: 首页 > article >正文

前端拖拽相关功能详解,一篇文章总结前端关于拖拽的应用场景和实现方式(含源码)

前言

本篇文章所有的代码,都是在 vue + vite + ts 项目基础之上实现的,这样也是为了方便大家直接用源码,在开始之前建议大家阅读这篇《零基础搭建 vite项 目教程》。此项目就是这个教程搭建的,本篇文章关于拖拽的相关代码是此项目的一个分支。如果你没有时间阅读详细的教程,你也可以直接在 git 上克隆项目。

learn-vite: w搭建简单的vite+ts+vue框架

本篇文章的所有代码都在 drag 分支上,基本内容大致如下(后续代码可能会有优化):

把项目克隆之后切换到 drag 分支,运行 npm run dev 直接访问 http://localhost:80/drag  就可以看到本篇文章涉及的全部内容。

我将拖拽的功能大致分为五大类,分别是(1)拖拽上传、(2)拖拽调整宽度、(3)拖拽调整按钮位置、(4)拖拽排序、(5)拖拽将文件移动到文件夹。这些都是比较常用的,除此之外其实还有很多复杂的情况,后续会再优化和补充。

一、文件拖拽到指定区域上传

文件拖拽上传,是一个很基础的功能,一般来说我们有两种选择,(1)使用组件库,(2)使用自己封装的方法。

1.1 组件库实现拖拽上传

常见的组件库,比如 elementPlus 、antDesignVue 等都提供上传组件。

使用组件库的好处是,简单、安装即用,组件库一般都提供很成熟的方法和各种事件的回调,免得我们再自定义。组件库的拖拽上传本质上就是利用了 js 的 drag、drop 等事件来实现的。

但是组件库的缺点也显而易见:

(1)会影响页面的 html 结构

因为我们在使用的时候,需要在页面增加一个 elUpload 标签,然后页面上所有的内容都需要包裹在这个 elUpload 标签内,页面的结构和布局都受这个标签的影响。

(2)样式需要自定义

组件库的样式很多时候不是我们想要的,还需要对它的样式进行修改自定义,徒增工作量。

我认为修改组件库的样式是一件很麻烦的事情,因为组件库的样式过于全面,对于很多操作比如 hover、focus、active 都有对应的样式。但是实际上我们的需求根本不用考虑这么多,所以在使用组件库的时候,样式覆盖的要考虑得很全面,还要考虑样式的层级关系,动不动就需要加一些 !important ,也很容易出现问题。

所以我在实际开发过程中,如果是小功能能不用组件库就不用,比如一个简单的输入框,我选择自定义 input 标签,而不是使用 el-input。

但是有些时候还是要用的,比如一个 popover 功能,我们需要它自动定位的时候就直接用 el-popover 比较好,因为计算它的位置是一个很麻烦的事情,也就是人们常说的不要自己造轮子。

总之,能找到对于自己来说更高效的开发方法就好。

对于一些新增的功能还好,可以使用组件库提高工作效率;但是如果在一个现有的旧页面中,增加上传功能,就不推荐使用组件库,因为我们最好不要改变页面原本的 dom 结构。

解决办法就是我们自定义一个拖拽上传方法。

(3)代码

我的这个项目用的组件库是 ant-design-vue ,但是文档中是用 elementPlus 举例的,实际上都是大同小异。

<template>
	<div class="drag-upload-container">
		<h1 class="title">使用组件库实现拖拽上传文件</h1>
		<!-- 使用组件库实现拖拽上传,需要在模版中增加一个标签,入下面的 a-upload-draager -->
		<a-upload-dragger v-model:fileList="fileList" name="file" :multiple="true" action="https://www.mocky.io/v2/5cc8019d300000980a055e76" @change="handleChange" @drop="handleDrop">
			<p class="ant-upload-text">Click or drag file to this area to upload</p>
			<p class="ant-upload-hint">Support for a single or bulk upload. Strictly prohibit from uploading company data or other band files</p>
		</a-upload-dragger>
	</div>
</template>
<script lang="ts" setup>
import { UploadDragger as AUploadDragger } from 'ant-design-vue'
import { ref } from 'vue'

const fileList = ref([])
const handleChange = () => {
	//
}
const handleDrop = () => {
	//
}
</script>
<style lang="scss" scoped>
.drag-upload-container {
	padding: 24px;
	.title {
		margin: 10px 0 20px;
	}
}
</style>

本小结的源代码在项目中的 src/pages/drag/dragUpload.vue  文件夹中。

1.2 自己封装拖拽上传方法

为了解决 1.1 节组件库影响 dom 结构的问题,我们需要自己封装一个拖拽上传方法,宗旨是:

  1. 不论页面结构多复杂,都不要影响原来的 dom 结构。
  2. 一个封装的公共类,复制到任何项目都可以使用

我们要知道拖拽的本质就是利用了 javascript 的 drag/drop 等事件,开始之前耐心的看一下 mdn 官方关于 HTML 拖拽 API 的介绍。HTML 的拖拽 API

拖放的主要步骤是 drop 事件定义的一个释放区(释放文件的目标元素)和为 dragover 事件定义一个事件处理程序。

(1)基本步骤

  1. 定义拖放区域,用于触发 drop 事件
  2. 给拖放区域定义 drapover 事件和样式,用于指示用户此处可以拖放文件
  3. 对 drop 事件的详细定义,文件的获取和处理

我们在封装类的时候,需要把拖放区域的 dom 当作一个参数传入,这样就可以在任何地方复用。需要用到的所有事件有:

  1. dragenter: 拖拽进入【拖放区域】,此时可能要展示某些提示文案或样式
  2. dragover: 在【拖放区域】中,此时可能要展示某些提示文案或样式
  3. dragleave: 拖拽离开【拖放区域】,此时可能要隐藏某些提示文案或样式
  4. drop: 在【拖放区域】松开鼠标,此时要获取我们拖拽的文件,并进行后续的处理

除了上面 4 个事件,还有 drag、 dragstart 、dragend 三个事件是我们在当前功能用不到的,因为这三个事件是针对【被拖拽元素】的,拖拽上传功能中,【被拖拽元素】是我们电脑文件系统中的某个文件。

如果【被拖拽元素】是我们当前页面的某个 dom 元素,那么这几个事件就有用了,在本篇文章的第五章【拖拽将文件移动到文件夹】中有用到。

(2)使用 dataTransfer 传递数据

DataTransfer 接口是一个 HTML 原生接口,用于保存拖动并放下过程中的数据,他可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型

我们只需要在 drop 事件中取 event.dataTransfer.files 就可以获取到我们正在拖拽的文件

// 一个 drop 事件
function onDrop(evt: DragEvent) {
    // 获取到拖拽的文件
	const res = evt.dataTransfer?.files
}

(3)事件监听

还有一个重要的问题,我们在监听 drop、dragover 等事件的时候,应该是监听我们的【拖放区域】元素的事件,而不是 document 等。在本例中,我们将【拖放区域】元素作为一个参数传递给封装的类,这样功能就可以复用了。

(4)代码

下面是我封装的一个 DragUploader 类,在这个类中,我们把拖拽中的【提示文案 tipText】通过参数传入,并在类的内部创建并设置对应的 dom 元素的样式(tipEle)。实际上,我们也可以直接把拖拽中的样式的整个 dom 作为参数传入,看具体需求和开发习惯。

export class DragUploader {
	// 拖拽区域的父元素
	el: HTMLElement | null = null
	// 拖拽中的文案
	tipText: string = ''
	// 拖拽中展示的元素
	tipEle: HTMLElement | null = null
	// 拖拽上传文件之后的回调
	fileCb: (files: Array<File>) => void

	constructor({ el, tipText, fileCb }: { el: HTMLElement; tipText: string; fileCb: (Files: Array<File>) => void }) {
		this.el = el
		this.tipText = tipText
		// 创建拖拽中展示的元素
		this.tipEle = this.createTipEle(tipText)
		// 获取文件后的回调,以供后续使用
		this.fileCb = fileCb
		// 监听拖拽事件,创建实例的时候就开始监听
		this.addListener(el)
	}

	createTipEle(tipText: string) {
		const ele = document.createElement('div')
		// 自定义样式,使用绝对定位,需要设置根元素的 position
		ele.style.position = 'absolute'
		ele.style.top = '0px'
		ele.style.left = '0px'
		ele.style.display = 'none'
		ele.style.alignItems = 'center'
		ele.style.justifyContent = 'center'
		ele.style.width = '100%'
		ele.style.height = '100%'
		ele.style.background = '#fff'
		ele.style.pointerEvents = 'none' // 必须加上这句,否则会重复触发 drag事件
		ele.innerHTML = tipText
		return ele
	}
	// 显示拖拽中的样式元素
	showTipEle() {
		if (!this.tipEle) {
			return
		}
		this.tipEle.style.display = 'inline-flex'
	}
	//隐藏拖拽中的样式元素
	hideTipEle() {
		if (!this.tipEle) {
			return
		}
		this.tipEle.style.display = 'none'
	}
	onDragover(evt: DragEvent) {
		// 一定要阻止默认事件,否则会调用浏览器的默认行为,打开拖拽的图片或者可预览的文件
		evt.stopPropagation()
		evt.preventDefault()
		this.showTipEle()
	}
	onDragEnter(evt: DragEvent) {
		evt.stopPropagation()
		evt.preventDefault()
		this.showTipEle()
	}
	onDragLeave(evt: DragEvent) {
		evt.stopPropagation()
		evt.preventDefault()
		this.hideTipEle()
	}
	onDrop(evt: DragEvent) {
		evt.stopPropagation()
		evt.preventDefault()
		this.hideTipEle()
		const res = (evt.dataTransfer?.files || []) as Array<File>
		this.fileCb(res)
	}
	// 一定要绑定 this 指针
	handleDragOver = this.onDragover.bind(this)
	handleDragEnter = this.onDragEnter.bind(this)
	handleDragLeave = this.onDragLeave.bind(this)
	handleDrop = this.onDrop.bind(this)
	addListener(el: HTMLElement) {
		// 避免重复插入拖拽中的样式元素
		if (el.contains(this.tipEle)) {
			return
		}
		// 将拖拽中的样式插入根元素
		if (this.tipEle) {
			el.appendChild(this.tipEle)
		}
		el.addEventListener('dragover', this.handleDragOver, false)
		el.addEventListener('dragenter', this.handleDragEnter, false)
		el.addEventListener('dragleave', this.handleDragLeave, false)
		el.addEventListener('drop', this.handleDrop, false)
	}
	// 移除事件监听,实例调用的,一般在组件的卸载的时候调用
	removeListenner(): void {
		if (!this.el) {
			return
		}
		this.el.removeEventListener('dragover', this.handleDragOver, true)
		this.el.removeEventListener('dragenter', this.handleDragEnter, false)
		this.el.removeEventListener('dragleave', this.handleDragLeave, true)
		this.el.removeEventListener('drop', this.handleDrop, true)
	}
}

在 vue 项目中使用

<template>
	<div class="drag-upload-container">
		<h1 class="title">自己封装上传组件</h1>
		<h2>需求:拖拽到下面虚线区域上传文件,不要使用组件库</h2>
		<ul ref="dragEle" class="target-drag-area">
			<li>床前明月光</li>
			<li>疑是地上霜</li>
			<li>举头望明月</li>
			<li>低头思故乡</li>
		</ul>
	</div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, Ref } from 'vue'
import { DragUploader } from './dragUpload'
const dragEle = ref(null)
const dragInstance: Ref<DragUploader | null> = ref(null)
const initDragger = () => {
	if (!dragEle.value) {
		return
	}
	dragInstance.value = new DragUploader({
		el: dragEle.value,
		tipText: '拖拽到此处上传文件',
		fileCb: (files: Array<File>) => {
			// 在这里可以获取拖拽的文件
			// 获取到了拖拽的文件,后续的操作就随意了
			console.log(111, files)
		},
	})
}

onMounted(() => {
	initDragger()
})

onBeforeUnmount(() => {
	if (dragInstance.value) {
		dragInstance.value.removeListenner()
	}
})
</script>
<style lang="scss" scoped>
.drag-upload-container {
	padding: 24px;
	.title {
		margin: 10px 0;
	}
	.target-drag-area {
		margin-top: 10px;
		padding: 10px;
		border: 1px dashed;
		border-radius: 10px;
		position: relative;
		overflow: hidden;
	}
}
</style>

本小结的源代码在项目中的 src/pages/drag/dragUploadCustom.vue  文件夹中。

1.3 拖拽上传总结 

我们可以在看一下 elementPlus 中拖拽上传组件是如何实现的。在 elementPlus 官方克隆源代码,找到 upload 文件夹,找到 upload-dragger.vue 文件,我们可以发现本质都是一样的,因为这是一个很简单的功能。

换句话说,如果你不用 el-upload 也不用 1.2 小节的方法,你也可以自己封装一个 my-upload 组件,然后利用 drop、dragover 等事件实现你的拖拽上传,这样的好处是可以自定义样式,不用修改组件库的复杂样式,坏处一样是对于复杂的旧项目会影响 dom 结构。

二、拖拽调整分屏宽度

拖拽调整分屏的宽度是一个非常常见又重要的功能,现在各种 AI 网站层出不穷,你会发现几乎每个产品都有分屏功能,左侧预览文件,右侧是 AI 对话,然后鼠标拖拽可以实现两侧分屏宽度的调整,页面基本结构如下:

需求概括:

  1. 页面 Wrap 分为左右两个区域 A 和 B
  2. A 和 B 中间有一个可以拖拽的竖线 C,用鼠标拖拽 C 在 Wrap 内左右移动,可以根据 C 的位置调整 A 和 B 的宽度。

实现方法其实也是利用 js 的各种事件,但是它和上传文件不同,虽然字面上都是拖拽,但是这个功能利用的是鼠标事件,mousedown,mousemove,mouseup 等。

在开始之前,同样可以在 MDN 上复习一下关于鼠标事件的官方文档,MouseEvent,我们用到的事件有

  1. mousedown:拖拽元素 C 的鼠标按下事件,代表开始拖拽
  2. mousemove:可拖拽区域 Wrap 的鼠标移动事件,代表拖拽中
  3. mouseup: 可拖拽区域 Wrap 的鼠标放开事件,代表结束拖拽
  4. mouseleave: 可拖拽区域 Wrap 的鼠标离开事件,代表结束拖拽

鼠标事件中除了上述事件之外,还有 mouseenter、mouseleave、mouseout、mousewheel 事件是这个功能不会用到的。

注意到我们使用的这四个事件,是针对两个 dom 进行监听的,一个是拖拽元素 C ,一个是可拖拽区域的元素 Wrap。

2.1 手动计算分屏宽度

(1)基本步骤

假设我们有一个拖拽的分割线元素 C、一个可拖拽区域元素 Wrap,要实现手动计算分屏宽度,基本步骤如下:

  1. 监听 C 元素的 mousedown 事件,代表开始拖拽
  2. 监听 Wrap 元素的 mousemove 事件,代表正在拖拽中
  3. 监听 Wrap 元素的 mouseup 事件,代表结束拖拽
  4. 监听 Wrap 元素的 mouseleave 事件,代表结束拖拽(这是在处理边缘情况)

关于我们必须要监听 mouseleave 事件,是因为很多时候我们会鼠标一直按下然后移出可拖拽区域才放开,这个时候是不会触发 Wrap 的 mouseup 事件的,会导致一直处于拖拽中的情况,所以我们可以在 mouseleave 事件中把拖拽的状态设置为 false,表示已经结束拖拽了。

(2)代码

下面是使用 vue 实现的简单的调整分屏宽度的代码,这样写简单需求没什么问题,但是你会发现在 initDrag 里面有很多事件监听的代码。如果我们在很多页面都需要实现分屏的功能,这部分代码就需要复制很多遍,这很不优雅,所以我们完全可以把监听事件的方法封装成一个公共的类。

<template>
	<div class="drag-split-screen">
		<h1 class="title">拖拽调整 A B区域的宽度</h1>
		<p>是否在拖拽中: {
  
  { isDragging }}</p>
		<p>左侧 A 区域的宽度: {
  
  { leftWidth }}</p>
		<div ref="dragParent" class="container">
			<!-- 区域 A 的宽度指定 -->
			<div class="area area-a" :style="{ width: `${leftWidth}px` }">区域 A</div>
			<div ref="dragEle" class="drag"></div>
			<!-- 区域 B 的宽度自适应 -->
			<div class="area area-b">区域 B</div>
		</div>
	</div>
</template>
<script lang="ts" setup>
import { Ref, onMounted, ref } from 'vue'
const leftWidth = ref(200)
// 拖拽区域的父元素
const dragParent: Ref<HTMLElement | null> = ref(null)
const dragEle: Ref<HTMLElement | null> = ref(null)
const isDragging = ref(false)
const initDrag = () => {
	if (!dragEle.value || !dragParent.value) {
		return
	}
	// 区域 A 的左侧边缘,距离屏幕最右侧的偏移量
	const offsetX = dragParent.value.getBoundingClientRect().left
	// 监听拖拽元素的 mousedown事件,代表拖拽的开始
	dragEle.value.onmousedown = (evt: MouseEvent) => {
		evt.stopPropagation()
		evt.preventDefault()
		isDragging.value = true
	}
	// 监听 父元素 dragParent 的 mousemove 事件,代表拖拽中
	// 注意,监听的是 父元素 的而不是拖拽元素的,因为监听不到
	// 其实也可以监听 document,主要看需求
	// mousemove 可以增加 debounce 防抖
	dragParent.value.onmousemove = (evt: MouseEvent) => {
		evt.stopPropagation()
		evt.preventDefault()
		if (!isDragging.value) {
			return
		}
		//鼠标事件的 evt.x 鼠标当前是距离页面左边的距离
		leftWidth.value = evt.x - offsetX
		// 这里面还可以限制宽度的最小最大值
		// ...
	}
	// 监听document 的 mouseup 事件,代表拖拽结束
	dragParent.value.onmouseup = evt => {
		evt.stopPropagation()
		evt.preventDefault()
		isDragging.value = false
	}
	dragParent.value.onmouseleave = function (evt) {
		evt.stopPropagation()
		evt.preventDefault()
		isDragging.value = false
	}
}
onMounted(() => {
	initDrag()
})
</script>
<style lang="scss" scoped>
.drag-split-screen {
	padding: 24px 24px;
	.title {
		margin: 10px 0;
	}
	.container {
		width: 100%;
		height: 300px;
		display: flex;
		align-items: stretch;
		justify-content: center;
		border: 1px solid;
		.area {
			display: inline-flex;
			align-items: center;
			justify-content: center;
			width: 100%;

			&.area-a {
				flex-shrink: 0;
			}

			&.area-b {
				flex-grow: 1;
			}
		}
		.drag {
			width: 4px;
			height: 100%;
			flex-shrink: 0;
			background: #000;
			cursor: ew-resize;
		}
	}
}
</style>

    2.2 自己封装分屏方法

    自己封装一个分屏方法作为对 2.1 方法的优化,本质和原理都是一样的,只是可以提高代码的可复用性能。

    (1)基本步骤

    1. 定义一个公共类
    2. 将拖拽元素 dragEle 以参数的形式传入
    3. 将可拖拽区域元素 parentEle 以参数的形式传入
    4. 支持拖拽开始、拖拽中、拖拽结束事件回调

    (2)代码

    export class DragSplitScreen {
    	dragEle: HTMLElement | null = null
    	parentEle: HTMLElement | null = null
    	// 拖拽的回调函数
    	dragStart?: (left: number) => void
    	dragMove?: (left: number) => void
    	dragEnd?: () => void
    	constructor({
    		dragEle,
    		parentEle,
    		dragStart,
    		dragMove,
    		dragEnd,
    	}: {
    		dragEle: HTMLElement
    
    		parentEle?: HTMLElement
    		dragStart?: (left: number) => void
    		dragMove?: (left: number) => void
    		dragEnd?: () => void
    	}) {
    		// 如果不指定父元素,默认使用 document.body
    		this.parentEle = parentEle || document.body
    		// 拖拽的元素
    		this.dragEle = dragEle
    		this.addListener()
    		this.dragStart = dragStart
    		this.dragMove = dragMove
    		this.dragEnd = dragEnd
    	}
    	onDragStart(evt: MouseEvent) {
    		evt.stopPropagation()
    		if (this.dragStart) {
    			this.dragStart(evt.x)
    		}
    		if (!this.parentEle) {
    			return
    		}
    		this.parentEle.addEventListener('mousemove', this.handleDragMove, true)
    		this.parentEle.addEventListener('mouseup', this.handleDragEnd, true)
    	}
    	onDragMove(evt: MouseEvent) {
    		evt.stopPropagation()
    		if (this.dragMove) {
    			this.dragMove(evt.x)
    		}
    	}
    	onDragEnd(evt: MouseEvent) {
    		evt.stopPropagation()
    		if (this.dragEnd) {
    			this.dragEnd()
    		}
    		if (!this.parentEle) {
    			return
    		}
    		this.parentEle.removeEventListener('mousemove', this.handleDragMove, true)
    	}
    	handleDragStart = this.onDragStart.bind(this)
    	handleDragMove = this.onDragMove.bind(this)
    	handleDragEnd = this.onDragEnd.bind(this)
    
    	addListener() {
    		if (!this.dragEle || !this.parentEle) {
    			return
    		}
    		this.dragEle.addEventListener('mousedown', this.handleDragStart, true)
    	}
    }
    

    在 vue 中使用,我们可以发现相比于 2.1 小节,我们的 initDrag 方法中的代码量明显减少了,事件监听的方法都在类中,无需我们额外的处理。

    <template>
    	<div class="drag-split-screen">
    		<h1 class="title">封装成公共方法,拖拽调整 A B区域的宽度</h1>
    		<p>是否在拖拽中: {
      
      { isDragging }}</p>
    		<p>左侧 A 区域的宽度: {
      
      { leftWidth }}</p>
    		<div ref="dragParent" class="container">
    			<!-- 区域 A 的宽度指定 -->
    			<div class="area area-a" :style="{ width: `${leftWidth}px` }">区域 A</div>
    			<div ref="dragEle" class="drag"></div>
    			<!-- 区域 B 的宽度自适应 -->
    			<div class="area area-b">区域 B</div>
    		</div>
    	</div>
    </template>
    <script lang="ts" setup>
    import { Ref, onMounted, ref } from 'vue'
    import { DragSplitScreen } from './dragSplitScreen'
    const leftWidth = ref(200)
    // 拖拽区域的父元素
    const dragParent: Ref<HTMLElement | null> = ref(null)
    const dragEle: Ref<HTMLElement | null> = ref(null)
    const isDragging = ref(false)
    const dragInstance: Ref<DragSplitScreen | null> = ref(null)
    const initDrag = () => {
    	if (!dragEle.value || !dragParent.value) {
    		return
    	}
    	// 区域 A 的左侧边缘,距离屏幕最右侧的偏移量
    	const offsetX = dragParent.value.getBoundingClientRect().left
    	dragInstance.value = new DragSplitScreen({
    		dragEle: dragEle.value,
    		parentEle: dragParent.value,
    		dragStart: (left: number) => {
    			isDragging.value = true
    		},
    		dragMove: (left: number) => {
    			leftWidth.value = left - offsetX
    		},
    		dragEnd: () => {
    			isDragging.value = false
    		},
    	})
    }
    onMounted(() => {
    	initDrag()
    })
    </script>
    <style lang="scss" scoped>
    .drag-split-screen {
    	padding: 24px 24px;
    	.title {
    		margin: 10px 0;
    	}
    	.container {
    		width: 100%;
    		height: 300px;
    		display: flex;
    		align-items: stretch;
    		justify-content: center;
    		border: 1px solid;
    		.area {
    			display: inline-flex;
    			align-items: center;
    			justify-content: center;
    			width: 100%;
    
    			&.area-a {
    				flex-shrink: 0;
    			}
    
    			&.area-b {
    				flex-grow: 1;
    			}
    		}
    		.drag {
    			width: 4px;
    			height: 100%;
    			flex-shrink: 0;
    			background: #000;
    			cursor: ew-resize;
    		}
    	}
    }
    </style>
    

    本小结的源代码在项目中的 src/pages/drag/dragSplitScreenCustom.vue 文件夹中。

    2.3 拖拽分屏总结

    在拖拽调整分屏宽度的需求中,我们还有一些细节需要注意,比如拖拽中鼠标样式。

    一般来说在拖拽中我们需要给拖拽元素设置 cursor: col-resize 或者 cursor: ew-resize,如下的样式

    指示双向重新设置大小
    指示双向重新设置大小

    我们不仅要给拖拽的元素 C 设置这个样式,在拖拽中,我们还需要给可拖拽区域 Wrap 设置成这个样式,然后在拖拽结束给 Wrap 重置。

    三、悬浮按钮拖动移动位置

    悬浮按钮,就是指 position: fixed / absolute 的元素,一些网站的置顶按钮、重要操作按钮都是这样的,这些按钮有的可以在屏幕内随意拖动并放置,有的只可以上下拖动改变位置,这也是一个很常规的功能。

    3.1 悬浮按钮拖拽停在屏幕的任意位置

    停在屏幕的任意位置,说明这是一个被设置为 postition: fixed 的按钮,实现方式类似第 2 章的方法,利用鼠标的 mousedown + mousemove + mouseup + mouseleave 事件,同样需要监听两个元素,一个是悬浮按钮,一个是 document.body 。

    (1)代码

    看起来很简单,因为在这个需求中我们的可拖拽区域是整个屏幕,所以根本不需要考虑一些位置上的偏移。

    <template>
    	<div class="drag-page-container">
    		<h1 class="title">悬浮按钮拖拽停在屏幕的任意位置</h1>
    		<h2>正在拖拽: {
      
      { isDragging }}</h2>
    		<div ref="fixedBtnEle" class="fix-btn" :style="{ left: `${style.left}px`, top: `${style.top}px` }">drag</div>
    	</div>
    </template>
    <script lang="ts" setup>
    import { Ref, onMounted, ref } from 'vue'
    
    const fixedBtnEle: Ref<HTMLElement | null> = ref(null)
    const isDragging = ref(false)
    const style = ref({
    	left: 100,
    	top: 100,
    })
    const initDrag = () => {
    	if (!fixedBtnEle.value) {
    		return
    	}
    	let offsetX = 0
    	let offsetY = 0
    	fixedBtnEle.value.addEventListener('mousedown', evt => {
    		isDragging.value = true
    		// 鼠标开始移动的时候,相对于 drag 元素的边界的偏移量
    		offsetX = evt.x - style.value.left
    		offsetY = evt.y - style.value.top
    	})
    	document.body.addEventListener('mousemove', evt => {
    		if (!isDragging.value) {
    			return
    		}
    		// 在这里可以做一些边界值的处理,如:判断是否超出屏幕边界等
    		style.value = {
    			left: evt.x - offsetX,
    			top: evt.y - offsetY,
    		}
    	})
    	document.body.addEventListener('mouseup', () => {
    		isDragging.value = false
    	})
    	// 需要加一个 mouseleave 事件,否则当鼠标移动到页面外部之后不会触发 mouseup
    	// 导致再移回来的时候位置还会随鼠标变化
    	document.body.addEventListener('mouseleave', () => {
    		isDragging.value = false
    	})
    }
    
    onMounted(() => {
    	initDrag()
    })
    </script>
    <style lang="scss" scoped>
    .drag-page-container {
    	position: relative;
    	padding: 24px;
    	.title {
    		margin: 10px 0;
    	}
    	.fix-btn {
    		display: inline-flex;
    		align-items: center;
    		justify-content: center;
    		width: 50px;
    		height: 50px;
    		border-radius: 50%;
    		border: 1px solid;
    		background: red;
    		color: #ffff;
    		cursor: pointer;
    		position: fixed;
    		left: 100px;
    		top: 100px;
    		user-select: none;
    	}
    }
    </style>
    

    本小结的源代码在项目中的 src/pages/drag/dragFixedBtn.vue 文件夹中。

    3.2 悬浮按钮只能在指定区域移动

    相对更复杂也是更常见的需求是让一个按钮只能在指定区域移动,比如只能上下移动,这个时候我们使用同样的方法,然后再考虑一下可拖拽区域的边缘情况,和鼠标位置的偏移量即可。

    (1)代码

    在这种场景中,悬浮按钮是相对于可拖拽区域绝对定位的,所以计算位置的时候要减去拖拽区域的位置偏移,使用 getBoundingClientRect  方法获取可拖拽区域元素的偏移量。

    如果要求只能上下移动,那我们把可拖拽区域的宽度设置为和按钮宽度一样就行,只能水平移动同理。

    <template>
    	<div class="drag-page-container">
    		<h1 class="title">悬浮按钮,只能下面虚线区域内移动</h1>
    		<h2>正在拖拽: {
      
      { isDragging }}</h2>
    		<div ref="dragAreaEle" class="drag-area">
    			<div ref="fixedBtnEle" class="fix-btn" :style="{ left: `${style.left}px`, top: `${style.top}px` }">drag</div>
    		</div>
    	</div>
    </template>
    <script lang="ts" setup>
    import { Ref, onMounted, ref } from 'vue'
    const dragAreaEle: Ref<HTMLElement | null> = ref(null)
    const fixedBtnEle: Ref<HTMLElement | null> = ref(null)
    const isDragging = ref(false)
    const style = ref({
    	left: 0,
    	top: 0,
    })
    const initDrag = () => {
    	if (!fixedBtnEle.value || !dragAreaEle.value) {
    		return
    	}
    	// 区域的边界
    	const boundary = dragAreaEle.value.getBoundingClientRect()
    	let minX = boundary.left
    	let maxX = boundary.right
    	// 悬浮按钮的宽高
    	let fixedBtnWidth = fixedBtnEle.value?.clientWidth || 0
    	let fixedBtnHeight = fixedBtnEle.value?.clientHeight || 0
    	// 相对于 drag 元素的边界的偏移量
    	let offsetX = 0
    	let offsetY = 0
    
    	fixedBtnEle.value.addEventListener('mousedown', evt => {
    		isDragging.value = true
    		// 鼠标开始移动的时候,相对于 drag 元素的边界的偏移量
    		offsetX = evt.x - style.value.left - boundary.left
    		offsetY = evt.y - style.value.top - boundary.top
    	})
    	document.body.addEventListener('mousemove', evt => {
    		if (!isDragging.value) {
    			return
    		}
    		// 在这里可以做一些边界值的处理,如:判断是否超出屏幕边界等
    		let targetLeft = evt.x - offsetX - boundary.left
    		let targetTop = evt.y - offsetY - boundary.top
    		// 限制不能超出区域边界
    		targetLeft = Math.max(0, targetLeft)
    		targetLeft = Math.min(boundary.width - fixedBtnWidth, targetLeft)
    		targetTop = Math.max(0, targetTop)
    		targetTop = Math.min(boundary.height - fixedBtnHeight, targetTop)
    
    		// 这是悬浮按钮相对于区域的绝对定位的 left 和 top
    		style.value = {
    			left: targetLeft,
    			top: targetTop,
    		}
    	})
    	document.body.addEventListener('mouseup', () => {
    		isDragging.value = false
    	})
    	// 需要加一个 mouseleave 事件,否则当鼠标移动到页面外部之后不会触发 mouseup
    	// 导致再移回来的时候位置还会随鼠标变化
    	document.body.addEventListener('mouseleave', () => {
    		isDragging.value = false
    	})
    }
    
    onMounted(() => {
    	initDrag()
    })
    </script>
    <style lang="scss" scoped>
    .drag-page-container {
    	position: relative;
    	padding: 24px;
    	.title {
    		margin: 10px 0;
    	}
    	.drag-area {
    		margin-top: 40px;
    		width: 80vw;
    		height: 200px;
    		border-radius: 4px;
    		border: 1px dashed;
    		position: relative;
    	}
    	.fix-btn {
    		display: inline-flex;
    		align-items: center;
    		justify-content: center;
    		width: 50px;
    		height: 50px;
    		border-radius: 50%;
    		background: red;
    		color: #ffff;
    		cursor: pointer;
    		position: absolute;
    		left: 100px;
    		top: 100px;
    		user-select: none;
    	}
    }
    </style>
    

    本小结的源代码在项目中的 src/pages/drag/dragFixedBtnArea.vue 文件夹中

    四、拖拽排序

    拖拽排序包括按钮、菜单、文件等很多东西,这是个十分复杂的功能,因为我们不仅要处理各种位置计算,还需要增加动画使整个拖拽行为更加优雅。如果让我自己写,那可费劲了,好在我们有现成的库,vue 有一个 vuedraggable 的npm包,非常好用。

    Vue.Draggable 是一款基于 Sortable.js 实现的 vue拖拽插件

    拖拽排序功能,可以简单,也可以复杂,比如浏览器的标签页就是可以拖拽排序的,要做到这么优雅还是需要费点劲的。还有一些文档软件中,每个文档的顺序也是可以拖拽排序的,它不仅要实现功能,还要增加很多效果,比如指示可以放置位置的样式等,本篇文章只实现简单的功能,也就是 vuedraggable 这个包的基本使用方法。

    4.1 基本功能

    下图概括了使用 vuedraggable 由简单到复杂的情况,可以自己克隆代码然后看下效果

    4.2 代码

    <template>
    	<div class="container">
    		<h1>拖拽调整按钮的顺序</h1>
    		<h2>使用 vuedraggable npm包,安装 npm i vuedraggable@next 【这个是vue3版本】</h2>
    		<h2>1. 简单粗暴的版本</h2>
    		<ul>
    			<li>没有动画效果,拖拽中的时候页面上有两个正在拖拽中的元素</li>
    		</ul>
    		<draggable v-model="menuList" class="drag-box" item-key="id" :force-fallback="true">
    			<template #item="{ element }">
    				<div class="btn-item" :style="{ background: element.bgColor }">{
      
      { element.name }}</div>
    			</template>
    		</draggable>
    		<h2>2. 增加动画效果</h2>
    		<ul>
    			<li>增加动画效果</li>
    			<li>使用 animation 属性</li>
    		</ul>
    		<draggable v-model="menuList" class="drag-box" item-key="id" animation="200" :force-fallback="true">
    			<template #item="{ element }">
    				<div class="btn-item" :style="{ background: element?.bgColor || '' }">{
      
      { element.name }}</div>
    			</template>
    		</draggable>
    		<h2>3. 隐藏拖拽中元素的占位元素</h2>
    		<ul>
    			<li>使用 ghost-class 给拖拽元素的占位符增加 class,并设置透明度为 0 ,看起来就像隐藏了一样,还可以增加更多样式</li>
    		</ul>
    		<draggable v-model="menuList" class="drag-box" ghost-class="drag-ghost" item-key="id" animation="200" :force-fallback="true">
    			<template #item="{ element }">
    				<div class="btn-item" :style="{ background: element?.bgColor || '' }">{
      
      { element.name }}</div>
    			</template>
    		</draggable>
    		<h2>4. 隐藏拖拽中的元素</h2>
    		<ul>
    			<li>使用 drag-chosen 给拖拽中元素的增加 class, 并设置透明度为0,看起来就像隐藏了一样</li>
    			<li>下面的效果,看起来像是只能在一个固定的虚线区域拖拽</li>
    			<li>适用场景:类似浏览器多个标签页拖拽调整顺序</li>
    		</ul>
    		<draggable v-model="menuList" class="drag-box" :move="onDragMove" chosen-class="drag-chosen" item-key="id" animation="200" :force-fallback="true">
    			<template #item="{ element }">
    				<div class="btn-item" :style="{ background: element?.bgColor || '' }">{
      
      { element.name }}</div>
    			</template>
    		</draggable>
    	</div>
    </template>
    
    <script lang="ts" setup>
    import { onMounted, ref } from 'vue'
    import draggable from 'vuedraggable'
    
    const menuList = ref([
    	{
    		id: 1,
    		name: '菜单一',
    		bgColor: 'red', // 为了更好的看效果,每个项目加一个单独的背景色
    	},
    	{
    		id: 2,
    		name: '菜单二',
    		bgColor: 'pink',
    	},
    	{
    		id: 3,
    		name: '菜单三',
    		bgColor: 'green',
    	},
    	{
    		id: 4,
    		name: '菜单四',
    		bgColor: 'blue',
    	},
    ])
    onMounted(() => {})
    </script>
    <style lang="scss" scoped>
    .container {
    	padding: 24px;
    	h1 {
    		margin: 10px 0;
    		font-size: 20px;
    	}
    	h2 {
    		margin: 20px 0 10px;
    		font-size: 18px;
    	}
    	ul {
    		padding-left: 20px;
    		list-style: decimal;
    	}
    	.drag-box {
    		display: inline-flex;
    		align-items: center;
    		justify-content: flex-start;
    		flex-wrap: wrap;
    		width: 100%;
    		user-select: none;
    		border: 1px dashed;
    		padding: 10px;
    		position: relative;
    		.btn-item {
    			display: inline-flex;
    			align-items: center;
    			justify-content: center;
    			margin: 0 10px 10px 0;
    			width: 100px;
    			height: 40px;
    			color: #fff;
    			border-radius: 10px;
    			cursor: pointer;
    
    			&.drag-ghost {
    				opacity: 0; // 视觉上隐藏拖拽中的元素的占位元素,可以给占位符元素增加各种样式
    			}
    			&.drag-chosen.sortable-drag {
    				opacity: 0 !important; // 视觉上隐藏拖拽中的元素,可以给拖拽中的元素增加各种样式
    			}
    		}
    	}
    }
    </style>
    

    本小结的源代码在项目中的 src/pages/drag/dragSort.vue 文件夹中。

    五、拖拽将文件移动到文件夹

    虽然在第四章中我们有一个比较好用的包 vuedraggable 来处理我们的拖拽,但是它不是万能的,它的主要功能还是在拖拽 + 排序。

    我们之前提到过文件系统一般有一个文件列表拖拽排序的功能,其实还有另一个功能就是“拖拽将文件移动到文件夹”,文件列表中不仅有文件,还可能有文件夹,很多产品都支持直接拖拽文件把它拖到一个文件夹中,这个需求中有几个重要的点:

    1. 只有文件可以拖拽,文件夹不可以拖拽
    2. 拖拽中,只有文件夹可以放置

    可以克隆项目,然后看一下这个功能的简单版本

    5.1 基本思路

    要实现这个功能,我们需要用到和第一章“拖拽”上传类似的事件,其实实现方法也是类似,区别在于,“拖拽”上传,我们的【可拖拽元素】是位于电脑系统中的,所以我们没有用到 drag、dragstart、dragend事件,这一点我们之前也提到过。

    但是本节的功能却用到了这三个事件,因为本节功能的【可拖拽元素】是在同一个页面上的 dom。本节用到的所有事件如下

    1. dragstart:针对“文件”
    2. dragend:针对“文件”

    3. dragover:针对“文件夹”

    4. drop:针对“文件夹”

    此外还有 dragenter、dragleave 其实也可以用到,作为一些细节的优化点,但是我这里没有用到

    5.2 使用 dataTransfer 传递数据

    在 1.2 中我们只在 drop 事件中使用 dataTransfer 获取数据,但是我们其实还可以在 drag 或者 dragstart 事件中设置我们要传递的数据,然后再在 drop 事件中获取值。

    const onDragStart = (evt: DragEvent, item) => {
    	console.log('dragstart', evt, item)
    	if (!evt.target) {
    		return
    	}
    	// 获取拖拽中的元素 id
    	dragEleId.value = (evt.target as HTMLElement).getAttribute('data-id')
    	// 可以通过 dataTransfer 传值给 drop 事件
    	evt.dataTransfer?.setData('drag-id', `拖拽ID-${dragEleId.value}`)
    }

    5.3 代码

    下面的这些代码,其实并不是很完美,还有很多问题没有解决,比如拖拽中的文件、文件夹的样式怎么处理,我想在文件列表中隐藏拖拽中的“文件”,给它设置  opacity: 0 或者 display: none 都没有成功,都会影响拖拽事件。这个后面还得再看一下,该怎么处理,或者谁有解决办法,告诉我一下~~感谢~

    <template>
    	<div class="container">
    		<h1>拖拽将文件移动到文件夹</h1>
    		<ul>
    			<li>将文件拖拽,移动到文件夹中</li>
    			<li>拖拽中,将拖拽的元素在列表中设置特殊样式</li>
    			<li>拖拽中,除了拖拽中的元素,其他文件置灰</li>
    			<li>拖拽中,可放置的文件夹显示可放置的样式</li>
    		</ul>
    		<!-- 必须设置 dragover 或者dragenter才能 触发drop -->
    		<div class="file-box">
    			<div
    				v-for="item in fileList"
    				:key="item.id"
    				class="file-item"
    				:data-id="item.id"
    				:class="[
    					item.type,
    					{ 'is-draging': item.id == dragEleId },
    					{
    						'can-drop': item.type === 'folder' && dragEleId,
    					},
    					{
    						'can-not-drop': item.type !== 'folder' && dragEleId,
    					},
    				]"
    				:draggable="item.type !== 'folder'"
    				@dragstart="onDragStart($event, item)"
    				@dragend="onDragEnd"
    				@dragover="onDragOver($event, item)"
    				@drop="onDrop($event, item)"
    			>
    				<FileTextOutlined v-if="item.type == 'file'" />
    				<FolderOutlined v-else />
    				<div class="text">{
      
      { item.id }}-- {
      
      { item.name }}</div>
    			</div>
    		</div>
    	</div>
    </template>
    <script lang="ts" setup>
    import { FolderOutlined, FileTextOutlined } from '@ant-design/icons-vue'
    import { onMounted, ref } from 'vue'
    
    const fileList = ref([
    	{
    		id: 1,
    		type: 'file',
    		name: '如何学好前端',
    	},
    	{
    		id: 2,
    		type: 'folder',
    		name: '工具',
    	},
    	{
    		id: 3,
    		type: 'folder',
    		name: '集合',
    	},
    	{
    		id: 4,
    		type: 'file',
    		name: '怎么摸鱼',
    	},
    ])
    const dragEleId = ref()
    // 使用 dragstart 事件,不用 drag 事件,因为前者只触发一次,drag事件是持续触发的
    const onDragStart = (evt: DragEvent, item) => {
    	console.log('dragstart', evt, item)
    	if (!evt.target) {
    		return
    	}
    	// 获取拖拽中的元素 id
    	dragEleId.value = (evt.target as HTMLElement).getAttribute('data-id')
    	// 可以通过 dataTransfer 传值给 drop 事件
    	evt.dataTransfer?.setData('drag-id', `拖拽ID-${dragEleId.value}`)
    }
    const onDragEnd = (evt: DragEvent) => {
    	dragEleId.value = null
    	console.log('dragend')
    }
    
    // 必须给可以放置的元素,即“文件夹”,设置 dargover 事件,并且阻止默认事件,才会触发 drop 事件
    const onDragOver = (evt: DragEvent, item: any) => {
    	// drag over 必须设置 preventDefault,才能触发 drop 事件
    	evt.preventDefault()
    	console.log('dragover', evt)
    }
    
    const onDrop = (evt: DragEvent, item: any) => {
    	// 在 drop 事件的 dataTransfer 中接受值
    	const data = evt.dataTransfer?.getData('drag-id')
    	console.log('ondrop', data)
    	if (item.type !== 'folder') {
    		dragEleId.value = null
    		return
    	}
    	// 移除拖拽的元素,假装它已经放进了文件夹中,实际应用中可能调用接口之类的
    	const targetIndex = fileList.value.findIndex(item => Number(item.id) === Number(dragEleId.value))
    	if (targetIndex >= 0) {
    		fileList.value.splice(targetIndex, 1)
    	}
    	dragEleId.value = null
    }
    </script>
    <style lang="scss" scoped>
    .container {
    	padding: 24px;
    	user-select: none;
    	h1 {
    		margin: 10px 0;
    		font-size: 20px;
    	}
    	h2 {
    		margin: 20px 0 10px;
    		font-size: 18px;
    	}
    	ul {
    		list-style: decimal;
    		margin-left: 20px;
    	}
    	.file-box {
    		width: 100%;
    		height: 200px;
    		border: 1px solid #ccc;
    		border-radius: 4px;
    		.file-item + .file-item {
    			margin-top: 5px;
    		}
    		.file-item {
    			display: flex;
    			align-items: center;
    			justify-content: flex-start;
    			padding: 10px;
    			height: unset;
    			cursor: pointer;
    			&.can-drop {
    				border: 1px dashed red; // 可放置的(文件夹)样式
    			}
    			&.can-not-drop {
    				opacity: 0.5; // 不可放置的(文件类型)的样式
    				background: #fafafa;
    			}
    			&.is-draging {
    				// opacity: 0; // 不能设置透明度为0,否则鼠标下面也没有拖拽的元素了,可以自己试一下
    				opacity: 0.3;
    				color: blue;
    				// display: none; // 不能设置拖拽中的元素不展示,否则不能通过 dataTransfer传值,而且直接就出发dragend事件了
    			}
    			.text {
    				margin-left: 10px;
    			}
    		}
    	}
    }
    </style>
    

    总结

    本篇文章总结了前端开发过程中常见的 5 种关于拖拽的功能,基本涵盖了简单的系统需要的拖拽场景,后面如果有复杂的拖拽功能我会继续补充,文章内容很长,感谢您的耐心。

    文章源代码在 git 上可直接下载

    learn-vite: 搭建简单的vite+ts+vue框架https://gitee.com/yangjihong2113/learn-vite

    感谢大家阅读,欢迎关注,我们一起学习进步,我会持续更新前端开发相关的系统化的教程,新手建议关注我的系统化专栏《前端工程化系统教程》所有教程都包含源码,内容持续更新中希望对你有帮助。


    http://www.kler.cn/a/524850.html

    相关文章:

  1. 剑指 Offer II 007. 数组中和为 0 的三个数
  2. 网站如何正式上线(运维详解)
  3. 内网穿透实现MC联机
  4. vue-有关于TS与路由器
  5. 小程序-视图与逻辑
  6. 【物联网】ARM核常用指令(详解):数据传送、计算、位运算、比较、跳转、内存访问、CPSR/SPSR、流水线及伪指令
  7. 【AI论文】Omni-RGPT:通过标记令牌统一图像和视频的区域级理解
  8. 单机伪分布Hadoop详细配置
  9. 萌新学 Python 之数值处理函数 round 四舍五入、abs 绝对值、pow 幂次方、divmod 元组商和余数
  10. 利用飞书机器人进行 - ArXiv自动化检索推荐
  11. Java基础知识总结(二十六)--Arrays
  12. SpringBoot中@Valid与@Validated使用场景详解
  13. 生成模型:扩散模型(DDPM, DDIM, 条件生成)
  14. 2025年01月29日Github流行趋势
  15. 【hot100】刷题记录(6)-轮转数组
  16. [ASR]faster-whisper报错Could not locate cudnn_ops64_9.dll
  17. AI编译器之——为什么大模型需要Relax?
  18. 房屋租赁系统如何借助智能化手段提升管理效率与租客体验
  19. 剑指 Offer II 008. 和大于等于 target 的最短子数组
  20. 【2024年华为OD机试】(A卷,200分)- 查找树中元素 (JavaScriptJava PythonC/C++)
  21. 10.3 LangChain实战指南:解锁大模型应用的10大核心场景与架构设计
  22. 【C语言练习题】计算16位二进制数所表示的有符号整数
  23. 万物皆有联系:驼鸟和布什
  24. Github 2025-01-29 C开源项目日报 Top10
  25. TPA注意力机制详解及代码复现
  26. Linux pkill 命令使用详解