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

2

10

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)
Earlie answered 29/1, 2021 at 9:43 Comment(0)
E
28

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);
Earlie answered 3/2, 2021 at 14:34 Comment(2)
The user can technically still not leave their fingers from a trackpad and hold still, yet align so the remainder could be 0... which means "scrolling is done" would trigger even though it wasn't done.Difficile
In addition, it only works if each slide is 100% of the width, or if each slide is a fixed width. In order to work with variable width slides, the pxRemain needs to sum the previous siblings (and hope there are no order CSS overrides lol)Difficile
D
3

The scrollend event is sufficient.

document.querySelector('#cont').addEventListener('scrollend', () => console.log('snap ended'))
#cont {
  width: 100%;
  height: 100px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
}

div > div {
  scroll-snap-align: start;
display:flex;
justify-content:center;
align-items:center;
border:solid red 2px;
box-sizing:border-box;
}
<div id='cont'>
  <div>1</div>
  <div>2</div>
  <div>3</div>
</div>
Disused answered 11/7, 2023 at 7:31 Comment(4)
Thanks! This works fine, but keep in mind that scrollend support has only recently been introduced to major browsers, and not at the time of writing the original question. Support is still not very broad, at 58% (caniuse.com/?search=scrollend), so falling back to my method (as a polyfill, if you will) would still cover that.Earlie
@KlaasLeussink i just start coding again, so i had no idea that it was newer, even though there is so much new functionality nowDisused
Welcome back, you're going to love all of it ;-)Earlie
@KlaasLeussink ty sir! enjoying it so far and looking forward to the rest of it :DDisused

© 2022 - 2024 — McMap. All rights reserved.