实现滚动阴影效果的多种方案对比

1,301 阅读6分钟

背景

最近在做业务需求的时候,碰到了需要根据列表的滚动状态动态添加顶部和底部阴影的场景(见下图)。最开始笔者通过使用IntersectionObserver观测顶部和底部元素在视窗中的显示状态,动态增删阴影样式,来实现滚动阴影效果。但后面又发现通过CSS也能够实现同样的效果。因此,笔者在此尝试汇总归纳滚动阴影效果的CSS和JS的多种实现方案,作为参考。

Feb-19-2023 21-04-26.gif

实现方案

CSS方案

CSS实现滚动阴影效果的核心是利用DOM元素渲染的层级和定位规则,即通过组合一个Z轴层级较高且会随滚动条滚动的遮挡样式,以及一个Z轴层级较低但不会随滚动条滚动的阴影样式,使得滚动时遮挡样式移走,阴影样式固定不动,从而实现滚动阴影效果。

具体来说,可以通过 (多重背景 + background-attachment),(position:absolute + sticky) 来实现上述效果。

background-attachment 实现滚动阴影

CSS background支持为DOM元素设置多重背景,其中先设置的背景样式Z轴层级高于后设置的样式。因此我们可以利用它让遮挡样式和阴影样式共存于包含元素上。

background-attachment 允许我们设置背景和内容、元素以及视口之间的关联关系。这里我们主要用到 local 和 scroll 两个值,其中 local 指定背景随内容滚动,并将渲染区域设置为内容可滚动区域而不是包含元素的边框范围(保证遮挡元素的位置正确);而scroll则指定背景固定在包含元素的边框范围,并不随内容滚动而滚动。

image.png

利用上面两个CSS规则,我们就可以很方便的实现阴影滚动效果了,比如这样:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>滚动阴影实践</title>
    <style>
        .list-container {
            display: inline-block;
            width: 300px;
            height: 300px;
            overflow: scroll;
            border: 1px solid red;
            padding-top: 10px;
        }
        .list-container > div {
            padding: 6px;
            /* background-color: aqua; */
            border: 1px solid gray;
            margin: 0 6px 10px 6px;
        }
        .attachment {
            background: linear-gradient(#fff, transparent),
                        linear-gradient(rgba(0, 0, 0, 0.5), transparent),
                        linear-gradient(to top, #fff, transparent),
                        linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
            background-size: 100% 50px, 100% 10px, 100% 50px, 100% 10px;
            background-position: top, top, bottom, bottom;
            background-repeat: no-repeat;
            background-attachment: local, scroll, local, scroll;
        }
    </style>
</head>
<body>
    <h2>background-attachment</h2>
    <div class="list-container attachment">
        <div>123</div>
        <div>123</div>
        <div>123</div>
        <!-- 省略一堆div..... -->
    </div>
</body>
</html>

效果如下:

Feb-19-2023 12-22-57.gif

不过这种方法存在一个问题,由于父DOM的Z轴层级位于子DOM之下,因此当子元素也设置了背景等颜色样式时,阴影效果会被覆盖,如下图所示:

Feb-19-2023 12-24-43.gif

因此,为了避免该问题,我们需要额外引入一个和子元素同级的阴影元素,模拟滚动阴影效果。也就是下面的第二种CSS方法。

absolute + sticky 实现滚动阴影

利用css position样式也能够实现滚动阴影效果。具体来说,需要新增一个遮挡元素和一个阴影元素,并为遮挡元素加上absolute令其随内容滑动且不占据空间,阴影元素加上sticky令其不占空间且始终显示在包含元素最上方。这样,当遮挡元素随内容向上滚动后,就会露出阴影元素,从而实现滚动阴影效果。这部分的代码如下所示:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>滚动阴影实践</title>
    <style>
        .list-container {
            display: inline-block;
            position: relative;
            width: 300px;
            height: 300px;
            overflow: scroll;
            border: 1px solid red;
            padding-top: 10px;
        }
        .list-container > div:not(.other) {
            padding: 6px;
            background-color: aqua;
            border: 1px solid gray;
            margin: 0 6px 10px 6px;
        }
        .top-shelter {
            position: absolute;
            width: 100%;
            height: 25px;
            background: linear-gradient(#fff, transparent);
            z-index: 1;
            top: 0;
        }
        .top-shadow {
            position: sticky;
            width: 100%;
            height: 10px;
            background: linear-gradient(rgba(0, 0, 0, .5), transparent);
            top: -10px;
            margin-top: -10px;
        }
    </style>
</head>
<body>
    <h2>position实现</h2>
    <div class="list-container">
        <div class="other top-shelter"></div>
        <div class="other top-shadow"></div>
        <div>123</div>
        <div>123</div>
        <div>123</div>
        <!-- 省略一堆div..... -->
    </div>
</body>
</html>

如下图所示,可以看到此时阴影效果不会再被子元素背景所遮挡了。

Feb-19-2023 16-31-59.gif

然而,当我们想要用同样的方法实现底部阴影效果时,会发现存在一些问题:

  • absolute设置bottom是相较于边框范围而不是可滚动内容范围,不设bottom能够让元素位于原处但会导致可滚动区域被该元素拉长。
  • 不为遮挡元素设置bottom时,遮挡元素扩展的可滚动区域对于阴影元素来说无法感知 ,导致滑动到底时两者无法在底部重合起到遮挡效果。

因此,我们在设置底部阴影的时候,还需要利用负外边距的特性,解决上述可滚动区域拉长和底部无法重合的问题。我们需要新增的代码为:

// 新增样式
.bottom-shelter {
    position: absolute;
    width: 100%;
    height: 25px;
    background: linear-gradient(to top, #fff, transparent);
    z-index: 1;
    margin-top: -25px;
}
.bottom-shadow {
    position: sticky;
    width: 100%;
    height: 10px;
    background: linear-gradient(to top, rgba(0, 0, 0, .5), transparent);
    bottom: 0;
    margin-top: -10px;
}

// 新增DOM
<div class="list-container">
    <div class="other top-shelter"></div>
    <div class="other top-shadow"></div>
    <div>123</div>
    <div>123</div>
    <div>123</div>
    
    // 新增部分
    <div class="other bottom-shelter"></div>
    <div class="other bottom-shadow"></div>
</div>

加上上述代码后,我们就可以看到底部滚动阴影效果了:

Feb-19-2023 20-22-16.gif

然而,我们也可以看到该方案存在的问题:

  • 使用线性渐变来设置遮挡元素颜色,会导致阴影元素即便被遮挡也会对顶部和底部背景造成影响。特别地,当子元素数量较少无需滚动时,上述问题也仍然存在。
  • 不使用渐变,改为纯白色遮挡元素的话,在其与阴影元素部分重合的时候,也会存在阴影消失不平滑的视觉问题。

综上,我们可以发现CSS方案能够比较方便的实现滚动阴影效果,但其实现结果在视觉感知上并不完美。

基于此,下面我也将尝试基于JS来实现滚动阴影效果,以解决上述CSS方案的视觉感知问题。

JS方案

JS实现滚动阴影效果的核心是通过监听包含元素滚动状态,动态修改阴影DOM可见性来实现滚动阴影效果。传统方法通过监听scroll事件来判断包含元素滚动状态,但该事件密集发生,容易造成性能问题。

现代浏览器为我们提供了交叉观察者Intersection Observer API。该接口支持观察目标元素与其祖先或视口的交叉状态。基于该接口,我们能够很方便的实现滚动阴影效果。

IntersectionObserver API

使用交叉观察者监听第一个滚动子元素、最后一个滚动子元素与视口的交叉状态,并基于交叉状态动态切换对应阴影DOM的可见性,即可实现滚动阴影效果。该方案的代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>滚动阴影实践</title>
    <style>
        .list-container {
            display: inline-block;
            position: relative;
            width: 300px;
            height: 300px;
            overflow: scroll;
            border: 1px solid red;
        }
        .list-container > div:not(.shadow) {
            padding: 6px;
            background-color: aqua;
            border: 1px solid gray;
            margin: 0 6px 10px 6px;
        }
        .shadow {
            position: sticky;
            width: 100%;
            height: 10px;
            visibility: hidden;
        }
        .top-shadow {
            top: 0;
            background: linear-gradient(rgba(0, 0, 0, .5), transparent);
        }
        .bottom-shadow {
            bottom: 0;
            margin-top: -10px;
            background: linear-gradient(to top, rgba(0, 0, 0, .5), transparent);
        }
        .show-shadow {
            visibility: visible;
        }
    </style>
</head>
<body>
    <h2>IntersectionObserver</h2>
    <div class="list-container">
        <div class="shadow top-shadow"></div>
        <div>123</div>
        <div>123</div>
        <div>123</div>
        <!-- 省略一堆div..... -->
        <div class="shadow bottom-shadow"></div>
    </div>
    <script>
        const children = document.querySelectorAll('.list-container > div:not(.shadow)');
        const observedDom = [children[0], children[children.length - 1]];
        const shadowArr = document.querySelectorAll('.shadow');

        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                const shadowDom = entry.target === observedDom[0] ? shadowArr[0] : shadowArr[1];
                shadowDom.classList.toggle('show-shadow', entry.intersectionRatio < 0.9)
            });
        },
        {
            threshold: [0, 0.8, 1],
        });

        observedDom.forEach(item => {
            observer.observe(item);
        })
    </script>
</body>
</html>

效果如下,可以看到,相对于CSS方案,JS方案的阴影呈现和隐藏的视觉切换效果更好。

Feb-19-2023 21-02-15.gif

总结

本文对滚动阴影效果的CSS和JS实现方案进行了归纳总结。可以发现:

  1. 当子元素无遮挡类背景时,使用background-attachment能够快速实现阴影效果。
  2. 当子元素存在遮挡背景且不要求极致视觉效果时,则可以使用CSS position定位遮挡方案来实现阴影效果。
  3. 当需要更灵活的阴影控制效果,或追求更完美的视觉效果时,使用JS 交叉观察者方案来实现阴影效果或许是一个不错的选择。

参考文章

[1]使用纯CSS实现滚动阴影效果
[2]background-attachment --MDN
[3]Using multiple backgrounds --MDN
[4]CSS层级小技巧!如何在滚动时自动添加头部阴影?
[5]Intersection Observer API --MDN