引言:这是个令人头疼并且及其常见的体验问题。
所谓滚动穿透,指我们滑动顶层的弹窗,但效果上却滑动了底层的内容。
什么情况会有该问题?
出现该问题的大前提:
- 整个webapp是设置为可以滚动的。
例如:vue-cli中包裹的最外层html/body没有设置
height:100%;overflow:hidden;
- 在手机上打开页面。(chrome上观测不到!!!
高维世界?)!!!Chrome的移动端调试模拟器是看不见任何问题的
本文提供的解决案例的框架为vue-cli,若您使用原生或者react也不要紧,原理是一模一样的。
具体原理分析如下:
- 1、改变顶层:从穿透的思路考虑,如果顶层不会穿透过去,就解决了。
- 2、改变底层:既然是顶层影响了底层,要是底层不会滚动,就解决了。
明白了以上的两种原理,其实就很好解决了。
明白了以上的两种原理,其实就很好解决了。
明白了以上的两种原理,其实就很好解决了。
有问题的原始代码和bug展示
代码如下:
<template>
<div class="wrap">
<div class="main">
<button @click="showDialog">出现吧弹窗</button>
</div>
<div class="dialog-wrapper" v-if="visible" @click="closeDialog">
<div class="dialog-content" @click.stop>
<header @click="closeDialog">隐藏弹窗</header>
<ul>
<li>一个元素,不需要滚动</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
visible: false,
};
},
created() {},
mounted() {},
methods: {
showDialog() {
this.visible = true
},
closeDialog() {
this.visible = false
}
}
};
</script>
<style scoped lang="less">
.wrap {
100%;
height: 100%;
background: #088a9e;
overflow: scroll;
border: 5px solid #089e8a;
.main {
100%;
height: 100%;
background: #eee;
}
.dialog-wrapper {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, .5);
.dialog-content {
position: fixed;
bottom: 0;
left: 0;
100%;
height: 300px;
overflow: scroll;
background: seagreen;
li {
margin-top: 10px;
100%;
height: 150px;
background: khaki;
}
}
}
}
</style>
bug效果如下:
情况一、若顶层弹窗本身不需要滚动(这种情况较为简单)
如果弹窗本身不需要滑动,那是非常简单的。
方法A、从让顶部不穿透的考虑触发,我们可以这么作修改
直接修改顶层弹窗的div,设置 @touchmove.prevent
即可.
<div class="dialog-wrapper" @touchmove.prevent v-if="visible" @click="visible = false">
实现后的效果如下:
方法B、从让底层不能滚动的考虑触发,我们可以这么作修改
我们在弹窗出现的时候,临时不让底部可以滚动;在弹窗消失的时候,再把底部可以滚动的功能加回去。
这里我们使用添加类名,使得底层临时不能滑动解决。
类名里面我们利用设置了position:fixed;
不会随屏幕滚动的原理。
css添加如下
.dialog-open {
position: fixed;
}
vue中给滚动的元素加上ref便于获取,再加上两个method用于添加类名/删除类名。问题解决
这里还是放出完整代码。
<template>
<div class="wrap" ref="scrollEl">
<div class="main">
<button @click="showDialog">出现吧弹窗</button>
</div>
<div class="dialog-wrapper" v-if="visible" @click="closeDialog">
<div class="dialog-content" @click.stop>
<header @click="closeDialog">隐藏弹窗</header>
<ul>
<li>一个元素,不需要滚动</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
visible: false,
};
},
created() {},
mounted() {},
methods: {
showDialog() {
this.visible = true
this.afterDialogOpen()
},
closeDialog() {
this.visible = false
this.afterDialogClose()
},
afterDialogOpen() {
this.$refs.scrollEl.classList.add('dialog-open')
},
afterDialogClose() {
this.$refs.scrollEl.classList.remove('dialog-open')
}
}
};
</script>
<style scoped lang="less">
.wrap {
100%;
height: 100%;
background: #088a9e;
overflow: scroll;
border: 5px solid #089e8a;
.main {
100%;
height: 100%;
background: #eee;
}
.dialog-wrapper {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, .5);
.dialog-content {
position: fixed;
bottom: 0;
left: 0;
100%;
height: 300px;
overflow: scroll;
background: seagreen;
li {
margin-top: 10px;
100%;
height: 150px;
background: khaki;
}
}
}
}
.dialog-open {
position: fixed;
}
</style>
效果其实基本一样。
情况二、若弹窗本身需要滚动
我们修改本文最上方的(未解决穿透时)的原始代码结构,仅添加多个li
<ul>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
</ul>
重现了穿透问题。
并且这里直接对父级采用 touchmove.prevent
是不可行的,因为弹窗本身需要滚动,若使用了,本身也滚不了了。
bug效果如下:
显然这里我们不能完全照抄情况一的方法A。否则整块元素都划不动了。
父级设置touchmove.prevent,其内的元素也是会受到影响的。
但解决原理是一样的。
但解决原理是一样的。
但解决原理是一样的。
既然父级会影响,那我搞个同级不就好了吗!
如下:我们多添加一层元素设为touchmove.prevent,同级的元素是不会影响的,利用z-index区分开来。
方法C:还是从改变顶层元素不让穿透的思想解决
方法A的优化升级版
下面是我们的部分修改方案(期间我们还会遇见一个问题,关于 touchomove 和 click 的问题)
<template>
<div class="wrap">
<div class="main">
<button @click="showDialog">出现吧弹窗</button>
</div>
<div class="dialog-wrapper" v-if="visible" @click="closeDialog">
<div class="dialog-no-touch-area" @touchmove.prevent.stop>
</div>
<div class="dialog-content" touchmove.stop @click.stop>
<header @click="closeDialog">隐藏弹窗</header>
<ul>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
visible: false,
};
},
created() {},
mounted() {},
methods: {
showDialog() {
this.visible = true
},
closeDialog() {
this.visible = false
}
}
};
</script>
<style scoped lang="less">
.wrap {
100%;
height: 100%;
background: #088a9e;
overflow: scroll;
border: 5px solid #089e8a;
.main {
100%;
height: 100%;
background: #eee;
}
.dialog-wrapper {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, .5);
.dialog-no-touch-area {
z-index: 100;
position: fixed;
100%;
height: 100%;
}
.dialog-content {
z-index: 200;
position: fixed;
bottom: 0;
left: 0;
100%;
height: 300px;
overflow: scroll;
background: seagreen;
li {
margin-top: 10px;
100%;
height: 150px;
background: khaki;
}
}
}
}
</style>
但是你最后会发现一个 bug:在我们滑动灰色遮罩的部分的时候,我们发现触发了click事件,但是我们想要区分touchmove连携的click
和正常的click
。
究其原因是:
在移动端,手指点击一个元素,会经过:touchstart --> touchmove -> touchend --> click
解决方案关键在于区分 click 事件和 touchmove 事件。
这里提供我的方法(借鉴于某个阅读器项目的代码),网上的方法没有找到合适的。
代码如下:
<template>
<div class="wrap">
<div class="main">
<button @click="showDialog">出现吧弹窗</button>
</div>
<div class="dialog-wrapper" v-if="visible" @click="closeDialog" @tap="closeDialog">
<div class="dialog-no-touch-area"
@touchstart="touchStart"
@touchend="touchEnd"
@touchmove.prevent.stop>
</div>
<div class="dialog-content" touchmove.stop @click.stop>
<header @click="closeDialog">隐藏弹窗</header>
<ul>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
<li>很多元素,需要滚动</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
visible: false,
// 用于检测是否是移动事件,通过间隔时间、间隔距离进行判断
touchStartX: 0,
touchStartTime: 0,
};
},
created() {},
mounted() {},
methods: {
showDialog() {
this.visible = true
},
closeDialog() {
console.log('click or tap')
this.visible = false
},
touchStart($event) {
this.touchStartX = $event.changedTouches[0].clientX
// this.touchStartTime = $event.timeStamp
},
touchEnd($event) {
const offsetX = $event.changedTouches[0].clientX - this.touchStartX
// const diffTime = $event.timeStamp - this.touchStartTime
// alert('差距时间' + diffTime)
// 判断什么情况下是touchMove,什么情况是click
if (Math.abs(offsetX) >= 20) {
$event.preventDefault()
$event.stopPropagation()
}
}
}
};
</script>
<style scoped lang="less">
.wrap {
100%;
height: 100%;
background: #088a9e;
overflow: scroll;
border: 5px solid #089e8a;
.main {
100%;
height: 100%;
background: #eee;
}
.dialog-wrapper {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, .5);
.dialog-no-touch-area {
z-index: 100;
position: fixed;
100%;
height: 100%;
}
.dialog-content {
z-index: 200;
position: fixed;
bottom: 0;
left: 0;
100%;
height: 300px;
overflow: scroll;
background: seagreen;
li {
margin-top: 10px;
100%;
height: 150px;
background: khaki;
}
}
}
}
</style>
完美解决。
方法D:按照改变底层元素的思想解决
我本以为方法B的代码,是可以通用在两种情况的,但经过几次测试发现并不行。
需要小小的改动一下。
其中会有一个问题,感觉就是聚焦的问题,当滑动了遮罩的部分,浏览器就聚焦在遮罩层了。
这个时候需要再聚焦回来才能流畅地滑动。
那么怎么解决呢!!!请看下方鄙人表演一个四两拨千斤
.dialog-wrapper {
touch-action: none;
}
给它的遮罩结构的类添加一个禁用浏览器所有平移、缩放手势的属性。并且查看MDN文档后,发现它不会被继承————即是说不会影响到我们需要滑动的子级。
这样就不存在上方说的什么聚焦(只是我的说法)了,它压根没法被触碰。
其他代码同方法B。仅多一句css。
完美解决。
参考文案
HTML DOM addEventListener() 方法
(MDN解释touch-action)[https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action]
compete.