Search code examples
javascriptcssanimationeasingscroll-snap

Determine if a snap-scroll element's snap scrolling event is complete


Abstract

I am creating an image gallery using a scrollable element. I am using CSS' scroll-snap feature, which allows me to snap onto the elements (images) in the scroller.

By binding to the element's scroll event, I am applying various actions when the user is scrolling the element (things like preloading, hiding interface elements, etc). One of these is dependent on the scrolling event and needs to stop at the exact moment scrolling is completed. But scroll-snapping presents me with an unforeseen, and yet un-handled, situation;

I can't accurately determine if the snap-scrolling action is complete.

I can set a setTimeout on each scroll, which cancels itself and re-sets - effectively debouncing - and finally does get called if not reset. But the timeout used when setting this, can mean you are 'too late' when determining scrolling is done.

Bottom line: how do I check if scrolling is done, either because:

  1. The user has stopped scrolling, or;
  2. The scroller has reached its snapping point (scroll-snap-type is set)

Solution

  • I have finally, definitively, solved this brainteaser. It was much more simple to solve than I originally thought. (Note: in my case it's for a horizontal scroller; change offsetWidth to offsetHeight and scrollLeft to scrollTop in the example to adapt for a vertical scroller.)

    function scrollHandler(e) {
        var atSnappingPoint = e.target.scrollLeft % e.target.offsetWidth === 0;
        var timeOut         = atSnappingPoint ? 0 : 150; //see notes
    
        clearTimeout(e.target.scrollTimeout); //clear previous timeout
    
        e.target.scrollTimeout = setTimeout(function() {
            console.log('Scrolling stopped!');
        }, timeOut);
    }
    
    myElement.addEventListener('scroll', scrollHandler);
    

    Breakdown

    By using the scrolling element's own width (or height in case of vertical scroll) we can calculate if it has reached its snapping point by dividing the element's scrollposition (scrollLeft in my case) by its width (offsetWidth), and if that produces a round integer (meaning: the width 'fits' the scrolling position exactly x times) it has reached the snapping point. We do this by using the remainder operator:

    var atSnappingPoint = e.target.scrollLeft % e.target.offsetWidth === 0;
    

    Then, if snapping point is reached, you set the timeOut (used in the setTimeout that should fire when scrolling has finished) to 0. Otherwise, the regular value is used (in the above example 150, see notes).

    This works because when the element actually reaches its snapping point, one last scroll event is fired, and our handler is fired (again). Adjusting the timeOut to 0 will then instantly (see mdn) call our timeout function; so when the scroller 'hits' the snapping point, we know that instantaneously.

    Demo

    Below is a working example:

    function scrollHandler(e) {
        var atSnappingPoint = e.target.scrollLeft % e.target.offsetWidth === 0;
        var timeOut         = atSnappingPoint ? 0 : 150; //see notes
    
        clearTimeout(e.target.scrollTimeout); //clear previous timeout
    
        e.target.scrollTimeout = setTimeout(function() {
            //using the timeOut to evaluate scrolling state
            if (!timeOut) {
                console.log('Scroller snapped!');
            } else {
                console.log('User stopped scrolling.');
            }
        }, timeOut);
    }
    
    myElement = document.getElementById('scroller');
    
    myElement.addEventListener('scroll', scrollHandler);
    .scroller {
      display: block;
      width: 400px;
      height: 100px;
      
      overflow-scrolling: touch;
      -webkit-overflow-scrolling: touch;
      overflow-anchor: none;
      overflow-x: scroll;
      overflow-y: hidden;
    
      scroll-snap-type: x mandatory;
      scroll-snap-stop: normal;
      scroll-behavior: auto;
     }
     .scroller-canvas {
      position: relative;
      display: flex;
      flex-direction: row;
      flex-wrap: nowrap;
     }
     .scroller-canvas > * {
      position: relative;
      display: inline-flex;
      flex-flow: column;
      flex-basis: 100%;
      flex-shrink: 0;
      
      width: 400px;
      height: 100px;
      
      scroll-snap-align: start;
      scroll-snap-stop: normal;
     }
     .scroller-canvas > *:nth-child(even) {
      background-color: #666;
      color: #FFF;
     }
    
    /* stackoverflow code wrapper fix */
    .as-console-wrapper { max-height: 50px !important; }
    <div class="scroller" id="scroller">
      <div class="scroller-canvas">
        <div class="slide" id="0">Slide 1</div>
        <div class="slide" id="1">Slide 2</div>
        <div class="slide" id="2">Slide 3</div>
        <div class="slide" id="3">Slide 4</div>
        <div class="slide" id="4">Slide 5</div>
      </div>
    </div>


    Notes

    scrolling over snapping point While it is possible to 'hit' the snapping point by scrolling past it, this can be mitigated by (calculating and) taking scrolling speed into consideration when setting the timeOut, e.g. keep it at 150 when speed is not near zero.

    pixel ratio: If you get messed up calculations (remainder of 1 px, etc, so the function is not resolving correctly), you probably have some scaling/box-model issues that mess up the calculation of both the scrolling position and the offsetWidth calculation. There is an off-chance this is caused by the device's pixel ratio, so you can try to 'correct' the values (scrollleft and width) by multiplying these by the pixelratio. Important: turns out pixel ratio does not only indicate if the user has a high-dpi screen, but it also changes when the user has zoomed the page.

    timeout the arbirtrary timeOut used when scrolling has not yet reached snapping point is at 150. This is long enough to prevent it being fired before Safari @ iOS is done scrolling (it uses a bezier curve for scroll snapping, which produces a very long 'last frame' of around 120-130ms) and short enough to produce an acceptible result when the user pauses scrolling in between snapping points.

    scroll-padding if you have set scroll-padding on the scroll element, you will need to take that into account when determining the snapping point.

    pixels remaining: You could even break things down further, to calculate the pixels remaining before reaching snapping point:

    var pxRemain = e.target.scrollLeft % e.target.offsetWidth;
    var atSnappingPoint = pxRemain === 0;
    

    But note that you will need to subtract that from the element's width, depending on which way you are scrolling. This requires you to calculate the distance scrolled and check if that is negative or positive. Then it would become:

    var distance = e.target.scrollLeft - (e.target.prevScrollLeft ? e.target.prevScrollLeft : 0);
    var pxRemain = e.target.scrollLeft % e.target.offsetWidth;
        pxRemain = (pxRemain === 0) ? 0 : ((distance > 0) ? pxRemain : elementWidth - pxRemain);
    var atSnappingPoint = pxRemain === 0;
    //store scroll position for next iteration, to calculate distance
    e.target.prevScrollLeft = e.target.scrollLeft;
    

    Only snapping

    This script is written so it takes into account two situations:

    1. the element has snapped to its snapping point, or;
    2. the user has paused scrolling (or snapping detection has somehow gone wrong)

    If you only need the former, you don't need the timeout, and you can just write:

    function scrollHandler(e) {
      if (e.target.scrollLeft % e.target.offsetWidth === 0) {
        console.log('Scrolling is done!');
      }
    }
    
    myElement.addEventListener('scroll', scrollHandler);