别再滚了 – 现在就要解决你的滚动穿透问题

浏览量:5,357

前言

日常 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 });
}

留下评论