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:
- the element has snapped to its snapping point, or;
- 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);