likes
comments
collection
share

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果

作者站长头像
站长
· 阅读数 5

本文值得一写,笔者也是没找到合适的解决方案,而且被 GPT-4 忽悠到了。

先说结论

移动端上实现横向滚动分页效果,最好别用vue-virtual-scroller,因为它是使用系统滚动条监听scroll事件实现的。而是应该选择iscroll或者better-scroll这样通过模拟手势实现滚动效果的三方库。

再说原因

因为系统滚动条有2点很难处理:

  1. 只有scroll事件,而这个事件是滚动后再通知出去的,没办法去拦截滚动。
  2. 移动端上的WebView的滚动其实是被原生优化过的,比如 iOS 是用UIScrollView替换过, 这是有惯性滚动效果的,而我们做滚动分页最大的难题就是惯性滚动。

而笔者公司项目换组件比较麻烦,特别接手的这活是体验优化,那为了体验而整体重构代价太大了点。

最终效果

可以看到实现了类似 iOS UIPageViewController的效果,且是基于vue-virtual-scroller虚拟滚动基础上的。

实现思路

在确定不去魔改vue-virtual-scroller前提下,过程上用了好几种不同的思路实现了多版效果,都不可行。

思路上会把整个方案拆开讲,方便大家弄懂原理 ~

获取用户滚动手势停止时机

先是去找除了scroll外的其他可用事件,可是并没有,就scroll这一根独苗能用(前端滚动 API 太贫瘠了)。触发了scroll事件,就不会触发任何touch事件,所以我们需要用别的方式去得到用户滚动手势停止的时机。

这一点其实vue-virtual-scroller也是同样的实现思路:

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果

关联文章:

通过使用setTimeout超时设置来获取滚动是否停止了,vue-virtual-scroller源码的意思是如果停止且当前不是“连续的”就需要强制刷新。

而我们也一样,通过要构造一个setTimeout超时设置来拿到用户是否滚动停止,虽然这从 App 的角度看很不准,只能算模拟 ...

    let categoryScrollTimeout: number | undefined;
    function categoryContainerBounceScrolling(callback: () => void) {
        window.clearTimeout(categoryScrollTimeout);
        categoryScrollTimeout = undefined;
        ...
        categoryScrollTimeout = window.setTimeout(() => {
            categoryScrollEnd(callback);
        }, 150);
    }

这样就初步构造一个滚动方法,且给外面一个滚动彻底完成的回调,用于加载数据。

设置最大滚动值

分页效果就不能说一滚动滚出好几页(惯性原因),那就没有翻页效果了。

所以我们需要在scroll事件里增加一下最大滚动限制,那为了拿到最大滚动,还需要记录初始滚动位置,这样才能计算出是否超出了一屏。

    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 叽叽歪歪了半天:

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果

-webkit-overflow-scrolling确实在 iOS 上是有效的,但在 Android 至少是华为设备上无效,而且 Android 比 iOS 抖的厉害多了,不忍直视。

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果

GPT 这里说的很对,event.preventDefault()阻止不了已经发生的滚动,这不可行。但它给了我灵感,直接禁用整个滚动条是否可以实现。

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果

它说不行 ... 然后笔者信了它,就去试其他方法了,这里浪费了很多时间。

但换个问法,它就说可以 ...

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果

事实证明确实可以。

    function categoryContainerBounceScrolling(callback: () => void) {
        ...
        if (Math.abs(scrollDiv.scrollLeft - categoryStartScrollLeft!) > windowWidth) {
            ...
            // 超出后设置不再滚动
            scrollDiv.style.overflow = 'hidden';
        }
        ...
    }

滚动结束弹性归位

categoryScrollEnd是滚动结束的处理方法,这里需要处理的是如果滚动不到半屏,那就弹性回归到当前页,如果滚动超过半屏,那我们需要滚动到上一页或者下一页。

完整实现截图:

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果

还要注意的是,我们还定义了isCategoryScrollAnimation是否在滚动动画结算的变量,用于最后弹性滚动时不要再触发滚动事件了,一个scroll事件去做所有事真的是绕。

整个动画结算完后,还要记得做变量归位,这里用了一个setTimeout阻挡下用户频繁滚动。

这里还有一点,弹性滚动方法smoothScroll,没有去用 css scroll-behavior: smooth,因为整体上最好把这个禁用掉,这也会导致抖抖抖。

   &__scroll-container {
        ...
        scroll-behavior: unset;
        -webkit-overflow-scrolling: auto;
    }

最后附上smoothScroll方法实现源码,支持传滚动速度或者传耗时。


/**
 * 虚拟列表自定义滚动,控制时长
 * @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事件的时机就不适合做这事。

在移动端,还是第一优先用手势来自定义滚动。

使用「vue-virtual-scroller」实现在 App 横向滚动分页效果