需求
实现一个组件,要求
- 内部的卡片可自动进行水平滚动,首尾相接,无限滚动
- 用户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
- 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,
);
}
- 惯性滚动是需要做加速度衰减的运动,其动画效果与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.参与前端开发规范制订、技术文档编写、技术分享等。
职位要求
- 精通各种前端技术(包括HTML/CSS/JavaScript等),熟悉ES6语法,具备跨终端(Mobile+PC)的前端开发能力,熟悉网络协议(HTTP/SSL),熟悉常见安全问题和对策;
- 熟悉前端工程化与模块化开发,并有实践经验(如gulp/webpack、VueJS/React等);
- 至少熟悉一门非前端的语言(如NodeJS/Java/PHP/C/C++/Python/Ruby等),并有实践经验;
- 对前端技术有持续的热情,良好的团队协作能力,提升团队研发效率,实现极致性能,通过创新交互优化产品体验。
参考文章
- 前端也要懂物理 —— 惯性滚动篇 https://juejin.cn/post/6844904185121488910