JS实现手势下拉出现二楼广告功能

原始诉求及解决的问题

产品的原始需求是:

1、手势下拉时,出现二楼广告
2、有强运营广告时,不需手指触发,直接开屏展现

用于尝试解决首页 banner 点击转换率低,大型运营活动效果弱的问题。实现功能截图及视频如下:


解决思路

后面决定用( css3translate) + (手势滑动)来解决。主要需要解决以下问题:

1、监听手势滑动

当满足
1)手指下滑
2)页面在最顶部
3)当前未展示二楼
4)手指下滑移动距离超过一定的距离
可下滑展示二楼

当满足
1)手指上滑
2)当前已展示二楼
可上滑收回二楼

2、滑动效果

整个页面 dom 分为三个部分:
1)二楼 dom 区域 (黄色区域)
2)触摸区域 (蓝黑区域)
3)页面主体部分 (红色部分)

二楼和页面主体这两个部分会进行 translateY 移动, 监听手指滑动的事件绑定在触摸区域上。

之所以不把触摸区域放在整个页面,是因为二楼的下拉和收回底部区域一定不可能触发,因此只需要再页面上半部分绑定即可

整个滑动分为三种情况(以下x为二楼的高度)
1、当页面最开始时, 二楼 translateY(-x), 主体部分 translateY(0)
2、当手指向下滑动 y 距离时,二楼 translateY (-x + y), 主体部分 translateY(y)
3、当手指松开时,判断 y 的距离是否超过一定距离(设置的moveOffset)

若超过,则证明滑动距离足够大,此时二楼 translateY(0),而主体部分 translate(x)
若不超过,则证明滑动距离比较小(也可能是误触),此时二楼不下拉展示,回到原来的距离。即二楼 translateY(-x), 主体部分 translateY(0)


核心实现

首先根据功能,编写类方法,类方法里主要要传入以上提及到的多个元素:

1
2
3
4
5
6
7
8
9
10
11
12
this.secondFloorInstance = new secondFloor({
// 二楼区域
secondFloorWrap,
// 主体区域
contentWrap,
// 触摸区域
touchWrap,
// 下拉成功回调
onPullDownSucc: () => {
this.onPullDownSuccCallback()
}
})

类里面实现以下几个功能:

移动元素的方法

移动元素及动画,使用 css 的 transform translate 即可。有了移动元素的动画方法,随后就可以就可以根据上面的思路解决下拉的动画和收回的动画。

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
class SecondFloor{
constructor(options) {
// 处理 options 的判断
// 绑定事件
// ....

// 初始化变量
this.secondFloorShowing = false
}

/**
* 移动 dom 元素 y 轴方向的位置
* @param el 需要移动的元素
* @param y y轴方向移动的距离
* @param duration 动画时间
*/
translateY(el, y = 0, duration = 0) {
if (!el) {
return
}

el.style.transform = `translate(0px, ${y}px) translateZ(0px)`
el.style.webkitTransform = `translate(0px, ${y}px) translateZ(0px)`

el.style.transitionDuration = `${duration}ms`
el.style.webkitTransitionDuration = `${duration}ms`
}

// 显示二楼
showSecondFloor() {
let transitionDuration = this.options.transitionDuration

this.translateY(this.secondFloorWrap, 0, transitionDuration)
this.translateY(this.contentWrap, this.secondFloorWrapHeight, transitionDuration)

this.secondFloorShowing = true
}

// 隐藏二楼
hideSecondFloor() {
let transitionDuration = this.options.transitionDuration

this.translateY(this.contentWrap, 0, transitionDuration)
this.translateY(this.secondFloorWrap, this.secondFloorWrapHeight * -1, transitionDuration)

this.secondFloorShowing = false
}
}

使用 translateY 时,注意要加上硬件加速,GPU 中的 transform 等 css 属性不会触发重绘。整体看起来二楼下滑和收回会更加流畅。

1
2
-webkit-transform: translateZ(0);
transform: translateZ(0);

手势处理

当动画处理后,后面最重要的就是手势的处理,手势的处理需要注意边界条件

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
132
133
134
135
136
137
138
139
140
141
142
143
144
145

// 设置手指移动的方向
const MOVE_DIRECTION = {
INIT: "init",
UP: "up",
DOWN: "down"
}

Object.freeze(MOVE_DIRECTION)

class SecondFloor{
constructor() {
// ...

this.startX = 0
this.startY = 0
this.direction = MOVE_DIRECTION.INIT

// 是否符合触摸条件的标识
this.isTouching = false

// 当前高度
this.initialScrollTop = 0

// 处理下拉逻辑
this.bindTouchStartEvent()
this.bindTouchMoveEvent()
this.bindTouchEndEvent()
}

/**
* 绑定手指触摸事件
*/
bindTouchStartEvent() {
this.touchWrap.addEventListener("touchstart", e => {
this.initialScrollTop = this.getScrollTop()

// 只有在最顶部的时候或在二楼展示状态下才需要去绑定手势事件
if (this.initialScrollTop <= 0 || this.secondFloorShowing) {
this.isTouching = true
this.startX = (e.touches && e.touches[0].pageX) || e.clientX
this.startY = (e.touches && e.touches[0].pageY) || e.clientY
}
}, this.testSupportPassive() ? { passive: true } : false)
}

/**
* 绑定手指移动事件
*/
bindTouchMoveEvent() {
this.touchWrap.addEventListener("touchmove", e => {
if (!this.isTouching) {
return
}

let currentX = (e.touches && e.touches[0].pageX) || e.clientX
let currentY = (e.touches && e.touches[0].pageY) || e.clientY

let xDiff = currentX - this.startX
let yDiff = currentY - this.startY

// x 的绝对值大于 y 的绝对值,说明是左右滑动,阻止默认行为
if (Math.abs(xDiff) > Math.abs(yDiff)) {
this.preventDefault(e)
return
}

// 如果y轴差值大于0,说明是向下滑动
if (yDiff > 0) {
if (this.secondFloorShowing) {
this.preventDefault(e)
return
}

// 阻止默认滚动行为,此处只需要动画即可
if (this.initialScrollTop <= 0) {
this.preventDefault(e)
this.direction = MOVE_DIRECTION.DOWN

this.translateY(this.secondFloorWrap, Math.abs(yDiff) - this.secondFloorWrapHeight)
this.translateY(this.contentWrap, Math.abs(yDiff))
}
} else {
// 差值小于0,则是向上滚动,此时收回二楼,且阻止默认滚动行为,只需要再touchend时动画
if (this.secondFloorShowing) {
this.preventDefault(e)
this.direction = MOVE_DIRECTION.UP
}
}
})
}

/**
* 绑定手指松开时的事件
* 松开时就判断移动距离,改收回就收回,改展示二楼就展示二楼
*/
bindTouchEndEvent() {
this.touchWrap.addEventListener("touchend", e => {
this.initialScrollTop = 0

if (!this.isTouching) {
return
}

if (this.direction === MOVE_DIRECTION.DOWN) {
this.handleMoveDownEndEvent(e)
} else if (this.direction === MOVE_DIRECTION.UP) {
this.handleMoveUpEndEvent(e)
}

this.startX = null
this.startY = null
this.isTouching = false
})
}


/**
* 处理用户下拉的时候的操作
* @param e
*/
handleMoveDownEndEvent(e) {
let currentY = (e.changedTouches && e.changedTouches[0].pageY) || e.clientY
let diff = currentY - this.startY

// 当距离超过了设置的值
if (diff > this.options.moveOffset) {
this.showSecondFloor()
this.options.onPullDownSucc && this.options.onPullDownSucc()
} else {
this.hideSecondFloor()
}

this.direction = MOVE_DIRECTION.INIT
}

/**
* 手指上滑动事件
*/
handleMoveUpEndEvent() {
this.hideSecondFloor()

this.direction = MOVE_DIRECTION.INIT
}
}

整个手势滑动,处理时比较重要的就是边界处理,比如如果不是在最顶部,那就不能触发下拉。比如如果二楼已经出现,再下拉不能再触发二楼动画。再比如能触发上滑一定是能只能在二楼展开的时候。


其他

test passive


总结

这里功能不复杂,但是做起来还是花了点时间,主要是这里需要着重处理下边界。