ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ScrollWheel滚动选择器如何支持不同高度组件?

ScrollWheel滚动选择器如何支持不同高度组件? “Cant the ScrollWheel set components of different heights?”——这句话我太熟悉了。几乎每个用过多端 UI 组件库的人早晚都会在某天盯着那个只能统一高度的滚动选择器发出同样的灵魂拷问。默认情况下 ScrollWheel滚动选择器/滚轮选择器确实只允许等高的 item你塞一个不同高度的组件进去轻则布局错乱重则直接白屏或拦截事件。我最早踩这个坑是在做一个订单筛选页产品要求中间一列显示“日期星期”上下两列显示辅助信息高度天然不一样。当时我翻遍组件文档也只看到 fixedHeight 之类的固定高度属性最后花了整整两天自己造了一个支持可变高度的轮子。这篇文章就把我当时的研究过程、踩坑记录和最终方案完整写出来给正在被同样问题折磨的朋友一个可落地的参考。1. 问题本质ScrollWheel 为什么默认只能等高1.1 ScrollWheel 的结构与默认行为ScrollWheel 这类组件的本质是一个“虚拟列表 滚动吸附”的组合体。通常它由三部分构成容器Viewport可视区域负责裁剪超出部分滚动层Scroll Content承载所有 item 的容器可以横向或纵向移动吸附逻辑滚动停止后自动将最近的 item 对齐到指定位置。在绝大多数开源组件和商业组件库中ScrollWheel 默认假设所有 item 高度一致因为它可以直接通过预估高度来计算出滚动层的总长度、item 的索引位置以及吸附偏移量。整个计算只需要一次乘法总长度 item高度 × item数量 当前索引 round(滚动偏移 / item高度) 吸附偏移 当前索引 × item高度这种设计在性能和复用性上都有天然优势。虚拟列表只需要渲染可视区域内的几个 item其余全部复用滚动时只需要维护一个位移量。等高假设让这一切变得非常简单、稳定。1.2 组件库为什么“拒绝”可变高度很多人以为是组件库偷懒其实不完全是。可变高度对内部实现造成的冲击是结构性的无法预知总长度你必須等所有 item 渲染完才知道真实高度无法在首帧就给出滚动条的精确范围索引计算复杂度上升等高的索引是 O(1) 的公式推导而可变高度需要基于累积高度数组做二分查找复杂度变成 O(log n)复用策略复杂化虚拟列表要复用 item就必须在滚动过程中动态测量、动态调整 item 的位置和后继 item 的起点处理不好就会出现跳动或白屏吸附逻辑不再线性吸附不再是简单的“偏移量除以固定高度”而是要在滚动停止时遍历候选节点找到最近的对齐点。所以很多组件库干脆不做这个能力或者只通过“提供 itemHeight 回调让调用方预先告诉每个索引的高度”这种半残方案来妥协。明白了这些底层原因你就能判断到底是换一个组件还是自己造轮子还是调整业务方案绕过去。2. 需求拆解你真的需要不同高度吗2.1 真需求的常见场景我整理了真实项目中会“逼你”使用可变高度的最普遍场景供大家对照场景需求描述是否真需要可变高度日期时间选择中间列显示“今天 2024-05-20 周一”两侧显示上下午是辅助信息列比主列窄且高度不同级联地址选器省级、市级、区级列宽高度不同或带“全部”图标项是音视频倍速选择某些档位需要附加说明文字如“1.0x 标准播放”是商品规格选择每个规格项内容长短不一标签换行率不同是强行等高会让文案截断调查问卷滚动项选项有纯文本和带图片/评分星星的组合是本质是混合内容列表如果你的需求落在这些场景里那可变高度就是刚需绕不过去。2.2 伪需求识别能通过设计规避就别硬扛但必须提醒的是我也见过不少场景是产品还没想清楚开发就直接上了可变高度的复杂方案。比如选项内容长短不一但最短的和最长的高度差不超过 20px。这种完全可以通过统一设定一个最大高度、内容垂直居中、超出省略号处理来解决只有一个特殊 item 需要展示更多信息。可以考虑在用户选中后用弹层或下方展开区展示详情而不是把特殊 item 硬塞进滚轮不同列需要不同宽度/高度但每列内部是等高的。这种情况正确做法是拆成多个 ScrollWheel 并列联动而不是在一个滚轮里做可变高度。判断标准很简单如果不问“为什么”就没人会注意到这一列里的元素高度不同那就不值得为它引入一整块复杂度。遇到伪需求先用成本和风险说服产品改设计是省力的最优解。只有当内容差异确实影响阅读和操作时才进入下面要讲的硬核方案。3. 方案选型绕开限制的三种主流思路3.1 思路一多 ScrollWheel 联动如果需求是“不同列有不同的等高”那就别硬在单个 ScrollWheel 里做。直接拆成多个 ScrollWheel 实例每一列内部保持等高。列与列之间通过当前选中索引实现联动比如日期选择器里改了年月和日列就要刷新可选范围。这种方案的好处是用到了组件库的原生能力稳定且性能好。缺点是列宽和列高仍然需要全局协调视觉上要保证几列内容的中心线对齐。实操时需要把每个滚轮的 item 高度设为同一个值比如都是 44px但每列内部的内容高度可以不同内容在 item 容器内自由排版。这样外部看是“不同高度”内部其实是统一 item 高度 差异化内容布局规避了问题本质但视觉目标达成了。3.2 思路二提供高度回调的组件部分组件库如有些 Vue 生态里的 picker 组件支持给每个 item 传入动态高度函数。如果你已经在使用这类组件改造成本最低。使用时的关键参数是itemHeight既可以传固定值也可以传(index, data) number的函数需要额外开启dynamic模式否则组件内部仍按固定高度计算需提供totalHeight或允许组件在数据变更时重新测量。这类组件在数据量小于几百条时表现不错。一旦数据量达到千级每次数据刷新都要触发全量重新测量性能会很差。建议数据超过 500 条就考虑思路三。3.3 思路三基于虚拟列表自研如果上面的方案都不满足或者组件库完全不支持那就只能走自研路线。说实话自研 ScrollWheel 没有想象中那么可怕尤其是现在各种框架都有成熟的虚拟列表底座比如小程序的 recycle-view、前端的 react-window、Flutter 的 ListView.builder 等我们只需要在虚拟列表上叠加吸附逻辑即可。我最终采用的是这个方案。在展开详细介绍之前先把组件的核心结构图用文字描述清楚Viewport可视区固定高度overflow hidden └── ScrollLayer按 transform 移动的容器 ├── Item 0高度 h0 ├── Item 1高度 h1 ├── Item 2高度 h2部分可见 ... └── Item N超出可视区不渲染关键点是ScrollLayer 并不直接渲染所有 item只渲染可视区前后各若干个buffer 区然后根据滚动偏移动态计算每个 item 的绝对位置。下面进入实操环节。4. 实操环节从零写一个支持不同高度的 ScrollWheel4.1 核心数据结构与准备我先明确一下我们需要维护的核心数据。以竖向滚轮为例items: Array{ id: string, content: any, height: number } positions: Array{ top: number, bottom: number } // 累积位置用于索引二分查找 scrollOffset: number // 当前滚动偏移量 viewerHeight: number // 可视区高度这里的positions是关键。它记录了每个 item 在滚动层中的绝对 top 和 bottom这样无论 item 高度如何变化我们都能根据滚动偏移量判断某个 item 是否在可视区内以及它距离视口中心有多远。首次渲染时需要对所有 items 做一次预测量。如果 item 高度无法在数据层直接获得就需要在渲染前先对每个 item 做一次离屏测量。离屏测量的通用做法是将 item 渲染到一个visibility: hidden或者position: absolute; left: -9999px的容器中等它挂载后再读取offsetHeight。这一步是自研滚轮最耗时的地方可以优化的空间也比较大建议在数据层尽量缓存height减少重复测量。4.2 布局与滚动测量实现接下来是布局的核心逻辑。我用伪代码风格写一下每一帧需要执行的步骤function updateVisibleItems(scrollOffset): // 1. 二分查找可视区起点 startIndex binarySearch(positions, scrollOffset - buffer) // 2. 计算可视区终点 endIndex binarySearch(positions, scrollOffset viewerHeight buffer) // 3. 渲染 startIndex 到 endIndex 的 item // 4. 将每个 item 设置到绝对位置translateY(positions[i].top)吸附逻辑相对独立。常规做法是监听滚动结束事件或滚动速度低于阈值后计算当前偏移量对应的最近锚点。function snapToNearest(): // 候选锚点所有 item 的顶部和底部 candidates positions.flatMap(p [p.top, p.bottom]) // 找到距离当前偏移量最近的一个锚点 target minBy(candidates, c Math.abs(c - scrollOffset)) // 平滑动画移动到 target animateScrollTo(target)这里有个细节容易被忽略锚点不能只取 item 顶部否则会出现大高度 item 的中间区域成为空白吸附区。例如一个 200px 高的 item 占据了视口中间用户滑动 100px 后松手如果只按顶部锚点吸附它可能直接就弹回上一个 item 的起点视觉上非常诡异。所以一定要把底部也纳入候选锚点让大 item 中间区域能稳定吸附到接近中心的位置。4.3 帧循环与性能踩坑自研滚轮的性能关键在于减少不必要的重排。我建议滚动过程完全不走布局属性只更新transform: translateY并且对 item 使用绝对定位。绝对定位的好处是 item 脱离了文档流修改它的定位不会触发父容器的重新计算。滚动频率高时可以配合requestAnimationFrame合并更新避免一帧内多次 setData小程序场景尤其重要。我在 React 和小程序里分别实现过一版经验是React 里推荐把scrollOffset存到 ref不触发 React 渲染只对每个 item 用transform直接操作 DOM或者用useLayoutEffect批量更新小程序里避免在滚动回调里直接 setData 每个 item 的位置正确的做法是只 setData 一个scrollOffset然后用wxs或worklet在视图层计算每个 item 的位置Flutter 里的 ScrollWheel 通常用ScrollableViewport组合item 高度变化时要把itemExtent设为null否则框架会按固定尺寸优化导致高度错误。4.4 吸附动画与阻尼手感吸附动画最怕的是“硬跳”。我试过直接把目标偏移量赋给滚动容器视觉效果就是页面猛地一抖。正确做法是做一个easeOutCubic的缓动动画从当前偏移出发在 250~350ms 内插值到目标锚点。另外要注意阻尼逻辑。如果用户在滚动过程中再次触发了新的滑动需要立即取消上一次的动画。常见的实现是维护一个animationId每次启动新动画前cancelAnimation旧动画。这个细节不处理好就会出现两个动画互相打架滚轮像抽风一样来回抖动。5. 常见问题与排查技巧实录5.1 滚动时 item 跳动或闪烁现象滚动过程中某些 item 突然消失或者位置跳到了错误的高度。原因绝大多数情况是虚拟列表的 buffer 计算不到位。比如可视区高度是 300px你只渲染了可视区内的 item但滚动速度较快的时候一帧之内偏移量变化可能超过 300px导致新进入可视区的 item 来不及渲染滚动层出现空白。解决把渲染范围从“可视区”扩大到“可视区前后各一屏”甚至两屏。以 300px 可视区为例渲染范围建议设为scrollOffset - 300到scrollOffset 600。这样即便手指猛甩下一帧也能覆盖到新增区域不至于闪白。5.2 动态高度环境下吸附位置不准现象滚轮停住后选中的 item 没有居中对齐或者吸附到了错误的位置。原因大部分情况下是positions数组和实际渲染的 item 高度不一致。比如某个 item 在首帧测量时宽度还没确定导致高度偏小或者字体加载完成前后 item 高度发生变化。解决用ResizeObserver或框架内的 resize 监听监听每个 item 的尺寸变化一旦发现高度变化就重建 positions 数组并修正当前 scrollOffset 对应的索引。这里的重建过程要避免累积误差从变化点开始重新累加而不是只改一个 item 的高度。注意字体加载是动态高度滚轮最常见的隐性坑。中文 webview 里如果没有预加载完整字体首帧测量高度和最终高度可能相差几个像素。建议在字体加载完成后主动触发一次重新测量。5.3 性能问题大数据量下首屏卡顿现象数据量上千条时初始化耗时明显滚动掉帧。原因初始化时要对所有 item 做测量和位置计算。等高的组件库之所以快是因为它不需要测量直接公式计算。自研方案为了避免测量最好要求调用方在数据层就给出height。如果无法提供那就只能接受一次 O(n) 的测量开销。优化思路对确定性的内容如纯文本、固定行数的内容在数据层直接算好高度对不确定的内容如图片、富文本采用“预估高度先行渲染图片加载后二次修正”的策略快速滚动时跳过中间 item 的渲染只渲染大概率停留的 item类似懒加载大量 item 复用保证 item 组件按索引复用而不是每次重建 DOM。按照我的实测在普通移动端浏览器里不上任何重型优化500 条以内条目的动态高度滚轮在 60fps 下流畅运行不费劲但超过 1000 条就必须走按需二次测量和渲染节流否则初始化时间会明显拉长。5.4 触摸事件与滚轮鼠标事件冲突现象在桌面端用鼠标滚轮操作时滚轮没有反应或者页面整体滚动而不是滚轮内部滚动。原因ScrollWheel 的事件系统一般只处理触摸/拖拽鼠标滚轮事件需要单独绑定。如果你在某个桌面 Web 项目里使用自研滚轮一定要手动监听wheel事件并调用滚动方法同时调用preventDefault()阻止外层页面滚动。这里有个事件冲突的经典坑如果wheel事件绑定的容器内还有其他需要滚动的区域preventDefault 会把它们一起禁掉。我的做法是判断事件的target是否在滚轮内部只在内部时才阻止默认行为。另外监听器要挂在被动模式{ passive: false }下否则浏览器会忽略 preventDefault。6. 测试清单与验收要点写到最后分享一份我每次改完滚轮都要跑一遍的测试清单大家可以复制到自己的项目里当验收标准检查项预期快速惯性滑动后松手平滑吸附到最近锚点无跳动滑动到一个超高 item 中间松手吸附到该 item 顶部或底部附近不偏离动态修改数据源增删 item位置数组正确重建无累计误差字体加载完成前后所有 item 重新测量滚动位置不漂移鼠标滚轮 触摸屏 触控板三种输入方式均能正常控制滚轮数据量 1000 条时首屏渲染不卡死首帧出现时间可接受选中状态与高亮高亮 item 能跟随吸附结果同步更新这份清单里每一项我都曾经在上述自研过程中踩出过问题尤其是“字体加载”和“超高 item 中间松手”这两个属于纯靠文档很难发现的盲区。如果你按这个清单走一遍基本能覆盖掉 90% 的常见故障。关于 ScrollWheel 能不能设置不同高度的组件我的最终结论是如果组件库没有原生支持不要硬改它的内部逻辑直接基于虚拟列表自研一个带可变高度的版本反而可控得多。调整好测量时机、吸附锚点和渲染 buffer 这三个关键点这个轮子做出来不仅比强行 hack 组件更稳后续遇到更复杂的滚动需求比如分组吸顶、无限循环滚动也都有了现成的扩展底座。
返回列表