Chrome 平滑滚动和requestAnimationFrame?
Chrome smooth scroll and requestAnimationFrame?
我构建了一个拖放式自动滚动器,用户可以在其中将元素拖到隐藏的 div
上,这会触发可滚动 div
的滚动动作。我正在使用 scrollBy({top: <val>, behavior: 'smooth'}
来平滑滚动,并使用 requestAnimationFrame
来防止函数调用过于频繁。这在 Firefox 中运行良好,并且根据 caniuse 应该在 Chrome 中得到原生支持;但是,它无法在 chrome 中正常工作。当用户离开隐藏 div
时,它只会触发一次事件。控制台中没有错误。 console.log()
表示正在调用包含 scrollBy()
的函数。如果我删除 behavior: 'smooth'
它会起作用,但当然不会平滑滚动。如果我删除该选项并在可滚动 div 上设置 css scroll-behavior: smooth
,结果相同。我完全不知所措。滚动功能的 MWE(这是在 Vue 应用程序中,因此任何 this.
都存储在数据对象中。
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 )
}
不确定您对 requestAnimationFrame
调用的期望是什么,但这是 应该 发生的事情:
scrollBy
将其行为设置为 smooth
实际上应该只在下一个绘画帧开始滚动目标元素,就在动画帧回调执行之前(step 7 here).
在平滑滚动的第一步之后,您的动画帧回调将触发 (step 11), disabling the first smooth scrolling by starting a new one (as defined here)。
重复直到达到最大,因为您永远不会等待足够的时间让平滑的 100 像素滚动完全发生。
这确实会在 Firefox 中移动,直到到达终点,因为此浏览器具有线性平滑滚动行为并从第一帧开始滚动。
但是 Chrome 有一个更复杂的缓入缓出行为,这将使第一次迭代滚动 0px。所以在这个浏览器中,你实际上会陷入无限循环,因为在每次迭代中,你都会滚动 0,然后禁用之前的滚动并再次要求滚动 0,等等
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>
因此,如果您实际上想要避免多次调用该滚动函数,那么您的代码不仅在 Chrome 中会被破坏,而且在 Firefox 中也会被破坏(它不会在 100px 后停止滚动)要么)。
在这种情况下,您需要的是等到平滑滚动结束。
已经有关于检测平滑 scrollIntoPage
何时结束的 a question here,但是 scrollBy
的情况有点不同(更简单)。
这是一个方法,它将 return 一个 Promise 让您知道平滑滚动何时结束(成功滚动到目的地时解析,并在被其他滚动中止时拒绝)。基本思路与 this answer of mine:
相同
启动一个 requestAnimationFrame
循环,检查滚动的每一步是否到达静态位置。一旦我们在同一位置停留了两帧,我们就认为我们已经到达终点,然后我们只需要检查我们是否到达了预期的位置。
有了这个,你只需要升起一个标志,直到之前的平滑滚动结束,完成后,将其放下。
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>
我构建了一个拖放式自动滚动器,用户可以在其中将元素拖到隐藏的 div
上,这会触发可滚动 div
的滚动动作。我正在使用 scrollBy({top: <val>, behavior: 'smooth'}
来平滑滚动,并使用 requestAnimationFrame
来防止函数调用过于频繁。这在 Firefox 中运行良好,并且根据 caniuse 应该在 Chrome 中得到原生支持;但是,它无法在 chrome 中正常工作。当用户离开隐藏 div
时,它只会触发一次事件。控制台中没有错误。 console.log()
表示正在调用包含 scrollBy()
的函数。如果我删除 behavior: 'smooth'
它会起作用,但当然不会平滑滚动。如果我删除该选项并在可滚动 div 上设置 css scroll-behavior: smooth
,结果相同。我完全不知所措。滚动功能的 MWE(这是在 Vue 应用程序中,因此任何 this.
都存储在数据对象中。
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 )
}
不确定您对 requestAnimationFrame
调用的期望是什么,但这是 应该 发生的事情:
scrollBy
将其行为设置为smooth
实际上应该只在下一个绘画帧开始滚动目标元素,就在动画帧回调执行之前(step 7 here).在平滑滚动的第一步之后,您的动画帧回调将触发 (step 11), disabling the first smooth scrolling by starting a new one (as defined here)。
重复直到达到最大,因为您永远不会等待足够的时间让平滑的 100 像素滚动完全发生。
这确实会在 Firefox 中移动,直到到达终点,因为此浏览器具有线性平滑滚动行为并从第一帧开始滚动。
但是 Chrome 有一个更复杂的缓入缓出行为,这将使第一次迭代滚动 0px。所以在这个浏览器中,你实际上会陷入无限循环,因为在每次迭代中,你都会滚动 0,然后禁用之前的滚动并再次要求滚动 0,等等
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>
因此,如果您实际上想要避免多次调用该滚动函数,那么您的代码不仅在 Chrome 中会被破坏,而且在 Firefox 中也会被破坏(它不会在 100px 后停止滚动)要么)。
在这种情况下,您需要的是等到平滑滚动结束。
已经有关于检测平滑 scrollIntoPage
何时结束的 a question here,但是 scrollBy
的情况有点不同(更简单)。
这是一个方法,它将 return 一个 Promise 让您知道平滑滚动何时结束(成功滚动到目的地时解析,并在被其他滚动中止时拒绝)。基本思路与 this answer of mine:
相同
启动一个 requestAnimationFrame
循环,检查滚动的每一步是否到达静态位置。一旦我们在同一位置停留了两帧,我们就认为我们已经到达终点,然后我们只需要检查我们是否到达了预期的位置。
有了这个,你只需要升起一个标志,直到之前的平滑滚动结束,完成后,将其放下。
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>