移动端兼容性好些的滚动动画(smooth scroll)实现记录

1,566 阅读5分钟

需求

先简单说下背景需求:移动端web页面有个功能是要滚动定位到某个元素。且不能那么生硬,需要带有动画效果。 最低兼容性要求:Android5.0 IOS11 以上。

调研(废话篇)

调查优先从原生找起,优先CSS、浏览器JS Api、CSS+JS实现。实在不行在纯js实现。

scroll-behavior

纯CSS方案上只有:scroll-behavior【MDN】

兼容性检查:IOS15.4+(2022-03-15)、Android61 (2017-09-05)

前端判断是否支持: const smoothScrollSupported = 'scrollBehavior' in document.documentElement.style;

PASS!,而且还是需要手动JS计算滚动位置。

Window.scrollTo、Element.scrollTo

Element.scrollToWindow.scrollto 二者的作用都是滚动元素或者文档到指定的位置。

语法

window.scrollTo(x-coord,y-coord)

window.scrollTo({
    top: 1000,
    behavior: "smooth"
});

重点是options.behavior属性,当值为smooth时,页面会平滑的滚动。

兼容性检查:IOS14,Android61 (2017-09-05)

PASS!

Window.scrollBy、Element.scrollBy

Element.scrollByWindow.scrollBy 二者的作用跟上面俩差不多,只是是按指定的偏移量滚动文档。

兼容性也跟上面一样,不用看了。

PASS!

Element.scrollIntoView

Element.scrollIntoView 将元素滚动到视口的指定位置。对于简单的布局其实挺好用,语义化也不错。

// 将元素滚动到视口中间,同理block为top就是顶部,end就是底部
Element.scrollIntoView({block: "center"})

但要滚动到指定元素下方呢?因为页面上难免有一些header元素,或者导航Tab等。block的三个取值就太笼统了,还是不太满足很多需求。

兼容性检查:这块需要注意的时,入参options的支持性和options.behavior='smooth'的不同。IOS15.4,Android61 (安卓赢麻了呀。

Element.scrollTop + requestAnimationFrame

scrollToprequestAnimationFrame 还得是他俩啊。。。没办法,计算吧!

在算之前,想想这种需求市面上应该封装的库,找到一个Star5.5k的库,github.com/cferdinandi… ,其核心逻辑就是按照屏幕刷新率一点点的滚,然后加点其他功能优化下。

大概看一眼文档,然后看下代码实现,代码量不是很多,是个学习的好机会。核心逻辑的不是很复杂,作为一个库,会有很多的代码在处理错误,兼容性判断和处理,多配置项支持等。结合我们的需求,一堆兼容性判断可以不用管了,配置项和其他功能?看我们的需求给它优化了。那这样核心代码就剩下不到两百行。那不如我们动手自己抄一遍,还能学到很多思想,安排!

代码实现

scroll.ts

// 滚动库
let animationInterval: any;

/**
 * 浏览器原生是否支持顺滑滚动效果
 */
const smoothScrollSupported = 'scrollBehavior' in document.documentElement.style;

/**
 * 判断页面是否滚动到底部
 */
const isReachTheBottom = () => {
  const scrollTop = window.scrollY;
  const { clientHeight, scrollHeight } = document.documentElement;

  return scrollTop + clientHeight >= scrollHeight - 1;
};

/**
 * 获取滚动结束后页面的位置
 */
const getEndLocation = (anchor: HTMLElement, headerHeight = 0, offset = 0): number => {
  const location = anchor.getBoundingClientRect().top + window.scrollY;
  return Math.max(location - headerHeight - offset, 0);
};

/**
 * 计算缓动模式,支持自定义函数
 * @link https://gist.github.com/gre/1650294
 * @link https://easings.net/ 效果预览
 */
const easingPattern = (settings: any = {}, time: number) => {
  let pattern;

  // Default Easing Patterns
  if (settings.easing === 'easeInQuad') pattern = time * time; // accelerating from zero velocity
  if (settings.easing === 'easeOutQuad') pattern = time * (2 - time); // decelerating to zero velocity
  if (settings.easing === 'easeInOutQuad')
    pattern = time < 0.5 ? 2 * time * time : -1 + (4 - 2 * time) * time; // acceleration until halfway, then deceleration
  if (settings.easing === 'easeInCubic') pattern = time * time * time; // accelerating from zero velocity
  if (settings.easing === 'easeOutCubic') pattern = --time * time * time + 1; // decelerating to zero velocity
  if (settings.easing === 'easeInOutCubic')
    pattern =
      time < 0.5 ? 4 * time * time * time : (time - 1) * (2 * time - 2) * (2 * time - 2) + 1; // acceleration until halfway, then deceleration
  if (settings.easing === 'easeInQuart') pattern = time * time * time * time; // accelerating from zero velocity
  if (settings.easing === 'easeOutQuart') pattern = 1 - --time * time * time * time; // decelerating to zero velocity
  if (settings.easing === 'easeInOutQuart')
    pattern = time < 0.5 ? 8 * time * time * time * time : 1 - 8 * --time * time * time * time; // acceleration until halfway, then deceleration
  if (settings.easing === 'easeInQuint') pattern = time * time * time * time * time; // accelerating from zero velocity
  if (settings.easing === 'easeOutQuint') pattern = 1 + --time * time * time * time * time; // decelerating to zero velocity
  if (settings.easing === 'easeInOutQuint')
    pattern =
      time < 0.5
        ? 16 * time * time * time * time * time
        : 1 + 16 * --time * time * time * time * time; // acceleration until halfway, then deceleration

  // Custom Easing Patterns
  if (typeof settings.customEasing === 'function') {
    pattern = settings.customEasing(time);
  }

  return pattern || time; // no easing, no acceleration
};

/**
 * 获取对应距离需要的滚动速度
 */
const getSpeed = (distance: number, settings = { speedAsDuration: false, speed: 1000 }) => {
  let speed = settings.speedAsDuration
    ? settings.speed
    : Math.abs((distance / 1000) * settings.speed);
  return speed;
};

/**
 * 取消滚动,移除下次动画回调并重置变量
 */
const cancelScroll = () => {
  cancelAnimationFrame(animationInterval);
  animationInterval = null;
};

interface IScrollOption {
  // 是否优先使用原生滚动效果,为true且浏览器支持时下面的速度动画效果将不起作用 默认true
  nativeScroll?: boolean;
  // 滚动速度 滚动1000px需要花费的时间(ms),为0时不带动画效果直接位移 默认300
  speed?: number;
  // 精确的让滚动所有距离的动画耗时都为speed值 默认false
  speedAsDuration?: boolean;
  // 缓动函数名,详见easingPattern方法
  easing?: string;
  // 自定义缓动函数
  customEasing?: (time: number) => number;
}

export const animateScroll = (
  anchor: HTMLElement | string,
  header: HTMLElement | string | number,
  _option: IScrollOption
) => {
  // 取消任何已存在的滚动
  cancelScroll();

  const defaultOption: IScrollOption = {
    nativeScroll: true,
    speed: 300,
    speedAsDuration: false,
    easing: 'easeInOutCubic',
  };

  const anchorElm =
    typeof anchor === 'string' ? (document.querySelector(anchor) as HTMLElement) : anchor;
  const headerElm =
    typeof header === 'string' ? (document.querySelector(header) as HTMLElement) : header;

  if (!anchorElm) {
    return;
  }

  const headerHeight =
    headerElm instanceof HTMLElement
      ? headerElm.getBoundingClientRect().height
      : Number(header) || 0;

  const startLocation = window.scrollY;
  const endLocation = getEndLocation(anchorElm, headerHeight);
  const distance = endLocation - startLocation;
  const speed = getSpeed(distance);
  const option = Object.assign(defaultOption, _option);

  // 速度为0时,直接滚动到指定位置,不带动效
  if (option.speed === 0) {
    window.scrollTo(0, endLocation);
    return;
  }

  // 当浏览器支持滚动时,且配置为优先使用原生则使用原生api
  if (smoothScrollSupported && option.nativeScroll) {
    window.scrollTo({ top: endLocation, behavior: 'smooth' });
    return;
  }

  let start: number | null,
    // 滚动进度区间值为[0,1]
    percentage: number,
    // 滚动位置
    position: number,
    // 累计滚动时间
    timeLapsed = 0;

  /**
   * 判断是否要停止滚动
   */
  const stopAnimateScroll = (position: number, endLocation: number): boolean => {
    let currentLocation = window.scrollY;
    // 判断是否该停止滚动
    if (position == endLocation || currentLocation == endLocation || isReachTheBottom()) {
      cancelScroll();
      start = null;
      return true;
    }
    return false;
  };

  /**
   * 循环的触发滚动
   */
  const loopAnimateScroll = (timestamp: number) => {
    if (!start) {
      start = timestamp;
    }
    timeLapsed += timestamp - start;
    percentage = speed === 0 ? 0 : timeLapsed / speed;
    percentage = percentage > 1 ? 1 : percentage;
    position = startLocation + distance * easingPattern({ easing: 'easeInOutCubic' }, percentage);
    window.scrollTo(0, Math.floor(position));
    if (!stopAnimateScroll(position, endLocation)) {
      animationInterval = window.requestAnimationFrame(loopAnimateScroll);
      start = timestamp;
    }
  };

  window.requestAnimationFrame(loopAnimateScroll);
};


使用

TODO,21点半,下班先了。

源码解析

参考这个文章,发现已经有大佬总结分析了# 平滑滚动的实现(下) - smooth-scroll源码分析

参考

js:scroll平滑滚动页面或元素到顶部或底部的方案汇总 blog.csdn.net/mouday/arti…