滚动导航+吸顶合并方案(含sticky详解)

20,950 阅读19分钟

前言

这是一个老套的需求,jq时代就很流行的交互需求。网络上也有不少资料,我这里写这篇文章的目的更主要是针对这些资料的不足,解决一些问题:

  • 吸顶和滚动导航的资料都是独立的,二者作为独立功能独立分析。我这里要把吸顶功能作为滚动导航的一个功能之一,作为一整套方案来分析。
  • 吸顶和滚动导航的分析,大多数是站在顶部导航的立场上写方案的,但是其实该功能,可以出现在页面的任何位置上,这些资料所写的方案缺少场景的延伸。既然要延伸了,很多细节是需要考虑的。
  • 滚动导航的介绍,往往是对传统监听scroll方法和position: sticky方法独立介绍,让读者二选一。我这里,把两个方法融合一起,让浏览器自己判断采用哪个方法,尽量采取最简单优化的sticky方法。
  • 亲自实践,揭开sticky的神秘面纱,给出不同于很多资料的见解,指出一些容易误导别人的描述。
  • sticky和传统方式的相结合。

先来看下总体效果和本次的实践目标,本文已实现改图效果教程,来理解要表达的滚动导航+吸顶的方案,这样从实践中触发讲解,比纯粹将一个抽象出来的方案要更容易让人理解。

image
【这里最好放个gif看效果】

由于我时隔多年再一次做这个效果,从当年大热的jq,已经变成更多框架的时代,我最后封装了一个vue的组件可供大家选择使用。

需求

这里举个相对复杂点的例子,我们不要再拘泥于顶部导航栏的这种效果,那实现比较简单,实际上这种效果可以出现在页面任何局部地方,虽然实现本质上是一样的,但是需要注意的小细节,还是很多的。

我们要实现的目标就是上面前言里的gif图效果

image

我们来看下图里的html结构

<html>
<body>
    <div class="left-section"></div>
    <div class="right-section">
        <div class="top-section"></div>
        <div class="nav-bar-wrap">
            <ul class="nav-bar">
                <li data-content="content1" class="active">导航1</li>
                <li data-content="content2">导航2</li>
                <li data-content="content3">导航3</li>
            </ul>
        </div>
        <div class="nav-content-container">
            <div class="content content1">导航1内容</div>
            <div class="content content2">导航2内容</div>
            <div class="content content3">导航3内容</div>
        </div>
    </div>
</body>
</html>

可以看到,导航栏nav-bar,是一个位于页面中间的一个位置。

我们看下一些关键的css样式(摘要)

html {
    height: 100%;
    overflow: hidden;
}
body {
    padding-top: 24px; /* 这是重点 */
    height: 100%;
    overflow: auto;
}

.left-section {
    display: inline-block;
    width: 300px;
}
.right-section {
    position: relative; /* 这是重点 */
    display: inline-block;
    margin-left: 24px;
    width: 700px;
}
.nav-bar-wrap {
    margin: 16px 0;
    height: 55px; /* 这是重点 */
}
.nav-bar {
    height: 100%;
}

上面的设置,分点说效果

  • 主要是让body成为滚动容器,即滚动条所属的元素。
  • left-sectionright-section各占页面的左右两边
  • 导航栏.nav-bar被一个父元素nav-bar-wrap包裹,目的是占位!当导航栏.nav-bar吸顶后,设置了position: fixed,脱离了文档流,如果没有这个父元素占位,页面的内容就会往上填补这个空缺,且,吸顶效果的瞬间页面很不流畅!
  • .right-section设置了position了,成为了导航内容和导航栏的offsetParent了,并不是滚动容器body了,这个是要注意的,且body设置了padding-top: 24px了。

上面的设置就打造了一个较为复杂的滚动导航情况了。

一上来就把 demo 放出来吧,这时候可以看看css的全部情况,js部分我们继续往下讲。

传统监听scroll方法

我们还是先分别介绍两个方法先,最后再一起融合。这里主要讲传统的scroll方法。

思路:

  • 监听scroll事件,当滚动距离达到要吸顶的条件时(基于导航栏的offsetTop进行计算判断),导航栏设置为position: fixed
  • 记录每个导航对应的内容的offsetTop,一般当滚动距离大于等于对应内容的offsetTop时,设置导航栏的选中状态;
  • 点击导航栏的导航,设置滚动容器的scrollTop,一般是设置成内容的offsetTop

上面是一个最简单的思路。当然中间会有很多细节需要注意的,我们在下面一步步实现中去了解这些细节:

  • 当引起滚动的容器,不是导航对应的内容的offsetParent(后面解释offsetParent),所以在判断scrollTop与内容的offsetTop时,要加额外的一些计算。
  • 当导航栏并不是像顶部导航那种在页面dom结构中比较顶层的,且宽是屏幕宽度长的这种典型的情况时,如在页面dom树中比较里层的某个div下的小导航,要进行吸顶时,由于设置了position:fixed;,宽高值如果是相对值,如百分比时,相对的基准就发生了变化了,就要处理好它的宽高情况了。
  • 当页面上的内容发生了变化,如加载数据,页面发生重排重绘,此时就要更新导航栏自身以及每个导航对应内容所在容器的offsetTop,不然会影响后续的计算判断。这点是比较重要的,毕竟现在很多页面已经不是静态的了,也不是整个页面进行刷新的,都是局部刷新的了。
  • 当浏览器屏幕发生变化时(resize),由于可能引起重排重绘,所以还是要更新导航栏自身以及每个导航对应内容所在容器的offsetTop,不然会影响后续的计算判断。
  • 当滚动条到达底部,如果还不到导航栏最后一个导航被选择的条件时,就要强制选中最后一个导航。这点是交互上的小优化。

offsetParent

先解释下什么是offsetParent,因为后面用到的元素的offsetTop是基于元素的offsetParent的含padding以内区域计算的。

注意这里有个特殊情况,当offsetParentbody时,计算offsetTop是从bodymargin区域算起的。这点在官方资料里没提及到,是我实践时发现的。

距离一个元素最近的一个设置了positionrelativeabsolutesticky的祖先元素,即为该元素的offsetParent,如果祖先元素里没有该设置的,那么最近的td,th,tablebody元素即为offsetParent

以下三种情况的元素的offsetParent是null:

  • 该元素或其父元素设置了display: none
  • 该元素自己设置了position:fixed(火狐浏览器或返回<body>
  • 该元素是<body><html>

解决方案

为了让大家简单易懂,以下写法为简单粗暴的写法。

绑定监听

首先我们定义一些所需的变量

var navBar = document.querySelector('.nav-bar');
var menu = document.querySelectorAll('.nav-bar li');
var scrollContainer = document.querySelector('body');
var offsetTops = {}; // 存储各个部分的offsetTop

对滚动容器,例子中是body,进行关键的滚动事件绑定:

scrollContainer.addEventListener('scroll', handleScroll);

值得注意的是,如果html是滚动所在的容器的话,那么绑定监听scroll的对象是window,用html绑定是不起作用的

window.addEventListener('scroll', handleScroll);

接着我们获取导航栏和各个导航内容的offsetTop

calcTop(true);
/**
 * 计算页面的各个offsetTop
 * @param {Boolean} recalNav - 是否计算导航栏的offsetTop
 */
function calcTop(recalNav) {
    recalNav && (offsetTops.navBar = navBar.offsetTop);
    ['content1', 'content2', 'content3'].forEach(item => {
        offsetTops[item] = document.querySelector('.' + item).offsetTop;
    });
}

要注意,只要页面的内容发生变化了(如请求加载数据后渲染数据到html上),就要调用一次这个方法,确保offsetTop值是最新的。

其中关键的handleScroll是重点,我们一步步来解释:

function handleScroll() {
    var top = scrollContainer.scrollTop; // 获取当前滚动条滚动距离
    // 这是控制导航栏吸顶 - 吸顶,
    // 为什么要减去24呢?
    // 因为说了,滚动容器body并不是导航栏和导航内容的offsetParent,所以scrollContainer.scrollTop的距离是从body的paddingTop开始算的
    if ((top - 24) >= offsetTops.navBar) {
        navBar.style.position = 'fixed';
        navBar.style.top = 0;
        // 由于变成了`fixed`,基于的父元素变化了,得重新设置下样式,为了维持原来样式的样子
        navBar.style.left = '124px';
        navBar.style.width = '300px';
        navBar.style.height = '55px';
    }
    // 这是控制导航栏吸顶 - 取消吸顶
    if ((top - 24) < offsetTops.navBar) {
        navBar.style.position = 'static';
        navBar.style.width = '100%';
        navBar.style.height = '100%';
    }
    resetNavSelect();
    // 这是吸顶之后用来做衡量的距离值,为什么要加31呢?
    // 在不吸顶的情况下,导航指定的内容只要滚动到body顶部就算到了该内容了的导航了,即滚动了【内容的offsetTop + body的paddingTop】的距离
    // 但是吸顶之后,只要滚动到吸顶导航栏底部就算到了指定导航内容了,所以相当于只要滚动【内容的offsetTop + body的paddingTop - 吸顶导航栏的高度】的距离就会到达临界值
    // 转换成公式来理解,c代表导航内容的offsetTop,s代表滚动的距离,body的paddingTop为24,吸顶导航栏高度为55。只要滚动距离大于等于上面说的临界值,即肯定到达了对应导航。
    // 因此公式为: s >= c + 24 - 55, 即 s + 31 >= c 时,到达条件成立,因此滚动容器的scrollTop都要加上31,才是拿来判断的值
    var fixedBaseTop = top + 31;
    var menuLength = menu.length;
    // 滚动条到达底部就选中最后一个导航
    if (top + scrollContainer.clientHeight >= scrollContainer.scrollHeight) {
        menu[menuLength - 1].className = 'active';
        return;
    }
    // 以下都为依据滚动自动选择对应导航
    // 当滚动到导航内容到达吸顶后的导航栏底部之后,且其接着的导航内容尚未到达导航栏之前,即为该导航内容的导航选中情况
    // for循环里的执行情况不包括对导航栏最后一个导航做判断
    for (var i = 0; i < menuLength - 1; i++) {
        if (fixedBaseTop >= offsetTops['content' + (i + 1)] && fixedBaseTop < offsetTops['content' + (i + 2)]) {
            menu[i].className = 'active';
            return;
        }
    }
    // 这里是对最后一个导航做判断,如果它已到达导航栏底部之时之后,就选中它
    if (fixedBaseTop >= offsetTops['content' + (menuLength - 1)]) {
        menu[menuLength - 1].className = 'active';
        return;
    }
    // 没有一个情况符合就选中第一个导航。
    menu[0].className = 'active';
}

上面的代码注释已经说明清楚了,如果滚动容器body并不是导航栏和导航内容的offsetParent,需要做怎样的一个偏差值计算。一般是要考虑到滚动容器与导航栏和导航内容的offsetParent之间的一个距离,以及导航栏吸顶之后,考虑到导航栏自身的高度的问题。

以及当吸顶之后,要对导航栏的样式做一个补充,这里的例子还算是比较简单,吸顶前吸顶后都是一个固定px值,但是当布局情况复杂点的时候,你的导航栏宽高本身是根据原来百分比计算的,这时候吸顶之后要好好给导航栏赋值了。

接着我们针对改变页面浏览器大小,添加一个监听事件,让其重新计算导航栏和导航内容的offsetTop

window.addEventListener('resize', hanldeResize);

function hanldeResize() {
    calcTop(true);
}

因为页面浏览器大小发生变化了,页面的内容可能也会发生变化,导致一开始计算的offsetTop变成旧的了,要及时更新,这样才能保证滚动导航是正确地做了比较。

点击跳转导航内容

至此,其实基本核心的逻辑都已经处理完毕了。最后这部分是比较简单的,就是点击导航,页面滚动到导航内容所在位置。

navBar.onclick = selectNav;
/**
 * 选择标题跳到对应内容
 */
function selectNav(e) {
    var ev = e || event;
    var target = ev.target || ev.srcElement; // 兼容IE
    this.resetNavSelect();
    target.className = 'active';
    scrollContainer.scrollTop = offsetTops[target.getAttribute('data-content')] - 31;
}

上面方法里赋值的scrollTop里有减去31,这个31即为上面解释的偏差值,由于滚动导航时是依据这个偏差值来定位导航的,所以按道理来说,点击导航栏跳转导航内容也是依据这个偏差值计算。当然你有个性,想不一样,也无可厚非。

小结

至此,用传统方法实现的介绍,到此结束。上面的例子的写法虽然不是最优化,但是为的是方便大家的理解,就这样了,后续可以看看封装的vue组件里的写法,很多考虑的细节在prop中也一目了然。

css新特性 —— sticky

sticky是作为position的一个值,顾名思义,是粘性的意思,跟吸附住导航栏是不是很形象?

丑话说在前,sticky的兼容性不是很好,可以看下 这里

说句实话,关于这个特性的介绍,很多资料都有说,但是我都觉得描述得不是很准确,特别是常见的说是介于relativefixed之间切换这种描述,我觉得是很误导人的。

我们先来个笼统一点的说法:

对设置了sticky的元素,在满足特定条件下,会产生粘性,保留原来位置不变,像被粘住了一样,而不满足条件情况下,跟普通效果一样。

那么所说的条件,是有哪些呢?我列举出来

粘性生效条件

对于设置了position: sticky的元素来说,本文我暂且描述成“粘性定位元素”,要满足以下条件才会产生所谓的“粘性”:

  • 一定要设置方位属性(top/left/bottom/right)
  • 粘性定位元素(不含margin)与其最近的一个祖先scrolling box(含border,padding)的距离,小于等于设定的方位属性阈值。如果没有拥有scrolling box的话会根据viewport来计算
  • 怎么判断是否达到阈值,是根据这个做判断的scrolling box的滚动事件确定的,言外之意即,该scrolling box一定可滚动(在你设定的方向属性的方向上,如你要垂直滚动时固定,就垂直方向一定要可滚动),只有滚动了监听到了才会生效(意味着如果设置了overflow: hidden是不起效的),而且,不会受到其他祖先scrolling box的滚动影响。
  • 父元素可视区域能容纳下粘性定位元素。一般发生在父元素不是滚动容器时。这点具体下面会说明一个情况。

我们汇总以及简化下上面条件的描述:

粘性定位元素要位于一个可滚动容器里,且一定要设置方向属性,该值用作与最近的一个祖先scrolling box的距离做比较,小于等于时生效。但是其父元素在滚动的影响下,如果可视区域容纳不下该粘性定位元素时,则粘性同样会消失。

对于上面说的scrolling box,本文暂且称为“滚动容器”

scrolling box:含有滚动条的或设置了overflowcss属性的容器,注意,是要设置了overflow的就是了,不管是hidden还是设置单个方向的overflow,如overflow-x

表现

我们来认识下,sticky的一些具体表现,看完这些,你大概就知道这是一个怎么样的过程,以及效果了。(注意字体加粗部分)

当你设置了方位属性,如top: 0,那么就以方位属性值作为阈值,当粘性定位元素与滚动容器border内区域的距离(即margin不算在内)等于该阈值时,该元素就表现得有粘性,即位置不会变化了,像粘在那个位置一样,之后距离小于阈值,也还是那瞬间粘住的位置一样。

注意粘住是指位置不会变化,其他样式如宽高还是之前一样,特别强调这点是因为很多资料会用fixed来表示这种粘性行为,其实是不严谨的,真正变成fixed,宽高样式的百分比基准是会变成页面的,但是这里的粘性行为,宽高百分比是基于父元素

更重要的是,粘性发生后,也会保留原本的文档流位置,而不会脱离文档流。所以我们可以理解,粘性是黏在父元素上的,而不是页面。

当粘性未生效时

粘性定位元素行为如static,看到很多资料说如relative,但是实践发现,更接近static,因为设置的方位属性在粘性生效前是没有作用的,而relative的方位属性是起效的。

当粘性生效后

如果粘性定位元素未发生粘性时与最近的滚动容器距离是小于设置的方位属性阈值的话,当发生滚动后变成小于或等于该阈值,就会产生粘性效果,表现为黏在等于该阈值时的位置。

但是如果一开始粘性定位元素未发生粘性时与最近的滚动容器距离就是大于设置的方位属性阈值的话,阈值的设置相当于起到定位作用,如top: 100px,那么粘性定位元素(不含margin)立刻就会黏在距离最近滚动容器(含border,padding)100px的地方,当然,此时还是粘性的是父元素身上。

当粘性定位元素的父元素并不是滚动容器

这里举垂直滚动为例,

当粘性效果发生后,继续往下滚动,父元素会继续被滚上去(这是正常表现),若粘性定位元素自身盒子模型(包括margin)到达父元素底部时,之后继续滚动下去粘性定位元素也会被滚上去(从视角上可以理解为粘性效果消失了)。

可以这么理解:因为粘性是相对于父元素区域,如果父元素包裹粘性定元素的区域都彻底被滚上去了,自然该定位元素也会跟随父元素滚上去。

这就是上面说的生效条件里的最后一条的情况。

(对于这部分描述,其他文章会描述成父元素的高度要大于该元素,他们是从结论说的,我这里从现象本质上来说,会更加清晰)

应用于本例子

上面我们介绍完了sticky的知识了,是时候把这个知识点应用在上面我的例子中了。sticky仅是用来实现吸顶效果,所以其他部分的功能(滚动导航、跳转导航内容等)还是得需要的。下面仅说实现吸顶效果部分。

还是原来的htmlcss的基础上,添加如下css

.nav-bar-wrap {
    position: sticky;
    /* 24是body的paddingTop */
    top: -24px;
}

这样就完成了!是不是超简单~

这段css代码就是替代了传统方案里的这段js代码,还不用计算吸顶后的样式呢:

// 这是控制导航栏吸顶 - 吸顶
if ((top + extraFixed) >= offsetTops.navBar) {
    navBar.style.position = 'fixed';
    navBar.style.top = 0;
    navBar.style.left = '124px';
    navBar.style.width = '300px';
    navBar.style.height = '55px';
}
// 这是控制导航栏吸顶 - 取消吸顶
if ((top + extraFixed) < offsetTops.navBar) {
    navBar.style.position = 'static';
    navBar.style.width = '100%';
    navBar.style.height = '100%';
}

二者结合

二者结合的意思是,根据浏览器是否支持sticky,来判断使用css方式的吸顶效果,还是js控制吸顶。

不过说句实话,除非你明确知道你开发的页面是应用在哪个浏览器上(或者有这个需求),这样的话你在开发时就只写合适的那段代码就好了。

但是如果你自己也不确定是应用在什么浏览器上,或者说本来是要适应大部分浏览器的话,二者结合的方案并不会省去写代码的功夫,就是说两个实现方式都要写,还要写判断,实际用哪个方式。这样做意义仅仅是,能使用css的就用css尽量减少dom操作,是性能上的优化。如果你没有这个追求的话,其实完全可以写传统的。

添加个判断浏览器是否支持sticky的方法

var isSupportSticky = validateSticky();
/**
 * 检查浏览器是否有支持的sticky值,没有返回false,有就添加sticky相关css,实现吸顶
 */
function validateSticky () {
    var supportStickyValue = valiateCssValue('position', 'sticky');
    if (supportStickyValue) {
        var navBarWrap = document.querySelector('.nav-bar-wrap');
        navBarWrap.style.position = supportStickyValue;
        navBarWrap.style.top = '-24px';
        return true;
    }
    return false;
}

/**
 * 检查浏览器是否支持某个css属性值
 */
function valiateCssValue (key, value) {
    var prefix = ['-o-', '-ms-', '-moz-', '-webkit-', ''];
    var prefixValue = [];
    for (var i = 0; i < prefix.length; i++) {
        prefixValue.push(prefix[i] + value)
    }
    var element = document.createElement('div');
    var eleStyle = element.style;
    for (var j = 0; j < prefixValue.length; j++) {
        eleStyle[key] = prefixValue[j];
    }
    return eleStyle[key];
}

这里的valiateCssValue方法,在我的另一篇文章中有详细介绍 js判断并告知支持css属性(值)的何种情况

接着我们只需要在handleScroll方法里做下微小的调整即可,在处理吸顶的那块逻辑里添加isSupportSticky的判断

function handleScroll() {
    ...
    if (!isSupportSticky) {
        // 这是控制导航栏吸顶 - 吸顶
        if ((top + extraFixed) >= offsetTops.navBar) { ... }
        // 这是控制导航栏吸顶 - 取消吸顶
        if ((top + extraFixed) < offsetTops.navBar) { ... }
    }
    ...
}

总结

文章主要是从实现一个实际例子来展开说明,基本的思路,以及中途可能会遇到的各个问题,要进行考虑的细节。文章实现的例子的demo

相信大家都是聪明人,都能够触类旁通,举一反三,只要掌握了本质的知识,再复杂也是能够面对的。

最后也提供一个基于Vue实现封装好的组件,从中可以感受下抽象出来的方案架构。

npm地址 vue-scroll-nav

github vue-scroll-nav

未经允许,请勿私自转载