本文值得一写,笔者也是没找到合适的解决方案,而且被 GPT-4 忽悠到了。
先说结论
移动端上实现横向滚动分页效果,最好别用vue-virtual-scroller ,因为它是使用系统滚动条监听scroll
事件实现的。而是应该选择iscroll 或者better-scroll这样通过模拟手势实现滚动效果的三方库。
再说原因
因为系统滚动条有2点很难处理:
- 只有
scroll
事件,而这个事件是滚动后再通知出去的,没办法去拦截滚动。 - 移动端上的
WebView
的滚动其实是被原生优化过的,比如 iOS 是用UIScrollView
替换过, 这是有惯性滚动效果的,而我们做滚动分页最大的难题就是惯性滚动。
而笔者公司项目换组件比较麻烦,特别接手的这活是体验优化,那为了体验而整体重构代价太大了点。
最终效果
第一次用掘金的视频上传,还先去搞了一下西瓜视频的账号 ~
可以看到实现了类似 iOS UIPageViewController
的效果,且是基于vue-virtual-scroller虚拟滚动基础上的。
实现思路
在确定不去魔改vue-virtual-scroller前提下,过程上用了好几种不同的思路实现了多版效果,都不可行。
思路上会把整个方案拆开讲,方便大家弄懂原理 ~
获取用户滚动手势停止时机
先是去找除了scroll
外的其他可用事件,可是并没有,就scroll
这一根独苗能用(前端滚动 API 太贫瘠了)。触发了scroll
事件,就不会触发任何touch
事件,所以我们需要用别的方式去得到用户滚动手势停止的时机。
这一点其实vue-virtual-scroller也是同样的实现思路:
关联文章:
通过使用setTimeout
超时设置来获取滚动是否停止了,vue-virtual-scroller源码的意思是如果停止且当前不是"连续的"就需要强制刷新。
而我们也一样,通过要构造一个setTimeout
超时设置来拿到用户是否滚动停止,虽然这从 App 的角度看很不准,只能算模拟 ...
typescript
let categoryScrollTimeout: number | undefined;
function categoryContainerBounceScrolling(callback: () => void) {
window.clearTimeout(categoryScrollTimeout);
categoryScrollTimeout = undefined;
...
categoryScrollTimeout = window.setTimeout(() => {
categoryScrollEnd(callback);
}, 150);
}
这样就初步构造一个滚动方法,且给外面一个滚动彻底完成的回调,用于加载数据。
设置最大滚动值
分页效果就不能说一滚动滚出好几页(惯性原因),那就没有翻页效果了。
所以我们需要在scroll
事件里增加一下最大滚动限制,那为了拿到最大滚动,还需要记录初始滚动位置,这样才能计算出是否超出了一屏。
typescript
let categoryStartScrollLeft: number | undefined;
function categoryContainerBounceScrolling(callback: () => void) {
...
const scrollDiv = categoryScrollRef.value!.$el as HTMLElement;
if (!categoryStartScrollLeft) {
// 记录初始位置
categoryStartScrollLeft = scrollDiv.scrollLeft;
}
if (Math.abs(scrollDiv.scrollLeft - categoryStartScrollLeft!) > windowWidth) {
// 滚动超过一屏,则重新定位到下一页或者上一页
scrollDiv.scrollLeft =
(categoryIndex.value + Math.sign(scrollDiv.scrollLeft - categoryStartScrollLeft!)) *
windowWidth;
...
}
...
}
以及需要在超出一屏时,强制滚动到当前的上一页或者下一页。
Math.sign 返回正负 1
当然,这一切都想的很美好,但我们有惯性滚动这个坑,所以这里手如果快速滑动,那整个滚动条就会抖起来。
处理惯性滚动
想法很简单,关掉惯性滚动就好了呗,但确实不好关,跟 GPT-4 叽叽歪歪了半天:
-webkit-overflow-scrolling
确实在 iOS 上是有效的,但在 Android 至少是华为设备上无效,而且 Android 比 iOS 抖的厉害多了,不忍直视。
GPT 这里说的很对,event.preventDefault()
阻止不了已经发生的滚动,这不可行。但它给了我灵感,直接禁用整个滚动条是否可以实现。
它说不行 ... 然后笔者信了它,就去试其他方法了,这里浪费了很多时间。
但换个问法,它就说可以 ...
事实证明确实可以。
typescript
function categoryContainerBounceScrolling(callback: () => void) {
...
if (Math.abs(scrollDiv.scrollLeft - categoryStartScrollLeft!) > windowWidth) {
...
// 超出后设置不再滚动
scrollDiv.style.overflow = 'hidden';
}
...
}
滚动结束弹性归位
categoryScrollEnd
是滚动结束的处理方法,这里需要处理的是如果滚动不到半屏,那就弹性回归到当前页,如果滚动超过半屏,那我们需要滚动到上一页或者下一页。
完整实现截图:
还要注意的是,我们还定义了isCategoryScrollAnimation
是否在滚动动画结算的变量,用于最后弹性滚动时不要再触发滚动事件了,一个scroll
事件去做所有事真的是绕。
整个动画结算完后,还要记得做变量归位,这里用了一个setTimeout
阻挡下用户频繁滚动。
这里还有一点,弹性滚动方法smoothScroll
,没有去用 css scroll-behavior: smooth
,因为整体上最好把这个禁用掉,这也会导致抖抖抖。
css
&__scroll-container {
...
scroll-behavior: unset;
-webkit-overflow-scrolling: auto;
}
最后附上smoothScroll
方法实现源码,支持传滚动速度或者传耗时。
typescript
/**
* 虚拟列表自定义滚动,控制时长
* @param scrollContainer 虚拟列表
* @param position 滚动位置
* @param duration 滚动时长
* @param speed 滚动速度(滚动位置 / 滚动耗时(ms))
*/
export const smoothScroll = (params: {
position: number;
speed?: number;
duration?: number;
scrollContainer?: RecycleScroller;
callback?: () => void;
}) => {
const { scrollContainer, position = 0, speed = 2, callback } = params;
if (!scrollContainer) {
return;
}
const target = scrollContainer.$el;
const startPosition = target.scrollLeft;
const distance = position - startPosition;
let startTime: number | null = null;
const duration = params.duration ?? Math.abs(distance / speed);
function animation(currentTime: number) {
if (startTime === null) startTime = currentTime;
const timeElapsed = currentTime - startTime;
const run = _ease(timeElapsed, startPosition, distance, duration);
target.scrollLeft = run;
if (timeElapsed < duration) {
requestAnimationFrame(animation);
} else {
if (target.scrollLeft !== position) {
target.scrollLeft = position;
}
callback && callback();
}
}
requestAnimationFrame(animation);
};
function _ease(t: number, b: number, c: number, d: number) {
t /= d / 2;
if (t < 1) return (c / 2) * t * t + b;
t--;
return (-c / 2) * (t * (t - 2) - 1) + b;
}
总结
其实最终效果也不算特别完美,有些操作下还是可以看出轻微的抖动,scroll
事件的时机就不适合做这事。
在移动端,还是第一优先用手势来自定义滚动。