Bootstrap 3.0 scrollspy responsive offsets
Asked Answered
F

3

8

I'm trying to get Bootstrap's scrollspy to work reliably on a responsive site on which the top navbar's height changes according to the width of the media/browser. So instead of hardcoding the offset on the data-offset attribute I'm setting it dynamically through Javascript initialization like this:

$('body').scrollspy({ offset: 70, target: '#nav' });

For wide layouts (i.e. Bootstrap -lg) it works fine but for narrower layouts there seems to be an "accumulating" offset in effect. In other words, it works fine for the first section but then takes increasing pixels to activate the following ones (e.g. 90px for the next, 110px for the 3rd, etc.).

I tried manipulating the scrollspy object directly as mentioned in this answer: How do I set the offset for ScrollSpy in Bootstrap? but to no avail.

Can someone recommend a canonical way of implementing scrollspy in a responsive site where the offset varies according to the media width?

Additional Information:

I just analyzed the scrollspy object in both scenarios and it turns out that the list of offsets is different when it's initialized through data- attributes only vs. via JS. It seems like when I initialize it via JS the offsets array gets populated before some of BS responsive adjustments happen and therefore the heights are different. How can I trigger scrollspy's initialization after all the responsive stuff has run? Is there a hook/callback after BS is done adjusting the layout? Is JS even involved or is all the responsive stuff handled by CSS?

Flor answered 14/8, 2013 at 23:50 Comment(5)
Interestingly, if I let scrollspy initialize itself from the data- attributes it works fine. Only when I do it from Javascript do I get the "accumulating" offset effect. Somehow initializing scrollspy from JS like in the snippet above is not equivalent to letting it do it from the data- attributes. Haven't figured out why yet.Flor
Responsiveness is handled by CSS only (unless you have respond.js in IE8 for compatibility). Are you wrapping your js code inside the document ready handler?Cover
Yes, I'm calling $('body').scrollspy({...}) inside of $(document).ready(function() {. But as I mentioned above, doing it this way creates a different set of offsets compared to letting it initialize by itself with the data- attributes.Flor
maybe the $(document).ready make the difference here. The plugin use $(window).on('load' to set the data- attributes. See also: #4396280Podvin
I think you're right, that's probably the key. According to that comment, $(document).ready happens earlier (often much earlier) than the$(window) load event. So it's very likely that the heights haven't been fully adjusted yet. One option is to try hooking the offsets on the latter callback instead, but at this point I have opted for a completely different approach, adjusting CSS padding, etc. Thank you all for your help and insights.Flor
H
18

Unfortunately jQuery pulls in the data-* attributes only once when the $().data(...) function is called the first time. And scrollspy only reads the options once when it is initialised. The refresh function of scrollspy does not reload any of the options. Calling $(..).scrollspy(...) again on the same element ignores any new data options (uses the previously initialized values).

However, scrollspy does store the options values in the elements data under the key 'bs.scrollspy'. So you can alter the options.offset field inside that data key.

To account for a dynamically changing navbar height and the need to alter the scrollspy offset value, you can use the following example for a variable height fixed-top navbar.

The following does a few of things.

  • It initializes scrollspy via javascript (after the window.load event fires), and starts off with an offset of the navbar's current height (also adjusts the body's padding-top value to be the same as the navbar height).
  • Resize events are monitored and the body's padding-top is adjusted, and the scrollspy offset is tweaked to match. Then a refresh is performed to recalculate the anchor offsets.

HTML

<body>
  <style type="text/css">
    /* prevent navbar from collapsing on small screens */
    #navtop .navbar-nav > li { float: left !important; }
  </style>
  <nav id="navtop" class="navbar-fixed-top" role="navigation">
    <div class="container">
      <ul class="nav nav-pills navbar-nav">
        <li><a href="#one">One</a></li>
        <li><a href="#two">Two</a></li>
        <li><a href="#three">Three</a></li>
      ... some more navlinks, or dynamically added links that affect the height ...
      </ul>
    </div>
  </nav>
  <section id="one">
   ...
  </section>
  <section id="two">
   ...
  </section>
  <section id="three">
   ...
  </section>
   ....
</body>

JavaScript

$(window).on('load',function(){
    var $body   = $('body'), 
        $navtop = $('#navtop'),
        offset  = $navtop.outerHeight();

    // fix body padding (in case navbar size is different than the padding)
    $body.css('padding-top', offset);
    // Enable scrollSpy with correct offset based on height of navbar
    $body.scrollspy({target: '#navtop', offset: offset });

    // function to do the tweaking
    function fixSpy() {
        // grab a copy the scrollspy data for the element
        var data = $body.data('bs.scrollspy');
        // if there is data, lets fiddle with the offset value
        if (data) {
            // get the current height of the navbar
            offset = $navtop.outerHeight();
            // adjust the body's padding top to match
            $body.css('padding-top', offset);
            // change the data's offset option to match
            data.options.offset = offset;
            // now stick it back in the element
            $body.data('bs.scrollspy', data);
            // and finally refresh scrollspy
            $body.scrollspy('refresh');
        }
    }

    // Now monitor the resize events and make the tweaks
    var resizeTimer;
    $(window).resize(function() {
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(fixSpy, 200);
    });
});

And thats it. Not pretty, but it definitely works. My HTML above may need some tweaking.

If you add elements to your navbar dynamically you will need to call fixSpy() after they are inserted. Or you could call fixSpy() via a setInterval(...) timer to run all the time.

Huntington answered 12/1, 2014 at 6:41 Comment(2)
Great, it worked, but why the setTimeout()? I've tried similar fixes, only to find that by the time my JS comes to changing the offset in the data object, it doesn't exist yet. Though using your code seems to get round that.Alchemist
The setTimeout is used to "de-bounce/throttle" the blast of resize events that the browser fires as the window is being resized. Without the set timeout, depending on the browser, the actual resizing of the window may become sluggish for larger sized document.Huntington
P
2

Scrollspy set a list of targets / offset after initialization. If you resize your screen scrollspy is not initialized again. You will have reload your page to recalculate the offsets.

The "accumulating" offset in effect you mention is cause by the same list of offsets with different content heights.

To could also trigger this reload with $(window).resize() note some browser will fire this twice, see https://mcmap.net/q/102563/-jquery-how-to-call-resize-event-only-once-it-39-s-finished-resizing for a solution:

var id; 

$(window).resize(function() 
{
    clearTimeout(id);
    id = setTimeout($('body').scrollspy({ target: '' }), 500);
});

Note the docs on http://getbootstrap.com/javascript/#scrollspy tell you something similar:

"When using scrollspy in conjunction with adding or removing of elements from the DOM, you'll need to call the refresh method "

With the above, you will get something like:

var id; 

$(window).resize(function() 
{
    clearTimeout(id);
    id = setTimeout($('[data-spy="scroll"]').each(function () {var $spy = $(this).scrollspy('refresh'}), 500);
});

NOTE: The scollspy plugin uses jQuery's scrollTop() function for calculations. So also read this: http://blog.jonathanargentiero.com/?p=134.

Podvin answered 15/8, 2013 at 11:37 Comment(1)
Thank you for he detailed response and information Bass. In my case, however, the DOM is not changing and the window is not being resized. I just have a navbar that is rendered with different heights for different viewport widths. So if you load the page on a wide-screen desktop, the navbar is 90px tall. On an iPad it'll be 70px tall and for smartphones it will be 60px. This all happens with responsive CSS. So I need to set up scrollspy with different offsets for each of these scenarios. Doing it from JS is failing with the problem I describe above.Flor
R
1

I'm aware that the original question asks for BS3 but I did not find a correct and easy answer for BS4. Hence I'm now attaching my solution which works perfectly for me.

As of BS4 the configuration location changed. If you are searching for the correct spot to directly change the offset on the fly. Here it is: $('body').data('bs.scrollspy')._config.offset. Additionally if you want that the direct change takes effect. Call $('body').scrollspy('refresh'); afterwards. Now the offset is respected by scrollspy.

Based on this approach I wrote a little snippet which might help you in adapting the offset dynamically for a specific navigation container (e.g. BS4 navbar).

var initScrollSpy = function() {
  var navContainer = '#mainNav'; // your navigation container

  // initial bind of scrollspy
  var bindScrollSpy = function() {
      $('body').scrollspy({
          target: navContainer,
          offset: getOffset() // determine your offset
      });
  }

  // get your offset dynamically
  var getOffset = function() {
      return $(navContainer).outerHeight();
  };

  // update the offset but only if the container size changed
  var updateOffset = function() {
      var cfg = $('body').data('bs.scrollspy')._config;
      if(cfg.offset != getOffset()){
          cfg.offset = getOffset();
          $('body').scrollspy('refresh');
      }
  }

  bindScrollSpy(); // bind scrollspy 
  $(window).resize(updateOffset); // react on resize event
  $(".collapse").on('shown.bs.collapse', updateOffset); // react on BS4 menu shown event (only on mobile). You might omit this line if your navigation has no change in height when opened on mobile
};

initScrollSpy(); // finally call snippet

I attached the explanations of the snippet within the code. But generally I bind the scrollspy offset as you normally would do. Additionally, we have two events. One that reacts on window resize and one that reacts on navbar fully shown. In case of both events I check if the offset value differs from the desired one and update it directly within scrollspy and that's it.

Rechabite answered 23/2, 2020 at 12:28 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.