当position:sticky被触发时检测事件

112

我正在使用新的 position: sticky (信息) 来创建一个类似于 iOS 的内容列表。

它运行良好,并且比以前的 JavaScript 解决方案 (示例) 要好得多,但据我所知,当触发时没有事件被触发,这意味着我不能在栏目到达页面顶部时进行任何操作,这与先前的解决方案不同。

我想在具有 position: sticky 属性的元素到达页面顶部时添加一个类 (例如 stuck)。是否有一种方式可以使用 JavaScript 监听此事件?使用 jQuery 也可以。


2
很有趣,因为那篇文章的最受欢迎评论恰好解决了你的问题。那个人说得非常准确,应该使用媒体查询而不是属性。这样,当元素被卡住时(我们经常这样做),就可以更改样式。哦,好吧,一个人可以做梦。 - Christian
1
是的,我注意到了那个评论,他的建议似乎更好。不过,position: sticky 是Chrome实现的,所以我正在寻找一种可用的方法! - AlecRust
2
你找到解决方案了吗? - Ric
如果有人通过谷歌搜索到这里,其中一位谷歌工程师提供了一种使用IntersectionObserver、自定义事件和哨兵的解决方案:https://developers.google.com/web/updates/2017/09/sticky-headers - Scott L
5
我是傻瓜吗?!第一位评论者在谈论哪篇文章?! - katerlouis
2
@katerlouis 不是!我认为这是链接失效或评论被删除的情况。 - Brian Zelip
12个回答

142

使用IntersectionObserver技巧的Demo:

// get the sticky element
const stickyElm = document.querySelector('header')

const observer = new IntersectionObserver( 
  ([e]) => e.target.classList.toggle('isSticky', e.intersectionRatio < 1),
  {threshold: [1]}
);

observer.observe(stickyElm)
body{ height: 200vh; font:20px Arial; }

section{
  background: lightblue;
  padding: 2em 1em;
}

header{
  position: sticky;
  top: -1px;                       /* ➜ the trick */

  padding: 1em;
  padding-top: calc(1em + 1px);    /* ➜ compensate for the trick */

  background: salmon;
  transition: .1s;
}

/* styles for when the header is in sticky mode */
header.isSticky{
  font-size: .8em;
  opacity: .5;
}
<section>Space</section>
<header>Sticky Header</header>

top值需为-1px,否则元素将永远不会与浏览器窗口顶部相交(从而永远不会触发intersection observer)。

为了抵消这1px的隐藏内容,应该在粘性元素的边框或填充中添加额外的1px空间。

或者,如果您希望保持CSS不变(top:0),则可以通过在intersection observer级别上添加设置rootMargin:'-1px 0px 0px 0px'来应用“校正”(如@mattrick在他的答案中所示)。

使用老式scroll事件监听器的演示:

  1. 自动检测第一个可滚动父元素
  2. 限制滚动事件的速率
  3. 函数组合以进行关注分离
  4. 事件回调缓存:scrollCallback(以便在需要时解除绑定)

// get the sticky element
const stickyElm = document.querySelector('header');

// get the first parent element which is scrollable
const stickyElmScrollableParent = getScrollParent(stickyElm);

// save the original offsetTop. when this changes, it means stickiness has begun.
stickyElm._originalOffsetTop = stickyElm.offsetTop;


// compare previous scrollTop to current one
const detectStickiness = (elm, cb) => () => cb & cb(elm.offsetTop != elm._originalOffsetTop)

// Act if sticky or not
const onSticky = isSticky => {
   console.clear()
   console.log(isSticky)
   
   stickyElm.classList.toggle('isSticky', isSticky)
}

// bind a scroll event listener on the scrollable parent (whatever it is)
// in this exmaple I am throttling the "scroll" event for performance reasons.
// I also use functional composition to diffrentiate between the detection function and
// the function which acts uppon the detected information (stickiness)

const scrollCallback = throttle(detectStickiness(stickyElm, onSticky), 100)
stickyElmScrollableParent.addEventListener('scroll', scrollCallback)



// OPTIONAL CODE BELOW ///////////////////

// find-first-scrollable-parent
// Credit: https://dev59.com/iFsV5IYBdhLWcg3wrAZJ#42543908
function getScrollParent(element, includeHidden) {
    var style = getComputedStyle(element),
        excludeStaticParent = style.position === "absolute",
        overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/;

    if (style.position !== "fixed") 
      for (var parent = element; (parent = parent.parentElement); ){
          style = getComputedStyle(parent);
          if (excludeStaticParent && style.position === "static") 
              continue;
          if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) 
            return parent;
      }

    return window
}

// Throttle
// Credit: https://jsfiddle.net/jonathansampson/m7G64
function throttle (callback, limit) {
    var wait = false;                  // Initially, we're not waiting
    return function () {               // We return a throttled function
        if (!wait) {                   // If we're not waiting
            callback.call();           // Execute users function
            wait = true;               // Prevent future invocations
            setTimeout(function () {   // After a period of time
                wait = false;          // And allow future invocations
            }, limit);
        }
    }
}
header{
  position: sticky;
  top: 0;

  /* not important styles */
  background: salmon;
  padding: 1em;
  transition: .1s;
}

header.isSticky{
  /* styles for when the header is in sticky mode */
  font-size: .8em;
  opacity: .5;
}

/* not important styles*/

body{ height: 200vh; font:20px Arial; }

section{
  background: lightblue;
  padding: 2em 1em;
}
<section>Space</section>
<header>Sticky Header</header>


这是一个React组件演示,它使用了第一种技术


3
好的,如果设置 top: 0,那么它将永远不会与浏览器窗口的顶部相交。设置 top: -1px 将允许与顶部有 1px 的重叠(尽管内容中的 1px 将不可见,因此需要进行补偿)。聪明。 - Daniel Tonon
3
你可以使用border-top: 1px solid transparent来避免使用calc函数。 - Daniel Tonon
2
我做了一些编辑。我澄清了“技巧”的含义以及为什么需要它。我还为人们澄清了哪些CSS部分是必需的,哪些是多余的(出于清晰起见,我考虑删除不必要的内容)。最后,将“演示”一词变成链接,使人们更容易用更直观的方式访问它,而不是滚动到答案的末尾来访问实时演示。所有这些都使答案对人们更有用。 - Daniel Tonon
3
问题在于,如果元素继续向下滚动并超出视图范围,isSticky也会被应用。如果您依赖该类来在滚动时显示/隐藏内容,则可能会导致问题和内容闪烁。 - Flowgram
2
解决方法如下:e.target.classList.toggle('isSticky', e.boundingClientRect.top < 0) - Flowgram
显示剩余14条评论

64

我发现一种与@vsync的答案有些类似的解决方案,但它不需要您在样式表中添加的“hack”。您只需更改IntersectionObserver的边界就可以避免移动元素本身到视口之外:

const observer = new IntersectionObserver(callback, {
  rootMargin: '-1px 0px 0px 0px',
  threshold: [1],
});

observer.observe(element);

11
你能举个例子吗?这对我似乎不起作用。 - Sơn Trần-Nguyễn
3
在我看来,这是最优雅的答案。 - rx2347
1
当我的粘性元素在右侧时,它可以正常工作,但当粘性元素在左侧时(水平滚动,根据实际情况调整了rootMargins),它就无法正常工作。当它在左侧时,它总是相交的,因此我永远无法确定何时会粘住。根边距没有任何影响。 - Fygo
混合了垂直同步响应;完美运作 :) - Edwin Joassart
3
这个答案对于页面顶部紧贴着的元素是不起作用的,例如导航栏。它将始终被视为已激活。 - Dan
显示剩余8条评论

27

5
这个回答解释不够清楚,但是链接提供了一个非常好的答案,如果用户可以使用仅适用于Chrome的解决方案。 - Wagner Danda da Silva Filho
9
需要使用纯CSS方案,不要用JavaScript。 - Green
4
@Green 一个纯CSS的解决方案会很棒,但目前还没有。此外,提问者要求JavaScript解决方案,因此这应该是被接受的答案。 - Mickäel A.
6
纯CSS的解决方案会违反CSS的一些基本规则。想象一下一个规则导致一个元素变得粘性,但是某个新的:stuck选择器改变了CSS使其不再粘性。你会进入一个无限循环。CSS被精心设计以避免这种情况发生。 - Perry
1
CSS 显然并不是经过精心设计的,这一点从伴随其各种(例如模块)规范的脚注中就可以看出来,这些脚注事实上纠正了自 CSS 诞生以来所犯的一系列错误。而且,无论 CSS 是否能够解决“无限”循环问题,这都是如此。已经有许多情况导致布局出现“跳跃”——例如,:hover 规则重置元素的大小,导致 :hover 不再适用,然后再次重置大小,再次应用 :hover,如此反复。:stuck 不会使任何事情变得更糟。 - Armen Michaeli

3
我想到了一个非常好用且非常小的解决方案。 :)
不需要任何额外的元素。
不过它是在窗口滚动事件上运行的,这是一个小缺点。
apply_stickies()

window.addEventListener('scroll', function() {
    apply_stickies()
})

function apply_stickies() {
    var _$stickies = [].slice.call(document.querySelectorAll('.sticky'))
    _$stickies.forEach(function(_$sticky) {
        if (CSS.supports && CSS.supports('position', 'sticky')) {
            apply_sticky_class(_$sticky)
        }
    })
}

function apply_sticky_class(_$sticky) {
    var currentOffset = _$sticky.getBoundingClientRect().top
    var stickyOffset = parseInt(getComputedStyle(_$sticky).top.replace('px', ''))
    var isStuck = currentOffset <= stickyOffset

    _$sticky.classList.toggle('js-is-sticky', isStuck)
}

注意:此解决方案不考虑具有底部粘性的元素。这仅适用于像粘性标题之类的东西。但可能可以调整以考虑底部粘性。

1
很棒的答案。Google提出的答案很好,但对于大多数情况来说有些过度了。此外,这个方案还支持IE11。 - png
我不会真的称它为IE11支持。更像是如果在IE11中查看,它只是不会导致网站崩溃。IE11不支持position: sticky - Daniel Tonon
1
滚动事件回调应该被“节流”,因为那里有大量的计算。此外,开发人员必须积极清理事件绑定,并在DOM更改时重新绑定,因为固定元素可能会在此函数运行之后添加到DOM中。此外.. 这段代码远非最优,可以通过重构来提高性能和可读性。 - vsync
如果你想要平滑的过渡,就不能进行限流。根据我的经验,它似乎表现得并不差。DOM元素可能会被添加,但大多数情况下并非如此。如果你知道更好的方法来完成同样的事情,欢迎提供你自己的答案。 - Daniel Tonon
1
很好的建议 @Jordan,我已根据您的建议更新了我的答案。 - Daniel Tonon
显示剩余3条评论

2
Chrome 添加了“position: sticky”后,发现其还不够成熟,并被降级为--enable-experimental-webkit-features标志。Paul Irish在二月份表示:“该功能目前处于奇怪的悬置状态。”我曾使用polyfill,但最终放弃了。它有时效果很好,但也存在一些边角情况,如CORS问题,并通过对所有CSS链接进行XHR请求并重新解析它们来减缓页面加载速度,以查找浏览器忽略的“position: sticky”声明。
现在我正在使用ScrollToFixed,比StickyJS更喜欢它,因为它不会用包装器破坏我的布局。

2

目前没有原生解决方案。请参见定位当前处于“粘滞”状态的position:sticky元素。但是我有一个CoffeeScript解决方案,可以与原生的position: sticky和实现粘性行为的polyfill一起使用。

将“sticky”类添加到您想要粘滞的元素中:

.sticky {
  position: -webkit-sticky;
  position: -moz-sticky;
  position: -ms-sticky;
  position: -o-sticky;
  position: sticky;
  top: 0px;
  z-index: 1;
}

CoffeeScript用于监控“粘性”元素的位置,并在它们处于“粘性”状态时添加“stuck”类:
$ -> new StickyMonitor

class StickyMonitor

  SCROLL_ACTION_DELAY: 50

  constructor: ->
    $(window).scroll @scroll_handler if $('.sticky').length > 0

  scroll_handler: =>
    @scroll_timer ||= setTimeout(@scroll_handler_throttled, @SCROLL_ACTION_DELAY)

  scroll_handler_throttled: =>
    @scroll_timer = null
    @toggle_stuck_state_for_sticky_elements()

  toggle_stuck_state_for_sticky_elements: =>
    $('.sticky').each ->
      $(this).toggleClass('stuck', this.getBoundingClientRect().top - parseInt($(this).css('top')) <= 1)

注意:此代码仅适用于垂直黏性位置。


这个解决方案的优点在于(在我看来)它可以处理窗口调整大小和页面重新布局,而不像那些检查并保存 offsetTop 的解决方案。 - BigglesZX

1

我知道这个问题提出已经有一段时间了,但我找到了一个很好的解决方案。插件stickybits在支持时使用position: sticky,并在元素被“粘住”的时候应用一个类。我最近使用它取得了不错的结果,并且在撰写本文时,该插件正在积极开发中(对我来说是一个 plus):)


使用stickybits时需要注意的一点是,当您有其他js监听滚动事件时,它可能无法正常工作。只有在我在不支持position: sticky(ie11,edge)的浏览器中进行测试时,才会出现这些问题。 - Davey
3
这个库有很多问题。 - Vahid Amiri
它已经被分叉并在此处得到维护(https://github.com/yowainwright/stickybits),我相当喜欢这个选项。 - AlecRust

1
你可以非常简单地这样做:
  1. 添加滚动事件监听器
  2. 通过getComputedStyle()获取元素的所需粘性top位置
  3. 使用getBoundingClientRect()获取元素的当前视口偏移量
  4. 如果两个值匹配,则使用classList.toggle()添加类
以下是代码示例:
const el = document.querySelector(".my-sticky-element");
window.addEventListener("scroll", () => {
    const stickyTop = parseInt(window.getComputedStyle(el).top);
    const currentTop = el.getBoundingClientRect().top;
    el.classList.toggle("is-sticky", currentTop === stickyTop);
});

超级简单/优雅的解决方案 - undefined

0

我在我的主题中使用这个片段来添加.is-stuck类到.site-header当它处于固定位置时:

// noinspection JSUnusedLocalSymbols
(function (document, window, undefined) {

    let windowScroll;

    /**
     *
     * @param element {HTMLElement|Window|Document}
     * @param event {string}
     * @param listener {function}
     * @returns {HTMLElement|Window|Document}
     */
    function addListener(element, event, listener) {
        if (element.addEventListener) {
            element.addEventListener(event, listener);
        } else {
            // noinspection JSUnresolvedVariable
            if (element.attachEvent) {
                element.attachEvent('on' + event, listener);
            } else {
                console.log('Failed to attach event.');
            }
        }
        return element;
    }

    /**
     * Checks if the element is in a sticky position.
     *
     * @param element {HTMLElement}
     * @returns {boolean}
     */
    function isSticky(element) {
        if ('sticky' !== getComputedStyle(element).position) {
            return false;
        }
        return (1 >= (element.getBoundingClientRect().top - parseInt(getComputedStyle(element).top)));
    }

    /**
     * Toggles is-stuck class if the element is in sticky position.
     *
     * @param element {HTMLElement}
     * @returns {HTMLElement}
     */
    function toggleSticky(element) {
        if (isSticky(element)) {
            element.classList.add('is-stuck');
        } else {
            element.classList.remove('is-stuck');
        }
        return element;
    }

    /**
     * Toggles stuck state for sticky header.
     */
    function toggleStickyHeader() {
        toggleSticky(document.querySelector('.site-header'));
    }

    /**
     * Listen to window scroll.
     */
    addListener(window, 'scroll', function () {
        clearTimeout(windowScroll);
        windowScroll = setTimeout(toggleStickyHeader, 50);
    });

    /**
     * Check if the header is not stuck already.
     */
    toggleStickyHeader();


})(document, window);


由于您目前的回答写得不够清晰,请[编辑]以添加更多详细信息,以帮助他人了解如何解决所提出的问题。您可以在帮助中心找到有关编写良好答案的更多信息。 - Community

0

只需使用原生JS。您还可以使用lodash的节流函数来预防一些性能问题。

const element = document.getElementById("element-id");

document.addEventListener(
  "scroll",
  _.throttle(e => {
    element.classList.toggle(
      "is-sticky",
      element.offsetTop <= window.scrollY
    );
  }, 500)
);


16
使用下划线"_ "并不是纯粹的JS。 - Sơn Trần-Nguyễn
1
@SơnTrần-Nguyễn 如我所述,这是一个lodash函数,用于防止可能的性能问题。 - Jassim Abdul Latheef
window.addEventListener - Kunukn

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接