移动端滑动穿透方案探索

在移动端的开发中,经常会遇到这样的场景,在弹出的浮层中进行指尖滑动时,浮层的底部也会跟着滑动,这真是个有趣的事情,不过有些产品还是会要求你改掉这个所谓的 BUG,对此,我搜索了些资料,更多的是其他应用的解决方案诸如 外卖饿了么 等,整理了几种解决方案

背景引入

浮层这个弹出式功能在当今的移动端设备中数见不鲜,给用户以一种突然显现式的视觉冲击,营造一种震撼效果和惊喜瞬间;站在开发者的角度,这种操作通过代码的形式有效利用了移动端屏幕的多维空间,营造一种自上而下式的层次分离感和上帝视角。既然是分离,上下层需要独立性,如果出现上层滑动带动下层一起滑起来了,那体验就是飞一般的感觉,噩梦一般。下面是菜单页H5点餐的体验,是不是有点僵硬,弹出购物车浮层,滑动浮层时,下面的菜单飘了;弹出 spu 浮层滑动时,下面的菜单页飘了。

问题分析

出现以上因滑动穿透导致底层也随之滚动的尴尬场景,究其原因就是所谓的冒泡事件在从中作怪。滑动引发 scroll 的冒泡事件从弹框层由内而外层层递进,一直延伸到 HTMl 层,最终导致 HTML 也触发了 scroll 事件,跟着一起滚动了起来。

方案探索

解决这个问题其实可以划分两个方向来解决,一个是阻止浮层滑动事件的穿透;一个是阻止底层的滚动事件。

浮层阻止滑动穿透

在浮层上阻止滑动事件,可防止这个事件冒泡穿透到更高的层级元素,最简单的方案是给弹框体绑定 touchmove 事件,并在事件执行函数中阻止滑动默认事件,而且这样并不会影响弹出层的事件点击。

阻止浮层 touchmove 事件

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

// 以react为例
// 页面结构
// <html>
// <body>
// <div id='app'>
// <AppContent />
// </div>
// </body>
// </html>

// appContent组件
class AppContent extends BaseComponent {
// 事件处理函数
forbidTouchMove(e) {
// 阻止默认事件
e.preventDefault();
}
render() {
return (
<div>
// 内容区
<div className='menu-layout'></div>
// 浮层区
<div className='alert-container' onTouchMove={() => this.forbidTouchMove()}>
<div className='alert-mask'></div>
<div className='alert-content'></div>
</div>
</div>
)
}
}

在 IOS 和 Android 设备上都能生效,但是当浮层区内有可滚动的元素,这种做法会导致可滚动的元素没办法滚动,因此需要对此方案进行优化。

阻止浮层 touchmove 事件优化

兼容滚动型浮层就不像简洁的阻止默认事件一行代码这么简单,那点餐的菜单购物车弹框模型来说,以封装通用组件为目标,依然是绑定浮层的 touch 事件,但是为了确定可滚动区的滚动方向以及局部变量在一次 touch 事件后充分释放,则需要绑定 touchStarttouchMovetouchEnd 全家桶;同时对于可滚动区需要加边缘检测,即滚动区滑到顶部再向上滑动或者滑到底部再向下滑也会触发滑动穿透现象。

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

// 以下滚动区均为垂直滚动区,水平滚动区和无滚动区的处理方式一样
export default function (container, selectorScrollable) {
// 数据缓存区
const data = {
initPosY: 0,
maxscroll: 0,
isAtTop: false,
isAtBottom: false
};

// 事件处理
$(container).on({
touchstart (e) {
const events = e.touches[0] || e;
const elTarget = $(e.target);
let elScroll = [];
// 不传selectorScrollable表示,浮层区内无滚动区
if (!selectorScrollable) { // 浮层区内无滚动区,不可elScroll = []; 防止浮层区点击事件被阻止
elScroll[0] = container;
} else if (elTarget.is(selectorScrollable)) { // 事件触发元素是否有滚动区
elScroll = elTarget;
} else if ((elScroll = elTarget.parents(selectorScrollable)).length == 0) { //事件触发元素父级检测是否有滚动区
elScroll = [];
}
// 浮层区无滚动区或者触发元素父级无滚动区
if (elScroll.length === 0) {
return;
}
// 滚动区元素
data.elScroll = elScroll;
// 初始垂直位置
data.initPosY = events.pageY;
// 滚动区可滚动的距离大小
data.maxscroll = elScroll[0].scrollHeight - elScroll[0].clientHeight;
},
touchmove (e) {
let canScrollThrough = false;
// 如果可滚动的距离为零,则阻止默认行为并返回
if (data.maxscroll <= 0) {
// 禁止滚动
e.preventDefault();
return;
}
// 获取滚动区元素
const elScroll = data.elScroll;
// 滚动区高度可变时,重新获取maxscroll
data.maxscroll = elScroll[0].scrollHeight - elScroll[0].clientHeight;
// 当前的滚动高度
const scrollTop = elScroll[0].scrollTop;
// 判断滑动方向
const events = e.touches[0] || e;
// 滑动方向,小于0是向上滑,大于0是向下滑
const distanceY = events.pageY - data.initPosY;
// 上下边缘检测,判断是否阻止默认事件
if (distanceY > 0 && scrollTop == 0) {
// 滑到顶
canScrollThrough = false;
data.isAtTop = true;
} else if (distanceY < 0 && (scrollTop + 1 >= data.maxscroll)) {
// 滑到底
canScrollThrough = false;
data.isAtBottom = true;
} else {
// 滚动条处于顶部和底部之间
if (!data.isAtTop && !data.isAtBottom) {
canScrollThrough = true;
} else {
canScrollThrough = false;
}
}
// 阻止默认事件
if (!canScrollThrough) {
e.preventDefault();
}
},
touchend () {
// 一次滑动后,初始化数据
data.isAtTop = false;
data.isAtBottom = false;
}
});
};

理论上,这样封装后,可完美兼容各机型,但是有点遗憾,在部分 Android 上还是会出现,对于滚动型浮层,当滚动区滚动到顶部或者底部撞击时会有轻微地滑动穿透。目前还没有找到问题根源,后续 Android 可能会对其进行优化。

底层阻止滚动

除了从根源处阻止滑动默认事件以避免事件穿透,从另一角度,即事件已经穿透,但是避免底层滚动同样可给人营造一种底层冰冻状态,避免底层滚动的方法不拘泥于一种,下面是其中几种行之有效的方案。

底层固定定位法

采用底层固定定位方案,当浮层弹出时,即设置浮层父级元素,推荐 body 元素固定定位并记录底层滚动位置以及 body 原 css 状态,当浮层消失时,将 body 恢复原状态及滚动位置,同时设置锁功能,保证浮层从隐藏 - 消失 - 隐藏的时间周期内,固定大法只被触发一次。

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

// 页面滚动模块
export const pageScrollModule = {
initBodyPosition: '', // 初始body的postion
initTopValue: '', // 初始body的top
initScrollTop: '', // 初始页面的滚动位置
key: false, // 控制锁,防止setForbidScroll到setInitScroll的执行周期内,这两个函数被重复调用,true为已锁住
bodyDom: document.getElementsByTagName('body')[0], // body元素缓存
// 允许body滚动
bodyScrollAllowed() {
// 判断模块是否已锁住
if(this.key) {
this.bodyDom.style.position = this.initBodyPosition; // 恢复body的position
this.bodyDom.style.top = this.initTopValue; //恢复body的top值
this.setScrollTop(this.initScrollTop); // 恢复页面的滚动位置
this.key = false; // 释放锁
}
},
// 禁止body滚动
bodyScrollFobidded() {
if(!this.key) {
this.initBodyPosition = this.bodyDom.style.position; // 缓存body的position
this.initTopValue = this.bodyDom.style.top; // 缓存body的top值
const scrollTop = this.getScrollTop(); // 获取页面滚动位置
this.setBodyFIxed(scrollTop); // body固定定位
this.initScrollTop = scrollTop; // 缓存页面滚动位置
this.key = true; // 锁住模块
}
},
setScrollTop(topValue) {
if (document.documentElement.scrollTop) {
document.documentElement.scrollTop = topValue;
} else if (window.pageYOffset) {
window.pageYOffset = topValue;
} else {
document.body.scrollTop = topValue;
}
},
getScrollTop() {
return document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop;
},
setBodyFIxed(topValue) {
this.bodyDom.style.position = 'fixed';
this.bodyDom.style.top = -topValue + 'px';
}
}

采用这个方法目前适用于大部分场景且 IOS 和 Android 都适用,虽然如此但会使整个 body 脱离文档流,未来的发展不可预测。

布局结构分离法

布局分离法即是在 HTML 结构做一定的设计,保证浮层尽可能贴近页面根元素,同时将页面所有内容置于根元素的子元素中如下。相关应用如美团酒旅,究其原理,则是如浮层区 alert-container 发生滑动穿透,它的父节点 .body#app 等会触发滚动事件,但是如果设置 html、body、#app.body 元素为视窗高度,而不是 document 高度,则这四个元素即使触发了滚动事件,但是因为高度的限制,它们并不会滚动,而由于所有文档流内容置于 .menu-layout 中,设置其 overflow 可滑动,即可保障页面内容在浮层消失时也是可以滑动的。

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

// 以react为例
// 页面结构
// <html>
// <body>
// // 页面根元素
// <div id='app'>
// <AppContent />
// </div>
// </body>
// </html>

// appContent组件
class AppContent extends BaseComponent {

render() {
return (
<div className='body'>
// 内容区,所有内容置于其中,除了各浮层区
<div className='menu-layout'></div>
// 浮层区1
<div className='alert-container'>
<div className='alert-mask'></div>
<div className='alert-content'></div>
</div>
// 浮层区2
<div className='alert-container'>
<div className='alert-mask'></div>
<div className='alert-content'></div>
</div>
</div>
)
}
}

设置相关CSS样式如下

1
2
3
4
5
6
7
8
9
10

html,body,app,body,menu-layout{
height: 100%;
}
app,html,body{
overflow: hidden;
}
menu-layout {
overflow: auto;
}

这种是代码量较少的较优方案,但是如果页面文档流中有锚点类功能,则需要在设置 .menu-layout 的滚动高度而非 html 抑或是 body 等,因为这几个元素的高度设置了 100%,将无法滚动,这也就是为啥需要一个 .menu-layout 元素来盛装所有文档流内容的原因。

固定高度滚动法

固定高度滚动大法是布局结构分离法的一个特例,但相关应用很多如美团外卖 H5 及其小程序,咱们的美团点评智慧餐厅小程序等,类似页面结构设计如下。这种方案是将整个页面文档 AppContent 设置为视窗高度,同时设置文档流中的可滚动区 MenuContent 为固定高度并设置超出时可滚动,关键一点是浮层不可是文档流中滚动区的子元素,即保证浮层和文档流的滚动区是分离状态,不相互依赖

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

// 以react为例
// 页面结构
// <html>
// <body>
// // 页面根元素
// <div id='app'>
// <AppContent />
// </div>
// </body>
// </html>

// appContent组件
class AppContent extends BaseComponent {

render() {
return (
<div className='body'>
// 内容区,所有内容置于其中,除了各浮层区
<div className='menu-layout'>
<Header />
<Other />
<MenuContent /> // 可滚动区
</div>
// 浮层区1
<div className='alert-container'>
<div className='alert-mask'></div>
<div className='alert-content'></div>
</div>
// 浮层区2
<div className='alert-container'>
<div className='alert-mask'></div>
<div className='alert-content'></div>
</div>
</div>
)
}

方案对比

以上给出了各个方向的可行方案,而针对不同的应用场景,需选择合适的优化方案。

方案项 代码量 性能 应用场景 缺点 优点 兼容性
阻止浮层 touchmove 事件优化 最多(JS) 最差 都可以 touch事件绑定较多,现阶段兼容性较差 暂无 IOS
阻止浮层 touchmove 事件 最少(JS) 较好 浮层没有可滚动区 不适用可滚动型浮层 代码较少,性能较好 IOS、Android
布局结构分离法 较少(CSS) 较好 都可以 需设计符合分离规则的布局结构 代码量较少,性能较好 IOS、Android
固定高度滚动法 最少(CSS) 最好 页面文档高度等于视窗高度 要求页面文档高度等于视窗高度,场景单一 代码量最少,性能最好 IOS、Android
底层固定定位法 中等(JS、CSS) 中等 都可以 浮层出现时,底层脱离文档流,会造成不可预测的定位关系 目前适用于任何布局的场景 IOS、Android

你的鼓励我会用来换杯奶茶~😁
0%