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>