Vue 封装一个移动端拖拽组件和自定义指令

公司项目的上一个版本中,应用内的消息提醒有一个这样的小气泡:

之前这个气泡是固定的,但在新版本的开发中,固定的气泡会阻碍一些按钮的交互,因此跟产品沟通了一下,决定为这个气泡加入沿屏幕右侧纵向拖拽的功能。

在网上找了几个觉得都不太满意。于是结合在网上找到的,尝试自己封装一个公共组件,便于以后类似功能的拓展。

需求分析

  • 可拖拽

  • 边界检测

    默认为屏幕宽高;如有父级元素则为父级元素宽高;可自行设置边界

  • 可设置拖拽方向 –> 横轴移动 / 纵轴移动 / 自由拖拽,默认为自由拖拽

  • 点击浮动的气泡触发下一步操作

怎么使用?

一开始想的是组件式使用,后来,鉴于拖拽都是直接操作DOM 元素,且组件式使用不够灵活,采用了自定义指令的方式。

但还是将两种使用方式都记录下来:

  • 组件式

    1
    2
    3
    <v-drag :x-move="true">
    需拖拽元素
    </v-drag>
  • 自定义指令

    1
    2
    3
    <div v-drag @click="fn"></div>

    <div v-drag="options"></div>
    1
    2
    3
    4
    5
    6
    7
    8
    options:{
    XMove: false,
    YMove: true
    maxWidth: 300,
    maxHeight: 100,
    minWidth: 10,
    minHeight: 10
    }

效果预览

为方便演示,均监听了鼠标事件,用于移动端时可将鼠标事件的监听注释。

组件式只能设置拖拽方向

遇到的问题及解决

滑动时警告问题

将touch事件绑定给document后,发现拖拽元素时页面也会滚动,并且出现了如下警告

1
2
[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. 
See https://www.chromestatus.com/features/5093566007214080

Google了一下后知道,原来移动端chrome出了一个新特性Passive Event Listeners,不会调用 preventDefault 函数来阻止事件的默认行为,这样浏览器就能快速生成事件,从而提升页面性能。

解决办法1:

在touch的事件监听方法上绑定第三个参数{ passive: false },
通过传递 passive 为 false 来明确告诉浏览器:事件处理程序调用 preventDefault 来阻止默认滑动行为。

1
2
3
4
5
elem.addEventListener(
'touchstart',
fn,
{ passive: false }
);

解决办法2:

1
2
* { touch-action: pan-y; } 
// 使用全局样式样式去掉

最终代码

组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
<!-- 拖拽滑动 -->
<template>
<div id="defaultDragComp"
ref="defaultDragComp"
@click.stop="goNext"
@touchstart="down"
>
<slot></slot>
</div>
</template>

<script>

export default {
name: 'drag-comp',
props: {
XMove: {
type: Boolean,
default: true
}, // 是否能在x轴拖拽
YMove: {
type: Boolean,
default: true
} // 是否能在y轴拖拽
},
data() {
return {
defaultDragComp: null,
dragging: false,
position: {x: 0, y: 0},
maxW: 0,
maxH: 0
}
},
mounted() {
this.initDragComp()
},
methods: {
// 获取拖拽元素及边界
initDragComp() {
const defaultDragComp = this.$refs.defaultDragComp
this.defaultDragComp = defaultDragComp
this.maxW = window.innerWidth - defaultDragComp.offsetWidth
this.maxH = window.innerHeight - defaultDragComp.offsetHeight
},
// 点击拖拽元素的下一步操作
goNext(done) {
this.$emit('goNext')
},
// 监听鼠标手指触碰事件,记录初始位置
down(event) {
document.addEventListener('touchmove', this.move)
document.addEventListener('touchend', this.end)


let {offsetLeft, offsetTop} = this.defaultDragComp
this.dragging = true
let touch
if (event.touches) {
touch = event.touches[0]
} else {
touch = event
}
let {clientX, clientY} = touch
this.position = {
x: clientX - offsetLeft,
y: clientY - offsetTop
}
},
// 监听document手指移动事件,更新元素位置,边界检测
move(event) {
let {
defaultDragComp,
dragging,
XMove,
YMove,
position,
maxW,
maxH
} = this
event.preventDefault()

if (dragging) {
let touch
if (event.touches) {
touch = event.touches[0]
} else {
touch = event
}
let {clientX, clientY} = touch

let deltaX = clientX - position.x
let deltaY = clientY - position.y

// 边界检测
if (deltaX < 0) {
deltaX = 0
} else if (deltaX > maxW) {
deltaX = maxW
}
if (deltaY < 0) {
deltaY = 0
} else if (deltaY > maxH) {
deltaY = maxH
}
if (XMove) {
defaultDragComp.style.left = deltaX + 'px'
}
if (YMove) {
defaultDragComp.style.top = deltaY + 'px'
}
}
},
// 手指释放时,移除全局监听事件
end() {
this.dragging = false
document.removeEventListener('touchmove', this.move)
document.removeEventListener('touchend', this.end)
}
}
}
</script>

<style lang="less">
#defaultDragComp {
position: fixed;
z-index: 1010;
}

</style>

自定义指令

drag.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108

export default {
inserted(el, bindings) {
let dragging = false
let position = {x: 0, y: 0}
el.addEventListener('touchstart', down, {passive: false})

// 获取配置项,设置默认值
let options = bindings.value
let {XMove, YMove} = options
let canXMove = XMove || true
let canYMove = YMove || true

// 获取边界
let {maxW, maxH, minW, minH} = getBoundary(el, options)

// 判断是否存在移动端touch事件,获取事件对象
function getEvent(event) {
let touch
if (event.touches) {
touch = event.touches[0]
} else {
touch = event
}
return touch
}

// 手指触碰
function down(event) {
document.addEventListener('touchmove', move, {passive: false})
document.addEventListener('touchend', end, {passive: false})

let {offsetLeft, offsetTop} = el
dragging = true
let touch = getEvent(event)
let {clientX, clientY} = touch
position = {
x: clientX - offsetLeft,
y: clientY - offsetTop
}
}

// 手指移动
function move(event) {

if (dragging) {
let touch = getEvent(event)
event.preventDefault()
let {clientX, clientY} = touch

let deltaX = clientX - position.x
let deltaY = clientY - position.y


if (deltaX < minW) {
deltaX = minW
} else if (deltaX > maxW) {
deltaX = maxW
}
if (deltaY < minH) {
deltaY = minH
} else if (deltaY > maxH) {
deltaY = maxH
}
if (canXMove) {
el.style.left = deltaX + 'px'
}
if (canYMove) {
el.style.top = deltaY + 'px'
}
}
}

// 手指释放
function end() {
dragging = false
document.removeEventListener('touchmove', move)
document.removeEventListener('touchend', end)
}
}
}

// 获取边界
// 优先级:自定义设置 > 带定位父级元素 > 屏幕
function getBoundary(el, options) {
const parentNode = el.offsetParent
let {maxWidth, maxHeight, minWidth, minHeight} = options
let deltaW = window.innerWidth
let deltaH = window.innerHeight
let minW = minWidth || 0
let minH = minHeight || 0
if (parentNode) {
let {width, height} = parentNode.getBoundingClientRect()
deltaW = width
deltaH = height
}
let maxW = (maxWidth || deltaW) - el.offsetWidth
let maxH = (maxHeight || deltaH) - el.offsetHeight


return {
maxW,
maxH,
minW,
minH,
}
}

main.js

注册全局指令

1
2
3
4
// 引入指令
import vDrag from '@/directive/drag'

Vue.directive('drag', vDrag)

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<template>
<div>
<div class="parent">
<div class="drag-comp" v-drag="options" @click.stop="fn1">
</div>
</div>
<div class="drag-comp fixed" v-drag="options">fixed</div>
</div>
</template>

<script>

export default {
data() {
return {
options: {
// maxWidth:200
}
};
},
methods: {
fn1() {
console.log('click');
},
}
}
</script>

<style lang="less">
.parent {
position: relative;
width: 200px;
height: 300px;
border: 1px solid deepskyblue;
}

.drag-comp {
position: fixed;
z-index: 99;
right: 10px;
bottom: 85px;
width: 40px;
height: 40px;
box-shadow: 0 2px 4px #ddd;
border-radius: 50%;
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
background-color: deepskyblue;

&.fixed {
position: absolute;
background: skyblue;
}
}
</style>