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

本文值得一写,笔者也是没找到合适的解决方案,而且被 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也是同样的实现思路:

关联文章:

通过使用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事件的时机就不适合做这事。

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

相关推荐
xkxnq几秒前
第八阶段:工程化、质量管控与高级拓展(130天),Vue端到端测试:Cypress自动化测试(登录流程+表单提交+页面跳转)
前端·vue.js·flutter
老毛肚4 分钟前
jeecgboot vue API 拆分02
前端·javascript·vue.js
爱因斯坦乐11 小时前
Vue项目整合
前端·javascript·vue.js
ct97812 小时前
组件间的通信
前端·javascript·vue.js
左手吻左脸。12 小时前
Vue 全栈面试题大全(2026 最新版最详细)
前端·javascript·vue.js
小新11013 小时前
最简单但完整的 Vue 响应式示例(一个简单的计数器按钮)
前端·javascript·vue.js
刘海不能乱1615 小时前
Java JUC源码分析系列笔记-Synchronized
vue.js
whatever who cares16 小时前
Vue3中vue文件和composables的分工
前端·javascript·vue.js
薛先生_09916 小时前
vue-编程式跳转-基本跳转
前端·javascript·vue.js
风吹夏回19 小时前
TypeScript 快速上手指南:从 JavaScript 到类型安全
javascript·ubuntu·typescript