务实|内容滚动与导航标签互动关联方案

2,010 阅读7分钟

务实|内容滚动与导航标签互动关联方案

一、需求场景描述

1.先看演示效果

GIF 2022-4-17 16-04-21.gif

类似这种,当也页面左侧内容滚动的时候,需要关联激活左侧导航节点;当点击右侧导航节点时, 也会将左侧对应的内容滚动到可视区域顶部的场景,并不少见,比如知识类社区,掘金查看文章时,百度查看百科词条时,都有这种场景,而我的实际开发种也遇到此类需求。遂有此文。

2.需求分解:

1.滚动左侧内容,关联激活右侧导航节点

2.单击右侧导航节点,右侧相应的段落滚动到可视区顶部

二、关键技术点提前知

技术点1:Element.scrollIntoView()

1.scrollIntoView() 的作用

scrollIntoView()方法会滚动元素的父容器,使被调用scrollIntoView()的元素对用户可见。

2.基本用法介绍:

var el = document.getElementById("p1");

// true 可以省略效果相同
el.scrollIntoView(true)

// alignToTop:Boolean型参数。
// 如果为 true,元素的顶端将和其所在滚动区的可视区域的顶端对齐。
// 如果为 false,元素的底端将和其所在滚动区的可视区域的底端对齐。
el.scrollIntoView(false);

// 可选,scrollIntoViewOptions:{behavior: "smooth", block: "end", inline: "nearest"}
// 可选,behavior :定义动画过渡效果,"auto"或 "smooth" 之一。默认为 "auto"。
// 可选,block:定义垂直方向的对齐,"start", "center", "end", 或 "nearest"之一。默认为 "start"。
// 可选,inline:定义水平方向的对齐,"start", "center", "end", 或 "nearest"之一。默认为 "nearest"。
el.scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"});
el.scrollIntoView({block: "end"});

3.注意事项

普通的布局没问题,但是要注意,取决于其它元素的布局情况,此元素可能不会完全滚动到顶端或底端。比如整体上已经到顶部了,无法再滚动,那么该元素就不会移动到可视区的顶部。

技术点2:Element.getBoundingClientRect()

Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合,就是该元素的 CSS 边框大小。返回的结果是包含完整元素的最小矩形,并且拥有left, top, right, bottom, x, y, width, 和 height这几个以像素为单位的只读属性用于描述整个边框。除了width 和 height 以外的属性是相对于视图窗口的左上角来计算的。

下面是它的打印信息示例:

console.dir(document.getElementById("p1").getBoundingClientRect())

image.png

如果是标准盒子模型,元素的尺寸等于width/height + padding + border-width的总和。如果box-sizing: border-box,元素的的尺寸等于 width/height。

三、实现思路分析

1.数据初始化处理

// 从内容数据list中,获取段落标题作为导航标题。并为导航节点增加href,以段落的id值作为href的值
this.list.map((item, i) => {
  this.activities.push({title: item.title, act: false, href: "#p" + i});
});

2.监听滚动条,以便滚动内容时,关联激活右侧导航标签

// 监听滚动条
window.addEventListener("scroll", function (e) {
  // 防抖动处理
  clearTimeout(that.timeout)
  this.timeout = setTimeout(() => {
    that.activeNavNode(e)
  }, 100)
});

3.实现点击右侧导航节点,关联左侧内容滚动到可视区顶部

// dom中定位导航
navToPosition(item, index) {
  // 激活相应的导航节点,变色
  this.active = index;
  // 根据导航节点的href信息即id信息,获取对应的元素节点,通过 scrollIntoView 滚动该元素到可视区顶部
  document.querySelector(item.href).scrollIntoView(true);
},

4.实现滚动右侧内容,关联激活左侧导航节点

// 激活左侧对应的导航条
activeNavNode(e) {
  const nodes = document.getElementsByTagName("section")
  for (let i = 0; i < nodes.length; i++) {
    let node = nodes[i];
    // 获取该元素此时相对于视口的顶部的距离,即是元素顶部距离视口屏幕上边的距离
    let nodeY = node.getBoundingClientRect().y
    // 当元素距离视口顶部的距离在 [0,200] 之间,设置激活该元素对应左侧的导航标题,这个数字可以按需定义
    // 这里关联内容和导航标签,是巧妙利用了内容在元素集合中的索引序号和导航标签中的一致
    // 即是 list 和 activities 和 nodes 中下标相等的元素,具有对应关联的关系
    if (nodeY <= 200 && nodeY >= 0) {
      this.active = Number(i)
      return
    }
  }
},

四、完整 demo 示例代码

<template>
  <div>
    <h1>页面内容滚动与右侧导航标签互动关联 效果演示 demo</h1>
    <div class="content" id="content">
      <div class="left">
        <section v-for="(item, i) in list" class="text-node" :key="i" :id="'p' + i">
          <div class="title">
            <div class="title-pre"></div>
            <div v-html="item.title"></div>
          </div>
          <div class="text">
            <div v-html="item.text"></div>
          </div>
        </section>
      </div>
      <div class="right">
        <div class="entry-message">
          <div class="title">内容导航</div>
          <div class="content-anchor">
            <div>
              <el-timeline>
                <el-timeline-item
                  v-for="(item, index) in activities"
                  :key="index"
                  :color="active === index ? '#2b6afb' : ''"
                >
                  <div
                    @click="navToPosition(item, index)"
                    :style="{ color: active === index ? '#2b6afb' : '' }"
                  >
                    <span class="cn-pointer" v-html="item.title"></span>
                  </div>
                </el-timeline-item>
              </el-timeline>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
// 示例样本内容,提前声明赋值,方便后面复用
const text =
  '1、能源资源\n' +
  '能源资源包括煤、石油、天然气、水能等,也包括太阳能、风能、生物质能、地热能、海洋能、核能等新能源。纵观社会发展史,人类经历了柴草能源时期、' +
  '煤炭能源时期和石油、天然气能源时期,正向新能源时期过渡,并且无数学者仍在不懈地为社会进步寻找开发更新更安全的能源。但是,' +
  '人们能利用的能源仍以煤炭、石油、天然气为主,在世界一次能源消费结构中,这三者的总和约占93%。\n' +
  '在一定历史时期和科学技术水平下,已被人们广泛应用的能源称为常规能源。那些虽古老但需采用新的先进的科学技术才能加以广泛应用的能源称为新能源。' +
  '凡在自然界中可以不断再生并有规律地得到补充的能源,称为可再生能源。经过亿万年形成的,在短期内无法恢复的能源称为非可再生能源。'

export default {
  name: 'ScrollIntoView',
  data () {
    return {
      list: [
        { title: '能源开发与利用1', text: text },
        { title: '能源开发与利用2', text: text },
        { title: '能源开发与利用3', text: text },
        { title: '能源开发与利用4', text: text },
        { title: '能源开发与利用5', text: text },
        { title: '能源开发与利用6', text: text }
      ],
      active: 0,
      timeout: null,
      activities: []
    }
  },
  mounted () {
    console.dir(document.getElementById('p1').getBoundingClientRect())
    this.init()

    window.sessionStorage.setItem('shi', '400块')

    const routeData = this.$router.resolve({
      name: 'DownloadFile'
    })
    window.open(routeData.href, '_blank')
  },
  methods: {
    init () {
      const that = this
      // 从内容数据list中,获取段落标题作为导航标题。并为导航节点增加href,以段落的id值作为href的值
      this.list.map((item, i) => {
        this.activities.push({ title: item.title, act: false, href: '#p' + i })
      })

      // 监听滚动条
      window.addEventListener('scroll', function (e) {
        // 防抖动处理
        clearTimeout(that.timeout)
        this.timeout = setTimeout(() => {
          that.activeNavNode(e)
        }, 100)
      })
    },
    // dom中定位导航
    navToPosition (item, index) {
      // 激活相应的导航节点,变色
      this.active = index
      // 根据导航节点的href信息即id信息,获取对应的元素节点,通过 scrollIntoView 滚动该元素到可视区顶部
      document.querySelector(item.href).scrollIntoView(true)
    },
    // 激活左侧对应的导航条
    activeNavNode (e) {
      const nodes = document.getElementsByTagName('section')
      for (let i = 0; i < nodes.length; i++) {
        const node = nodes[i]
        // 获取该元素此时相对于视口的顶部的距离,即是元素顶部距离视口屏幕上边的距离
        const nodeY = node.getBoundingClientRect().y
        // 当元素距离视口顶部的距离在 [0,200] 之间,设置激活该元素对应左侧的导航标题,这个数字可以按需定义
        // 这里关联内容和导航标签,是巧妙利用了内容在元素集合中的索引序号和导航标签中的一致
        // 即是 list 和 activities 和 nodes 中下标相等的元素,具有对应关联的关系
        if (nodeY <= 200 && nodeY >= 0) {
          this.active = Number(i)
          return
        }
      }
    }
  }
}
</script>
<style lang="scss" scoped>
.cn-pointer {
  cursor: pointer;
}

.content {
  display: flex;
  padding: 20px 0 100px 0;
  width: 1100px;
  min-width: 1100px;
  max-width: 1100px;
  align-content: center;
  margin: 0 auto;

  .left {
    flex-grow: 1;
    margin: 0 20px 500px 0;
    padding: 0 40px 40px;
    background: #ffffff;
    box-shadow: 0 2px 20px -6px rgba(30, 135, 240, 0.1);
    border-radius: 4px;
  }

  section {
    padding: 30px 0;

    .title {
      height: 25px;
      display: flex;
      align-items: center;
      margin-bottom: 20px;
      font-size: 18px;
      color: #262626;
      font-weight: bold;
      margin-top: 20px;

      .title-pre {
        width: 9px;
        height: 18px;
        margin-right: 10px;
        background: #1e87f0;
      }
    }

    .text {
      font-size: 14px;
      color: #262626;
      line-height: 44px;
      font-weight: 400;
      text-align: left;

      span {
        display: block;
        margin-bottom: 20px;
      }
    }
  }

  .right {
    min-width: 300px
  }

  .entry-message {
    box-sizing: content-box;
    padding: 26px 30px;
    background: #ffffff;
    box-shadow: 0 2px 20px -6px rgba(30, 135, 240, 0.1);
    border-radius: 4px;
    position: fixed;
    top: 105px;

    .title {
      margin-bottom: 24px;
      font-size: 18px;
      color: #262626;
      font-weight: 600;
    }

    .content-anchor {
      display: flex;
      flex-direction: row;
      align-items: center;
    }
  }
}
</style>