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 scrollPosition = Math.round(e.target.scrollLeft * devicePixelRatio);
var elementWidth = Math.round(e.target.offsetWidth * devicePixelRatio);
var atSnappingPoint = scrollPosition % elementWidth === 0;
var timeOut = atSnappingPoint ? 0 : 150; //see notes
clearTimeout(e.target.scrollTimeout);
e.target.scrollTimeout = setTimeout(function() {
console.log('Scrolling is done!');
}, 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 = scrollPosition % elementWidth === 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.
Notes
scrolling over snapping point I have not yet implemented/taken into account the fact that you can 'hit' the snapping point by scrolling over it. This is extremely rare, though. I will update the answer when I find a solution.
pixel ratio: Note that I take the device's pixel ratio into account. This is an important factor, which was the cause of an issue that took me some time to find out:
It messes up the calculation of both the scrolling position and the offsetWidth calculation, so we need to 'revert' back to the values at ratio 1, by multiplying these values 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 = scrollPosition % elementWidth;
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 checking if that is negative or positive. Then it would become:
var pxRemain = scrollPosition % elementWidth;
pxRemain = (pxRemain === 0) ? 0 : ((distance > 0) ? pxRemain : elementWidth - pxRemain);
var atSnappingPoint = pxRemain === 0;
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) {
var scrollPosition = Math.round(e.target.scrollLeft * devicePixelRatio);
var elementWidth = Math.round(e.target.offsetWidth * devicePixelRatio);
if (scrollPosition % elementWidth === 0) {
console.log('Scrolling is done!');
}
}
myElement.addEventListener('scroll', scrollHandler);