I have built a drag-drop autoscroller where the user drags and element over a hidden div
which triggers the scrolling action of the scrollable div
. I am using scrollBy({top: <val>, behavior: 'smooth'}
to get smooth scrolling and requestAnimationFrame
to prevent the function from calling too often. This works fine in Firefox and should be supported in Chrome natively according to caniuse; however, it fails to work properly in chrome. It only fires the event once when the user leaves the hidden div
. No errors in the console. console.log()
indicates that the function containing the scrollBy()
is being called. If I remove behavior: 'smooth'
it works, but of course no smooth scrolling. same result if I remove the option and set the css scroll-behavior: smooth
on the scrollable div. I'm at a complete loss. MWE of the scroll function (this is in a Vue app, so any this.
's are stored in a data object.
scroll: function () {
if ( this.autoScrollFn ) cancelAnimationFrame( this.autoScrollFn )
// this.toScroll is a reference to the HTMLElement
this.toScroll.scrollBy( {
top: 100,
behavior: 'smooth'
}
this.autoscrollFn = requestAnimationFrame( this.scroll )
}
Not sure what you did expect from your requestAnimationFrame
call to do here, but here is what should happen:
scrollBy
having its behavior set to smooth
should actually start scrolling the target element only at next painting frame, just before the animation frames callback get executed (step 7 here).
Just after this first step of the smooth scrolling, your animation frame callback will fire (step 11), disabling the first smooth scrolling by starting a new one (as defined here).
repeat until it reaches the top-max, since you are never waiting enough for the smooth 100px scrolling to happen entirely.
This will indeed move in Firefox, until it reaches the end, because this browser has a linear smooth scrolling behavior and scrolls from the first frame.
But Chrome has a more complicated ease-in-out behavior, which will make the first iteration scroll by 0px. So in this browser, you will actually end up in an infinite loop, since at each iteration, you will have scrolled by 0, then disable the previous scrolling and ask again to scroll by 0, etc. etc.
const trigger = document.getElementById( 'trigger' );
const scroll_container = document.getElementById( 'scroll_container' );
let scrolled = 0;
trigger.onclick = (e) => startScroll();
function startScroll() {
// in Chome this will actually scroll by some amount in two painting frames
scroll_container.scrollBy( { top: 100, behavior: 'smooth' } );
// this will make our previous smooth scroll to be aborted (in all supporting browsers)
requestAnimationFrame( startScroll );
scroll_content.textContent = ++scrolled;
};
#scroll_container {
height: 50vh;
overflow: auto;
}
#scroll_content {
height: 5000vh;
background-image: linear-gradient(to bottom, red, green);
background-size: 100% 100px;
}
<button id="trigger">click to scroll</button>
<div id="scroll_container">
<div id="scroll_content"></div>
</div>
So if what you wanted was actually to avoid calling multiple times that scrolling function, your code would be broken not only in Chrome, but also in Firefox (it won't stop scrolling at after 100px there either).
What you need in this case is rather to wait until the smooth scroll ended.
There is already a question here about detecting when a smooth scrollIntoPage
ends, but the scrollBy
case is a bit different (simpler).
Here is a method which will return a Promise letting you know when the smooth-scroll ended (resolving when successfully scrolled to destination, and rejecting when aborted by an other scroll). The basic idea is the same as the one for this answer of mine:
Start a requestAnimationFrame
loop, checking at every steps of the scrolling if we reached a static position. As soon as we stayed two frames in the same position, we assume we've reached the end, then we just have to check if we reached the expected position or not.
With this, you just have to raise a flag until the previous smooth scroll ends, and when done, lower it down.
const trigger = document.getElementById( 'trigger' );
const scroll_container = document.getElementById( 'scroll_container' );
let scrolling = false; // a simple flag letting us know if we're already scrolling
trigger.onclick = (evt) => startScroll();
function startScroll() {
if( scrolling ) { // we are still processing a previous scroll request
console.log( 'blocked' );
return;
}
scrolling = true;
smoothScrollBy( scroll_container, { top: 100 } )
.catch( (err) => {
/*
here you can handle when the smooth-scroll
gets disabled by an other scrolling
*/
console.error( 'failed to scroll to target' );
} )
// all done, lower the flag
.then( () => scrolling = false );
};
/*
*
* Promised based scrollBy( { behavior: 'smooth' } )
* @param { Element } elem
** ::An Element on which we'll call scrollIntoView
* @param { object } [options]
** ::An optional scrollToOptions dictionary
* @return { Promise } (void)
** ::Resolves when the scrolling ends
*
*/
function smoothScrollBy( elem, options ) {
return new Promise( (resolve, reject) => {
if( !( elem instanceof Element ) ) {
throw new TypeError( 'Argument 1 must be an Element' );
}
let same = 0; // a counter
// pass the user defined options along with our default
const scrollOptions = Object.assign( {
behavior: 'smooth',
top: 0,
left: 0
}, options );
// last known scroll positions
let lastPos_top = elem.scrollTop;
let lastPos_left = elem.scrollLeft;
// expected final position
const maxScroll_top = elem.scrollHeight - elem.clientHeight;
const maxScroll_left = elem.scrollWidth - elem.clientWidth;
const targetPos_top = Math.max( 0, Math.min( maxScroll_top, Math.floor( lastPos_top + scrollOptions.top ) ) );
const targetPos_left = Math.max( 0, Math.min( maxScroll_left, Math.floor( lastPos_left + scrollOptions.left ) ) );
// let's begin
elem.scrollBy( scrollOptions );
requestAnimationFrame( check );
// this function will be called every painting frame
// for the duration of the smooth scroll operation
function check() {
// check our current position
const newPos_top = elem.scrollTop;
const newPos_left = elem.scrollLeft;
// we add a 1px margin to be safe
// (can happen with floating values + when reaching one end)
const at_destination = Math.abs( newPos_top - targetPos_top) <= 1 &&
Math.abs( newPos_left - targetPos_left ) <= 1;
// same as previous
if( newPos_top === lastPos_top &&
newPos_left === lastPos_left ) {
if( same ++ > 2 ) { // if it's more than two frames
if( at_destination ) {
return resolve();
}
return reject();
}
}
else {
same = 0; // reset our counter
// remember our current position
lastPos_top = newPos_top;
lastPos_left = newPos_left;
}
// check again next painting frame
requestAnimationFrame( check );
}
});
}
#scroll_container {
height: 50vh;
overflow: auto;
}
#scroll_content {
height: 5000vh;
background-image: linear-gradient(to bottom, red, green);
background-size: 100% 100px;
}
.as-console-wrapper {
max-height: calc( 50vh - 30px ) !important;
}
<button id="trigger">click to scroll (spam the click to test blocking feature)</button>
<div id="scroll_container">
<div id="scroll_content"></div>
</div>