前言
日常 web 开发中,我们经常遇到想要滚动某元素,但却导致了其他元素发生了滚动的问题。
比如我们某元素使用了浮层,当浮层无法滑动时,我们尝试滑动浮层,会发现浮层下的元素(通常来说是页面)发生了滚动,这就是滚动穿透。
而元素滚动到底时,再尝试滚动会导致元素的可滚动父级开始滚动,这就是滚动链(Scroll Chaining),如下 demo:
通常来说这并不是什么问题,尤其是在桌面端,但移动端的滚动会尤其让用户感觉奇怪。
如果你也遇到了这样的问题,那不妨从下面找一下解决方案。
原因
通常的滚动不是我们自己实现的,利用 transform 等模拟 scroll 的库也不会有滚动穿透的现象发生。
是因为子元素的 scroll 事件冒泡到根节点,导致根节点进行滚动了吗?
实际上并不是,在子元素上,scroll event 其实是不冒泡的,参见!(MDN)[https://developer.mozilla.org/en-US/docs/Web/API/Element/scroll_event]。而 document 上 的滚动是冒泡的,这就是为什么我们监听页面滚动时可以将监听事件挂载到 document 或 window 上。
那为什么会这样呢?我想滑动的明明是子元素,父级元素却滚动起来了,W3C 的规范 也没要求这样啊。
换个说法来说规范可能更好理解一些,规范不是用户手册,它只会规定了你要有哪些特性以及如何实现这些特性,但它不会规定你不要实现某些特性。而各大浏览器厂商不约而同的选择了这样的模式:当尝试滚动目标元素时,如果元素不能滚动,那就去尝试让它的父级元素进行滚动,哪个能滚滚哪个。
与此一致的还有滚动链(Scroll Chaining), 当滚动元素滚动到底部或顶部时,再进行滚动(PC 需要使用滚轮),会带动外层元素或页面继续滚动。
参考这个 codepen, 滚动.scroll-wrapper-2 元素到底时,继续滚动会使.scroll-wrapper-1 开始滚动,而.scroll-wrapper-1 滚动到底时,会带动页面滚动。
了解了原因,问题就好解决了。
如何解决
滚动穿透
当有 mask 时,我们把 document 变为不可滚动的即可。
通常情况下我们都可以直接给 body 添加 overflow: hidden 属性,这样别说滚动穿透,连页面的滚动都被限制了,自然不会产生滚动了(bootstrap 也是这样做的,可以看这个demo)。
再给想要可以滚动的元素(如弹窗)添加上相应的 overflow 属性值,这样弹窗上的元素也可以滚动。
通常来说滚动穿透的问题这样就可以解决了,哪怕是多层嵌套的问题,也可以套用这个模板来解决:只让允许的部分发生滚动。
滚动链(Scroll Chaining)
感谢 W3C,为我们带来了好用的overscroll-behavior 属性,我先来简单介绍一下它。
overscroll-behavior: [ contain | none | auto ]{1,2};
auto 作为默认值没什么好说的,重点在于 contain 与 none 值。
contain 属性保留了默认的滚动行为,但它不会传递滚动行为,就像我可以在目标元素内进行下拉刷新的操作,但不论如何滚动,滚动行为始终被局限在此元素内。
none 属性移除了滚动边界的一些行为,元素始终也不能超过其滚动边界,当然,它也把滚动行为限制到了元素内。
这两个属性主要在移动端的实际表现有所不同(PC 端通常没有元素需要跨越滚动边界的特性)。
喜大普奔,事情完美被解决了,如果你不需要兼容 Safari 的话。
连 IE 都可以使用-ms-scroll-chaining 来近似实现这一效果,但无论是桌面端还是移动端,Safari 目前都不支持这一特性,唯一让人感觉有希望的应该就是可以在 developer menu 中开启对它的支持,想必正式支持也快了吧。
如果说桌面端的 Safari 还可以忽略的话,iOS 的市占率决定了必须要对这一问题进行修复。对于 iOS 系统来说,它不允许浏览器使用其他内核,所以 Chrome 更像是套了一层 Safari 内核的壳,所以不管用什么浏览器,它都没有支持这一特性。
未来可能是美好的,但现在还是需要自己动手。
应用元素上的滚动事件并不能使用 preventDefault 来阻止滚动行为,scroll event 的 cancelable 属性是 false。也可以这样想,scroll 的效果在先,scroll event 在后,当 listener 拿到 event 时,它已经发生滚动了,总不能让元素再滚回来吧。
那我们就要从其他方向入手,比如是什么导致了滚动。
在桌面端,通常是用滚轮滚动、滑动触摸板与拖拽滚动条,而移动端,自然是Touch 相关的事件。
此处仅以移动端为例,桌面端将 touch 相关的事件换为Wheel 事件即可(注意MouseScrollEvent 已经逐渐被废弃)。
touch 事件是可以被 preventDefault 的,所以我们阻止越界滚动的思路是这样:
取消touch 效果,不发生滚动 <- 监听touch 事件来判断滚动方向,判断元素是否要越过元素边缘滚动 <- 判断touch 的对象,是否为要限制的元素
让我们从想到的效果开始,一步一步来看。
取消 touch 效果
touch 事件有 touchstart、touchend、touchmove 等事件类型,实际导致滚动的自然是 touchmove 事件,所以我们对元素增加事件监听器,在事件回调中判断 cancelable,然后执行 preventDefault:
targetEl.addEventListener(
'touchstart',
(event) => {
if (event.cancelable) {
event.preventDefault();
}
},
false
); // 注意passive 要设为false
判断元素是否要越过元素边缘滚动
touch 相关的事件并没有获取滑动方向的相关属性,所以我们需要自己计算滚动的方向。
可以记录 touchstart 事件的坐标,以它的坐标为原点进行计算。
为了简化代码及更清楚的说明相关逻辑,我们以最常用的上下滑动来举例:
let touchStartY;
const recordTouchStart = (event) => {
touchStartY = event.touches ? event.touches[0].screenY : event.screenY;
};
const preventTouchMove = (event) => {
let allowScroll = false;
const el = targetEl;
const currentTouchY = event.touches
? event.touches[0].screenY
: event.screenY;
const isOverTop = touchStartY <= currentTouchY && el.scrollTop === 0;
const isOverBottom =
touchStartY >= currentTouchY &&
el.scrollHeight - el.scrollTop <= el.offsetHeight;
if (!(isOverBottom || isOverTop)) {
allowScroll = true;
}
if (!allowScroll) {
if (event.cancelable) {
event.preventDefault();
}
event.stopPropagation();
}
};
if (
window &&
window.navigator &&
window.navigator.userAgent.match(/iPhone|iPad/)
) {
targetEl.addEventListener('touchstart', recordTouchStart, { passive: false });
targetEl.addEventListener('touchmove', preventTouchMove, { passive: false });
}
判断 touch 的对象,是否为要限制的元素
以上的代码其实已经可以解决滚动链的问题了,但如果有多个元素需要滚动时,多个监听器不好维护。同时 touch 事件也是可以跨元素的,触摸时也有可能出现问题。
最好的方法还是加上事件委托,做进一步的限制。
let touchStartY;
const recordTouchStart = (event) => {
if (targetElWrapper.contains(event.target)) {
touchStartY = event.touches ? event.touches[0].screenY : event.screenY;
}
};
const preventTouchMove = (event) => {
let allowScroll = false;
if (targetElWrapper.contains(event.target)) {
const el = targetEl;
const currentTouchY = event.touches
? event.touches[0].screenY
: event.screenY;
const isOverTop = touchStartY <= currentTouchY && el.scrollTop === 0;
const isOverBottom =
touchStartY >= currentTouchY &&
el.scrollHeight - el.scrollTop <= el.offsetHeight;
if (!(isOverBottom || isOverTop)) {
allowScroll = true;
}
}
if (!allowScroll) {
if (event.cancelable) {
event.preventDefault();
}
event.stopPropagation();
}
};
if (
window &&
window.navigator &&
window.navigator.userAgent.match(/iPhone|iPad/)
) {
document.addEventListener('touchstart', recordTouchStart, { passive: false });
document.addEventListener('touchmove', preventTouchMove, { passive: false });
}