使用 Intersection Observer (IO) 更改样式 header/nav

Change style header/nav with Intersection Observer (IO)

Fiddle latest


我用 scroll event 方法开始这个问题,但由于建议使用 IntersectionObserver 这似乎更好的方法,我试图让它以这种方式工作。


目标是什么:

我想更改 header 的样式 (color+background-color),具体取决于 div/section 所观察到的电流寻找(我在想?)它的 classdata 将覆盖默认的 header 样式(black on white)。


Header 样式:

font-color:

根据内容(div/section)默认的header应该可以将font-color更改为只有两种可能的颜色:

background-color:

根据内容的不同,background-color可以有无限的颜色或者是透明的,所以最好分开处理,这些可能是最常用的 background-colors:


CSS:

header {
  position: fixed;
  width: 100%;
  top: 0;
  line-height: 32px;
  padding: 0 15px;
  z-index: 5;
  color: black; /* default */
  background-color: white; /* default */
}

Div/section 默认示例 header 内容无变化:

<div class="grid-30-span g-100vh">
    <img 
    src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
    data-src="/images/example_default_header.jpg" 
    class="lazyload"
    alt="">
</div>

Div/section 示例更改 header 内容:

<div class="grid-30-span g-100vh" data-color="white" data-background="darkblue">
    <img 
    src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
    data-src="/images/example_darkblue.jpg" 
    class="lazyload"
    alt="">
</div>

<div class="grid-30-span g-100vh" data-color="white" data-background="black">
    <img 
    src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
    data-src="/images/example_black.jpg" 
    class="lazyload"
    alt="">
</div>

路口观察者方法:

var mq = window.matchMedia( "(min-width: 568px)" );
if (mq.matches) {
  // Add for mobile reset

document.addEventListener("DOMContentLoaded", function(event) { 
  // Add document load callback for leaving script in head
  const header = document.querySelector('header');
  const sections = document.querySelectorAll('div');
  const config = {
    rootMargin: '0px',
    threshold: [0.00, 0.95]
  };

  const observer = new IntersectionObserver(function (entries, self) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        if (entry.intersectionRatio > 0.95) {
          header.style.color = entry.target.dataset.color !== undefined ? entry.target.dataset.color : "black";
          header.style.background = entry.target.dataset.background !== undefined ? entry.target.dataset.background : "white";   
        } else {
        if (entry.target.getBoundingClientRect().top < 0 ) {
          header.style.color = entry.target.dataset.color !== undefined ? entry.target.dataset.color : "black";
          header.style.background = entry.target.dataset.background !== undefined ? entry.target.dataset.background : "white";
          }
        } 
      }
    });
  }, config);

  sections.forEach(section => {
    observer.observe(section);
  });

});

}

与其监听滚动事件,不如看看 Intersection Observer (IO)。 这是为了解决像你这样的问题而设计的。而且它比监听滚动事件然后自己计算位置要高效得多。

首先,here is a codepen 它显示了您的问题的解决方案。 我不是这个代码笔的作者,我可能会做一些不同的事情,但它确实向您展示了解决问题的基本方法。

我要更改的内容:您可以在示例中看到,如果您将 99% 的内容滚动到一个新部分,即使新部分不完全可见,标题也会发生变化。

现在好了,解释一下它是如何工作的(注意,我不会盲目地从 codepen copy-paste,我也会把 const 改成 let,但是使用更适合你的东西项目。

首先,您必须指定 IO 的选项:

let options = {
  rootMargin: '-50px 0px -55%'
}

let observer = new IntersectionObserver(callback, options);

在示例中,一旦元素距离进入视图 50 像素,IO 就会执行回调。我不能从头顶推荐一些更好的值,但如果我有时间,我会尝试调整这些参数以查看是否可以获得更好的结果。

在 codepen 中,他们定义了内联回调函数,我只是这样写的,以便更清楚地了解发生了什么。

IO 的下一步是定义一些要观察的元素。在您的情况下,您应该向 div 添加一些 class,例如 <div class="section">

let entries = document.querySelectorAll('div.section');
entries.forEach(entry => {observer.observe(entry);})

最后你必须定义回调函数:

entries.forEach(entry => {
    if (entry.isIntersecting) {
     //specify what should happen if an element is coming into view, like defined in the options. 
    }
  });

编辑:正如我所说,这只是一个如何让您入门的示例,它不是您完成的解决方案复制粘贴。在基于可见部分的 ID 的示例中,当前元素被突出显示。您必须更改此部分,以便不是将活动 class 设置为例如第三个元素,而是根据您在元素上设置的某些属性设置颜色和 background-color。我建议为此使用 data attributes

编辑 2: 当然你可以继续只使用滚动事件,W3C 的 the official Polyfill 使用滚动事件来模拟旧 browsers.it 的 IO只是监听滚动事件和计算位置是不高效的,尤其是在有多个元素的情况下。因此,如果您关心用户体验,我真的建议您使用 IO。只是想添加此答案以显示此类问题的现代解决方案。

编辑 3: 我花时间创建了一个基于 IO 的示例,这应该可以帮助您入门。

基本上我定义了两个阈值:一个为 20%,一个为 90%。如果元素在视口中占 90%,则可以假设它将覆盖 header。因此,我将 header 的 class 设置为视图中 90% 的元素。

第二个阈值是20%,这里我们要检查元素是从顶部还是从底部进入视图。如果它从顶部可见 20%,那么它将与 header 重叠。

调整这些值并按照您所见调整逻辑。

const sections = document.querySelectorAll('div');
const config = {
  rootMargin: '0px',
  threshold: [.2, .9]
};

const observer = new IntersectionObserver(function (entries, self) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      var headerEl = document.querySelector('header');
      if (entry.intersectionRatio > 0.9) {
        //intersection ratio bigger than 90%
        //-> set header according to target
        headerEl.className=entry.target.dataset.header;      
      } else {
        //-> check if element is coming from top or from bottom into view
        if (entry.target.getBoundingClientRect().top < 0 ) {
          headerEl.className=entry.target.dataset.header;
        }
      } 
    }
  });
}, config);

sections.forEach(section => {
  observer.observe(section);
});
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

.g-100vh {
height: 100vh
}

header {
  min-height: 50px;
  position: fixed;
  background-color: green;
  width: 100%;
}
  
header.white-menu {
  color: white;
  background-color: black;
}

header.black-menu {
  color: black;
  background-color: white;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>


<header>
 <p>Header Content </p>
</header>
<div class="grid-30-span g-100vh white-menu" style="background-color:darkblue;" data-header="white-menu">
    <img 
    src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
    data-src="/images/example_darkblue.jpg" 
    class="lazyload"
    alt="<?php echo $title; ?>">
</div>

<div class="grid-30-span g-100vh black-menu" style="background-color:lightgrey;" data-header="black-menu">
    <img 
    src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
    data-src="/images/example_lightgrey.jpg" 
    class="lazyload"
    alt="<?php echo $title; ?>">
</div>

我可能不完全理解这个问题,但是对于你的例子 - 你可以通过使用 mix-blend-mode css 属性 来解决它而根本不用 javascript .

示例:

header {background: white; position: relative; height: 20vh;}
header h1 {
  position: fixed;
  color: white;
  mix-blend-mode: difference;
}
div {height: 100vh; }
<header>
  <h1>StudioX, Project Title, Category...</h1>
</header>
<div style="background-color:darkblue;"></div>
<div style="background-color:lightgrey;"></div>

这仍然需要调整,但您可以尝试以下操作:

const header = document.getElementsByTagName('header')[0];

const observer = new IntersectionObserver((entries) => {
 entries.forEach((entry) => {
    if (entry.isIntersecting) {
       header.style.color = entry.target.dataset.color || '';
        header.style.backgroundColor = entry.target.dataset.background;
    }
  });
}, { threshold: 0.51 });

[...document.getElementsByClassName('observed')].forEach((t) => {
    t.dataset.background = t.dataset.background || window.getComputedStyle(t).backgroundColor;
    observer.observe(t);    
});
body {
  font-family: arial;
  margin: 0;
}

header {
  border-bottom: 1px solid red;
  margin: 0 auto;
  width: 100vw;
  display: flex;
  justify-content: center;
  position: fixed;
  background: transparent;  
  transition: all 0.5s ease-out;
}

header div {
  padding: 0.5rem 1rem;
  border: 1px solid red;
  margin: -1px -1px -1px 0;
}

.observed {
  height: 100vh;
  border: 1px solid black;
}

.observed:nth-of-type(2) {
  background-color: grey;
}

.observed:nth-of-type(3) {
  background-color: white;
}
<header>
  <div>One</div>
  <div>Two</div>
  <div>Three</div>
</header>

<div class="observed">
  <img src="http://placekitten.com/g/200/300">
  <img src="http://placekitten.com/g/400/300">
</div>
  
<div class="observed" data-color="white" data-background="black">
  <img src="http://placekitten.com/g/600/300">
</div>

<div class="observed" data-color="black" data-background="white">
  <img src="http://placekitten.com/g/600/250">
</div>

CSS 确保每个被观察的部分占用 100vw,并且观察者在其中任何一个以至少 51% 的百分比进入视野时执行其操作。

在回调中 headers background-color 然后设置为相交元素的 background-color。

我遇到过同样的情况,我实施的解决方案非常精确,因为它不依赖于百分比,而是依赖于真实元素的边界框:

class Header {
  constructor() {
    this.header = document.querySelector("header");
    this.span = this.header.querySelector('span');
    this.invertedSections = document.querySelectorAll(".invertedSection");

    window.addEventListener('resize', () => this.resetObserver());

    this.resetObserver();
  }

  resetObserver() {
    if (this.observer) this.observer.disconnect();

    const {
      top,
      height
    } = this.span.getBoundingClientRect();

    this.observer = new IntersectionObserver(entries => this.observerCallback(entries), {
        root: document,
      rootMargin: `-${top}px 0px -${window.innerHeight - top - height}px 0px`,
    });

    this.invertedSections.forEach((el) => this.observer.observe(el));
  };

  observerCallback(entries) {
    let inverted = false;
    entries.forEach((entry) => {
      if (entry.isIntersecting) inverted = true;
    });
    if (inverted) this.header.classList.add('inverted');
    else this.header.classList.remove('inverted');
  };
}

new Header();
header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  padding: 20px 0;
  text-transform: uppercase;
  text-align: center;
  font-weight: 700;
}
header.inverted {
  color: #fff;
}

section {
  height: 500px;
}
section.invertedSection {
  background-color: #000;
}
<body>
  <header>
    <span>header</span>
  </header>
  <main>
    <section></section>
    <section class="invertedSection"></section>
    <section></section>
    <section class="invertedSection"></section>
  </main>
</body>

它做的其实很简单:我们不能使用 IntersectionObserver 来知道 header 和其他元素何时交叉(因为 root 必须是 parent观察到的元素),但我们可以计算 header 的位置和大小,以将 rootMargin 添加到观察者中。

有时,header 比它的内容高(因为填充和其他东西)所以我计算 header 中 span 的 bounding-box(我希望它仅在 this 元素与黑色部分重叠时才变为白色。

因为 window 的高度可以改变,我必须在 window 调整大小时重置 IntersectionObserver。

这里root 属性设置为document是因为代码段的iframe限制(否则你可以不定义这个字段)。

使用 rootMargin,我指定我希望观察者在哪个区域寻找交叉点。

然后我观察每一个黑色部分。在回调函数中,我定义了如果 至少一个 部分是重叠的,如果这是真的,我将一个 inverted className 添加到 header.

如果我们可以在 rootMargin 属性 中使用像 calc(100vh - 50px) 这样的值,我们可能不需要使用 resize 侦听器。

我们甚至可以通过添加边 rootMargin 来改进这个系统,例如,如果我有黑色部分只有 window 宽度的一半,并且可能与 [=54= 中的跨度相交也可能不相交] 取决于它的水平位置。

@昆汀·D

我在互联网上搜索了类似的东西,我发现这段代码是满足我需求的最佳解决方案。

因此我决定在它的基础上创建一个通用的“Observer”class,它可以在许多需要 IntesectionObserver 的情况下使用,包括更改 header 样式。 我没有对其进行太多测试,仅在一些基本情况下进行了测试,它对我有用。我还没有在水平滚动的页面上测试它。

这样使用起来很容易,只需将其保存为 .js 文件并 include/import 在您的代码中,就像一个插件。 :) 我希望有人会发现它有用。

如果有人发现更好的想法(尤其是“水平”网站),很高兴在这里看到它们。

编辑:我没有做出正确的“unobserve”,所以我修正了它。

/* The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

ROOT:
It is not necessary for the root to be the ancestor element of the target. The root is allways the document, and the so-called root element is used only to get its size and position, to create an area in the document, with options.rootMargin.
Leave it false to have the viewport as root.

TARGET:
IntersectionObserver triggers when the target is entering at the specified ratio(s), and when it exits at the same ratio(s).

For more on IntersectionObserverEntry object, see:
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#targeting_an_element_to_be_observed

IntersectionObserverEntry.time               // Timestamp when the change occurred
IntersectionObserverEntry.rootBounds         // Unclipped area of root
IntersectionObserverEntry.intersectionRatio  // Ratio of intersectionRect area to boundingClientRect area
IntersectionObserverEntry.target             // the Element target
IntersectionObserverEntry.boundingClientRect // target.boundingClientRect()
IntersectionObserverEntry.intersectionRect   // boundingClientRect, clipped by its containing block ancestors, and intersected with rootBounds

THRESHOLD:
Intersection ratio/threshold can be an array, and then it will trigger on each value, when in and when out.
If root element's size, for example, is only 10% of the target element's size, then intersection ratio/threshold can't be set to more than 10% (that is 0.1).

CALLBACKS:
There can be created two functions; when the target is entering and when it's exiting. These functions can do what's required for each event (visible/invisible).
Each function is passed three arguments, the root (html) element, IntersectionObserverEntry object, and intersectionObserver options used for that observer.

Set only root and targets to only have some info in the browser's console.

For more info on IntersectionObserver see: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

Polyfill: <script src="https://polyfill.io/v3/polyfill.js?features=IntersectionObserver"></script>
or:
https://github.com/w3c/IntersectionObserver/tree/main/polyfill


Based on answer by Quentin D, answered Oct 27 '20 at 12:12
https://whosebug.com/questions/57834100/change-style-header-nav-with-intersection-observer-io

root     - (any selector) - root element, intersection parent (only the first element is selected).
targets  - (any selector) - observed elements that trigger function when visible/invisible.
inCb     - (function name) - custom callback function to trigger when the target is intersecting.
outCb    - (function name) - custom callback function to trigger when the target is not intersecting.
thres    - (number 0-1) - threshold to trigger the observer (e.g. 0.1 will trigger when 10% is visible).
unobserve- (bolean) - if true, the target is unobserved after triggering the callback.

EXAMPLE:
(place in 'load' event listener, to have the correct dimensions)

var invertedHeader = new Observer({
   root: '.header--main', // don't set to have the viewport as root
   targets: '[data-bgd-dark]',
   thres: [0, .16],
   inCb: someCustomFunction,
});
*/

class Observer {
   constructor({
      root = false,
      targets = false,
      inCb = this.isIn,
      outCb = this.isOut,
      thres = 0,
      unobserve = false,
   } = {}) {
      // this element's position creates with rootMargin the area in the document
      // which is used as intersection observer's root area.
      // the real root is allways the document.
      this.area = document.querySelector(root); // intersection area
      this.targets = document.querySelectorAll(targets); // intersection targets
      this.inCallback = inCb; // callback when intersecting
      this.outCallback = outCb; // callback when not intersecting
      this.unobserve = unobserve; // unobserve after intersection
      this.margins; // rootMargin for observer
      this.windowW = document.documentElement.clientWidth;
      this.windowH = document.documentElement.clientHeight;

      // intersection is being checked like:
      // if (entry.isIntersecting || entry.intersectionRatio >= this.ratio),
      // and if ratio is 0, "entry.intersectionRatio >= this.ratio" will be true,
      // even for non-intersecting elements, therefore:
      this.ratio = thres;
      if (Array.isArray(thres)) {
         for (var i = 0; i < thres.length; i++) {
            if (thres[i] == 0) {
               this.ratio[i] = 0.0001;
            }
         }
      } else {
         if (thres == 0) {
            this.ratio = 0.0001;
         }
      }

      // if root selected use its position to create margins, else no margins (viewport as root)
      if (this.area) {
         this.iArea = this.area.getBoundingClientRect(); // intersection area
         this.margins = `-${this.iArea.top}px -${(this.windowW - this.iArea.right)}px -${(this.windowH - this.iArea.bottom)}px -${this.iArea.left}px`;
      } else {
         this.margins = '0px';
      }

      // Keep this last (this.ratio has to be defined before).
      // targets are required to create an observer.
      if (this.targets) {
         window.addEventListener('resize', () => this.resetObserver());
         this.resetObserver();
      }
   }

   resetObserver() {
      if (this.observer) this.observer.disconnect();

      const options = {
         root: null, // null for the viewport
         rootMargin: this.margins,
         threshold: this.ratio,
      }

      this.observer = new IntersectionObserver(
         entries => this.observerCallback(entries, options),
         options,
      );

      this.targets.forEach((target) => this.observer.observe(target));
   };

   observerCallback(entries, options) {
      entries.forEach(entry => {
         // "entry.intersectionRatio >= this.ratio" for older browsers
         if (entry.isIntersecting || entry.intersectionRatio >= this.ratio) {
            // callback when visible
            this.inCallback(this.area, entry, options);

            // unobserve
            if (this.unobserve) {
               this.observer.unobserve(entry.target);
            }
         } else {
            // callback when hidden
            this.outCallback(this.area, entry, options);
            // No unobserve, because all invisible targets will be unobserved automatically
         }
      });
   };

   isIn(rootElmnt, targetElmt, options) {
      if (!rootElmnt) {
         console.log(`IO Root: VIEWPORT`);
      } else {
         console.log(`IO Root: ${rootElmnt.tagName} class="${rootElmnt.classList}"`);
      }
      console.log(`IO Target: ${targetElmt.target.tagName} class="${targetElmt.target.classList}" IS IN (${targetElmt.intersectionRatio * 100}%)`);
      console.log(`IO Threshold: ${options.threshold}`);
      //console.log(targetElmt.rootBounds);
      console.log(`============================================`);
   }
   isOut(rootElmnt, targetElmt, options) {
      if (!rootElmnt) {
         console.log(`IO Root: VIEWPORT`);
      } else {
         console.log(`IO Root: ${rootElmnt.tagName} class="${rootElmnt.classList}"`);
      }
      console.log(`IO Target: ${targetElmt.target.tagName} class="${targetElmt.target.classList}" IS OUT `);
      console.log(`============================================`);
   }
}