H5模拟无限滚动

1,195 阅读5分钟

需求

实现一个组件,要求

  • 内部的卡片可自动进行水平滚动,首尾相接,无限滚动
  • 用户touch后停止自动滚动,跟随用户的手势滚动,后再自动滚动

分析

原生滚动的兼容性好,天然支持惯性滚动、回弹效果,但是卡片首尾相接涉及dom操作,与scroll事件的触发冲突,

所以选择使用js模拟滚动。模拟滚动需要满足:

  • 滚动:可以使用循环translateX实现滚动效果
  • 滚动开始与中断:开始/中断循环
  • 滚动效果:touchend后,仍会滚动一段距离,滚动速度逐渐衰减
  • 首尾相接:移出视窗左侧的卡片放置到最右侧

实现

- 初始化  

使用一个定宽的容器srollView作为滚动的视窗,设置为position: relative, overflow: hidden,内部的卡片定宽,设置为position: absolute, left: 0, translateX(cardWidth * index)。这样得到一个看似从左到右的平铺排列。为方便后续操作dom,使用ref引用scrollView及内部card

注:假定每个卡片的偏移量为cardWidth*index,真实情况往往需要加上边距计算, 比如(cardWdith + paddingLeft) * index

伪代码如下

<View
  onTouchMove={handleScrollViewTouchMove}
  onTouchStart={handleScrollViewTouchStart}
  onTouchEnd={handleScrollViewTouchEnd}
  ref={contentViewRef}
  style={styles.scrollview}
 >
 {dataList.map((item, index) => (
   <Card
     key={item.id}
     ref={(ref: HTMLDivElement) => {
       if (ref) {
         // 包裹一层,{left: 偏移值,node: 真实dom}
         const refInfo = { left: index * ref?.clientWidth, node: ref };
         if (cardsRef.current[index]) {
           ref.style.transform = `translateX(${refInfo.left}px)`;
         }
         cardsRef.current[index] = refInfo;
       }
     }}
     data={item}
   />
  ))}
</View>

- 模拟滚动

滚动使用requestAnimationFrame+translate模拟滚动,为了实现首尾相接的效果:

  • 计算视窗的左右边界,左边界为-cardWdith,右边界为 cardWdith * (len - 1)
  • 卡片一旦向左完全移,则将其放置在卡片组的最右侧
  • 卡片一旦向右移出边界,则将其放置在卡片组的最左侧

⚠️Tips:为了节省性能,只需要真实移动scrollView视窗内的卡片,其他卡片更新一下偏移量即可,这也是cardsRef使用这种数据结果的原因。此外做卡片的移动是直接操作dom,也是为了避免rerender触发不必要的重复计算。

  /**
   * 执行偏移
   * @param distance 偏移量, 单位px, >0时表示向右, <0时表示向左
   * @returns
   */
  const startTranslate = (distance: number) => {
    if (cardsRef.current.length === 0) {
      return;
    }
    const viewWidth = contentViewRef.current?.clientWidth || 0;
    const { clientWidth = 0 } = cardsRef.current[0].node;
    const len = cardsRef.current.length;
    const wrraperWidth = clientWidth * len;
    // 左右边界的值可视情况而定
    const leftBoundary = -clientWidth
    const rightBoundary = clientWidth * (len - 1);
    cardsRef.current.forEach((card) => {
      const { left, node } = card;
      if (distance > 0 && left + distance > rightBoundary) {
        // 往右移,且移动后的距离超出了右边界, 移到最左边
        const _left = left + distance - wrraperWidth;
        card.left = _left;
      } else if (distance < 0 && left + distance < leftBoundary) {
        // 往左移,移动后的距离超出了左边界, 移动到最右边
        const _left = left + distance + wrraperWidth;
        card.left = _left;
      } else {
        // 正常移动
        card.left = left + distance;
      }
      // 视窗内的做真实移动
      if (left >= leftBoundary && left <= viewWidth + clientWidth) {
        node.style.transform = `translateX(${card.left}px)`;
      }
    });
  };

- 自动滚动&停止滚动

使用一个isScrollingRef标识滚动,为true则继续执行startTranslate即可。停止滚动只需isScrollingRef置为false

const startAutoScroll = () => {
  isScrollingRef.current = true;
  const step = () => {
    if (isScrollingRef.current) {
      // 滚动的速度
      startTranslate(-0.2);
      cancelAnimationFrame(rqaIdRef.current);
      rqaIdRef.current = requestAnimationFrame(step);
    }
  };
  step();
}

const stopAutoScroll = () => {
  isScrollingRef.current = false;
  // 其他clearTimer相关
}

- 用户干预滚动

监听touch事件的三阶段,模拟跟手滚动和惯性。

  • touchStart

关闭自动滚动,记录touch事件焦点的位置信息,比如x轴和y轴的值

  • touchMove

更新touch事件焦点的位置信息,计算距上次touch事件的偏移,判定用户滚动方向。避免模拟滚动的水平方向与原生页面的竖直方向滚动之间产生冲突,当判定为横向滚动时则模拟滚动,当判断为纵向滚动时则使用原生的默认行为。

const _Delta_X_ = 15; // tab X轴滑动触发最小距离
const _Delta_Y_ = 20; // tab Y轴滑动触发最小距离

const { touches, changedTouches } = event || {};

const _touchx = touches?.[0]?.clientX || changedTouches?.[0]?.clientX;
const _touchy = touches?.[0]?.clientY || changedTouches?.[0]?.clientY;

const deltaX = Math.abs(_touchx - positionRef.current.startX); // X方向上移动的总距离
const deltaY = Math.abs(_touchy - positionRef.current.startY); // Y方向上移动的总距离

// 横向移动
if (
 !positionRef.current.isYmove &&
 (positionRef.current.isXmove ||
 (deltaY < _Delta_Y_ && deltaX > deltaY * 3 && deltaX > _Delta_X_))
){
   event.preventDefault(); // 阻止默认事件
   event.stopPropagation();
   positionRef.current.isXmove = true; // 标记为横向滚动
   positionRef.current.isYmove = false;
   
   // 存储滚动的时间戳和位置,用于惯性滚动
   if (event.timeStamp - momentumRef.current.lastTime > MomentumTimeThreshold)   {
     momentumRef.current.lastX = _touchx;
     momentumRef.current.lastTime = event.timeStamp;
   }
   
   // translate模拟滚动
   const touchOffsetX = _touchx - (positionRef.current.moveX || positionRef.current.startX); // 和上一次touch事件焦点的偏移,>0表示手向右滑,offset减小
	 startTranslate(touchOffsetX);
}else{
   ... // 更新touch事件的位置信息等
}
  • touchEnd
  1. touch事件结束时需要判断是否需要模拟惯性滚动,当滚动有动量足够大时才需要模拟惯性滚动,滚动的动量可以通过touchEnd事件之前的一段时间内MomentumTimeThreshold(项目中使用200ms)和偏移量来衡量。
const _distance = positionRef.current.moveX - momentumRef.current.lastX;
const _time = event.timeStamp - momentumRef.current.lastTime;
if (_time === 0) {
	return;
}

const speedAvg = Math.abs(_distance / _time);

// MomentumTimeThreshold时间内滑动距离>2才模拟惯性
if (Ma.abs(_distance) < 2 || _time > MomentumTimeThreshold) {
  clearTimer();
} else {
  // 惯性滑动的距离 = 最后一次touchmove事件触发的手指滑动距离 * 加速度常量
  // 加速度常量Deceleration值为0.003
  const inertiaDistanceOrigin =
    ((speedAvg * Math.min(2, speedAvg)) / Deceleration) * (speedAvg < 0 ? -1 : 1);
	// SwipeTime为常量,滚动效果的最短时间
  const duration = Math.min(SwipeTime, (speedAvg * 2) / Deceleration);
  
  // 惯性滚动后的回调
  const cb = () => {
    // 比如开始自动滚动
  };

  startAnimation(
    Math.round(_distance < 0 ? -inertiaDistanceOrigin : inertiaDistanceOrigin),
    duration,
    cb,
  );
}
  1. 惯性滚动是需要做加速度衰减的运动,其动画效果与easeOutCubic类似

其函数表达式为

function easeOutCubic(x: number): number {
  return 1 - pow(1 - x, 3);
}

使用上述的函数表达式计算每帧的位置进行滚动,模拟惯性效果

  /**
   * 执行动画
   * @param distance 偏移量, 单位px, >0时表示向右, <0时表示向左
   * @param duration 动画时长 单位ms
   * @param checkEndCb 动画结束后的回调
   * @param easingType 动画类型,见./easing.ts里定义
   */
  const startAnimation = useCallback(
    (
      distance: number,
      duration: number,
      checkEndCb?: () => void,
      easingType = DefaultEasingType,
    ) => {
      const startTime = Date.now();
      const destTime = startTime + duration;
      let lastEasing = 0;
      isScrollingRef.current = true;

      const step = () => {
        const now = Date.now();
        // 时间超出或者停止滚动,受isScrollingRef.current控制
        if (now > destTime || !isScrollingRef.current) {
          checkEndCb && checkEndCb();
          return;
        }
        const timeSlice = (now - startTime) / duration;
        
        // 取到的easeing即为上述的easeOutCubic函数
        const easeing = Easing[easingType].fn(timeSlice);
        const _distance = distance * (easeing - lastEasing);
        lastEasing = easeing;

        // 当移动距离太小时,停止
        if (Math.abs(_distance) < 0.0001 && now !== startTime) {
          checkEndCb && checkEndCb();
          return;
        }
        startTranslate(_distance);

        // cancelAnimationFrame(rqaIdRef.current);
        rqaIdRef.current = requestAnimationFrame(step);
      };
      requestAnimationFrame(step);
    },
    [],
  );

总结

模拟滚动主要是根据touch事件来手动计算元素的位置,然后考虑原生滚动的一些特殊情况,比如滚动中断、惯性滚动、滚动回弹等。滚动容器也需要考虑特殊情况,比如卡片填不满容器时,是否需要复制卡片使其可以滚动,是复制一个卡片还是需要复制一组。本文主要介绍了自己在实现模拟滚动的一点思路,如有不足,欢迎讨论~

招聘

团队在招前端开发,所属阿里巴巴淘宝下的新业务团队,氛围好,没有pua,包含跨端、ssr、Faas、搭建等多种前端技术方向,有兴趣欢迎投递简历952573581@qq.com

职位描述

1.按照产品方案完成电商用户端业务开发以及商家端、运营端后台建设; 2.关注用户体验,深入理解业务并能不断优化产品体验,制定明确的技术规划,通过技术手段为业务带来增量价值; 3.研究和探索创新的研发思路和最新的前端技术,寻求业务突破; 4.带领并能够对新人进行指导,与团队一起成长; 5.参与前端开发规范制订、技术文档编写、技术分享等。

职位要求

  1. 精通各种前端技术(包括HTML/CSS/JavaScript等),熟悉ES6语法,具备跨终端(Mobile+PC)的前端开发能力,熟悉网络协议(HTTP/SSL),熟悉常见安全问题和对策;
  2. 熟悉前端工程化与模块化开发,并有实践经验(如gulp/webpack、VueJS/React等);
  3. 至少熟悉一门非前端的语言(如NodeJS/Java/PHP/C/C++/Python/Ruby等),并有实践经验;
  4. 对前端技术有持续的热情,良好的团队协作能力,提升团队研发效率,实现极致性能,通过创新交互优化产品体验。

参考文章

  1. 前端也要懂物理 —— 惯性滚动篇 https://juejin.cn/post/6844904185121488910