react-use-gesture & react spring 实战 —— 实现一个 Tabs

1,763 阅读4分钟

背景

在开发移动端 h5 页面的时候,用到了一个 Tabs 组件。

image.png

上线之后发现在由于移动端的宽度小,界面上显示了如下几个 Tab,虽然用户可以左右滚动 TabsBar,但是问题是用户并不知道这个 TabsBar 是可以水平滚动的,于是会导致用户永远都不会进入到后面的一系列页面,仅仅点到 Tab4 之后就没有下文了。

难道要给 TabsBar 水平加个滚动条?不行,有点难看。

产品和设计师看到后就不高兴了,然后就提出了如下需求:

  1. 点击到每个 Tab 时,将该 Tab 滚动到 TabsBar 容器的中间,让用户意识到后面还有内容。
  2. PC 端也要支持 TabsBar 的水平拖动滚动(因为我们的应用可能被嵌入到 PC 端聊天窗中,宽度与移动端相当,使用笔记本触摸板是可以左右滚动的,但是鼠标滚轮在这里的交互就不太合适)。

实现

1. 确定 DOM 结构

TabsBar 需要一个水平滚动容器,宽度为 100%,隐藏滚动条。 内容区域要比容器宽,包含所有的 Tab。

<div className="container">
    <div className="content">
      <div className='item item-selected'></div>
      <div className='item'></div>
      <div className='item'></div>
    </div>
</div>

.container {
  width: 100%;
  position: relative;
  overflow-x: auto;
  touch-action: none;
  border-bottom: 1px solid #ccc;
}

.container::-webkit-scrollbar {
  display: none;  /* 隐藏水平滚动条 */
}

.content {
  width: max-content;
}

.item {
  position: relative;
  display: inline-block;
  width: 50px;
  height: 30px;
  margin: 0 4px;
}

.item::after {
  position: absolute;
  width: 100%;
  bottom: -1px;
  left: 0;
  content: "";
  border-bottom: 3px solid #37f;
  transition: all 0.3s;
  transform: scaleX(0);
}

.item-select::after {
  transform: scaleX(1);
}

得到如下的基本效果

2. 处理点击 Tab 滑动到中间的效果

首先是引入了 react-spring 来做简单的动画过渡效果。给容器加上 ref 用于手动调用 scroll。创建一个 spring 对象,使用 onChange 回调修改 scroll。

const [, api] = useSpring(() => ({
    from: { left: 0 },
    onChange(v) {
      refContainer.current!.scroll({ left: v.value.left });
    }
}));
const refContainer = useRef<HTMLDivElement | null>(null);

// 加上 ref 
<div ref={refContainer} className="container" >...</div>

然后在合适的地方,如点击 Tab 时触发容器的滚动。

 const handleClickMove = (e: React.MouseEvent) => {
    const item = e.target as HTMLDivElement;
    if (!item.classList.contains("item")) {
      return;
    }

    const parent = item?.offsetParent;
    if (!parent) {
      return;
    }

    // 获取 item 和 容器的大小
    const itemRect = item.getBoundingClientRect();
    const parentRect = parent.getBoundingClientRect();

    // 计算 item 的中心位置
    const itemCenter = item.offsetLeft + itemRect.width / 2;

    // 计算 容器的滚动位置
    let tox = itemCenter - parentRect.width / 2;

    // 截断
    tox = _.clamp(tox, 0, parent.scrollWidth - itemRect.width);

    // 开始滚动
    api.start({
      from: { left: refContainer.current?.scrollLeft! },
      left: tox
    });
  };
  
// 挂载 content 的 onClick 上面
  <div className="content" onClick={handleClickMove}>

这样就完成了点击 Tab 时将 Tab 滚动到中间的需求。

3. 处理 PC 拖动效果

处理手势事件,首先引入要 react-use-gesture。 需要简单分析一下。

  1. 正常的使用触摸板时,会触发 onWheel 和 onScroll,onScroll 在直接使用 elem.scroll 的时候也会触发,onWheel 只会在滚轮和触摸板触发,这时候让容器默认处理,同时需要取消 spring 变化(否则在点击之后的一小段事件里面,触摸板滚动是无效的。
  2. 为了实现拖动滚动,需要处理 onDrag,以反向移动距离作为实际的滚动距离。停止拖动时,需要使用当前的移动速度(px/ms) 乘 200ms 作为惯性移动的距离,(这里考虑的是 spring 默认的移动时长是 250ms,所以乘一个略小与 250 的值得到距离看起来很自然)。
  3. 需要使用 initial 来传入每次拖动的初始状态,就直接使用 ref 获取了 scrollLeft。
  const bind = useGesture(
    {
      onWheel() {
        api.stop(); // 取消动画,让浏览器自己处理
      },
      onDrag(h) {
        if (h.dragging) {
          // 反向的 drag movement 才是滚动的反向
          api.start({
            left: -h.movement[0],
            immediate: true // 无动画过程
          });
        } else {
          // 松手时的惯性滚动
          api.start({
            left: api.current[0].get().left - h.velocities[0] * 200
          });
        }
      }
    },
    {
      drag: {
        // 每次拖动传入 当前的 scrollLeft 作为初始状态
        initial: () => [-refContainer.current!.scrollLeft, 0],
        axis: "x", // 仅在 x 方向 drag
        filterTaps: true
      }
    }
  );
 
 // 手势事件绑定在容器上
 <div {...bind()} ref={refContainer} className="container">

这里查看实现源码

总结

本文主要讲解了解决移动端和 PC 端的 Tabs 用户体验手势交互问题。在这个过程中学习了 react-use-gesture 和 react-spring,了解到动画对于提升用户感知的重要效果、手势交互的丰富也为我打开了新的大门、新的可能,扩展了自己的技能、工具,面对未来的开发工作也能更加从容自信了。

参考