背景
在开发移动端 h5 页面的时候,用到了一个 Tabs 组件。
上线之后发现在由于移动端的宽度小,界面上显示了如下几个 Tab,虽然用户可以左右滚动 TabsBar,但是问题是用户并不知道这个 TabsBar 是可以水平滚动的,于是会导致用户永远都不会进入到后面的一系列页面,仅仅点到 Tab4 之后就没有下文了。
难道要给 TabsBar 水平加个滚动条?不行,有点难看。
产品和设计师看到后就不高兴了,然后就提出了如下需求:
- 点击到每个 Tab 时,将该 Tab 滚动到 TabsBar 容器的中间,让用户意识到后面还有内容。
- 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。 需要简单分析一下。
- 正常的使用触摸板时,会触发 onWheel 和 onScroll,onScroll 在直接使用 elem.scroll 的时候也会触发,onWheel 只会在滚轮和触摸板触发,这时候让容器默认处理,同时需要取消 spring 变化(否则在点击之后的一小段事件里面,触摸板滚动是无效的。
- 为了实现拖动滚动,需要处理 onDrag,以反向移动距离作为实际的滚动距离。停止拖动时,需要使用当前的移动速度(px/ms) 乘 200ms 作为惯性移动的距离,(这里考虑的是 spring 默认的移动时长是 250ms,所以乘一个略小与 250 的值得到距离看起来很自然)。
- 需要使用 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,了解到动画对于提升用户感知的重要效果、手势交互的丰富也为我打开了新的大门、新的可能,扩展了自己的技能、工具,面对未来的开发工作也能更加从容自信了。
参考
- react-spring,弹簧动画库
- react-use-gesture,手势hook库