资讯动态

鸿蒙ArkUI横向循环列表组件RcList:尺寸计算与自动播放协同方案

发布时间:2026/10/20 5:53:43 来源:尧图企业网站定制
花半年时间打磨一个列表组件听起来像是一件性价比很低的事情。但如果你真的在 HarmonyOS6 上做过横向循环卡片列表又要中间卡片始终居中、两侧卡片半露出、自动播放和手势切换互不打扰就会明白原生组件能给你的只是一层“底子”剩下的几乎全是工程细节。这篇文章要说的 RcList就是我基于 ArkUI 的 List 与 Scroller 封装出来的一个横向循环列表容器。它的核心职责可以归纳成三件事尺寸怎么算、索引怎么循环、自动播放与手势怎么协同。如果你最近也在鸿蒙项目里做类似的卡片旋转列表正被 scrollToIndex 的偏移算不准、列表滚动到边缘回弹这些问题困扰这篇文章应该能帮你省掉几天的排查时间。1. 整体设计与思路拆解1.1 RcList 到底解决什么问题先还原我遇到的实际场景。项目里需要一个首页运营推荐位视觉稿画的是横向排列若干张卡片中间那张最大两边的卡片露出窄边每 4 秒自动向前滚动一张手指触摸时自动播放停下来手指松开后再恢复。这个形态在车载大屏、商品推荐流、Banner 轮播里都很常见。一开始我直接用 List 组件写确实能滚动但离“可用”差得远。最主要的问题是状态逻辑散落得到处都是自动播放要一个定时器滚动停止要监听回调居中放大要计算当前索引卡片越界要处理循环边界。改着改着代码就成了一团乱麻。RcList 就是在那个节点被提出来做组件化的目标是把横向循环列表的公共能力收敛到一个容器组件里让上层页面只需要传入数据、卡片宽度和点击回调剩下的事情都由组件内部消化。这个组件的受众并不是写第一行鸿蒙代码的新手而是已经在用 ArkUI 做业务、开始觉得重复代码太多的同学。如果你的页面里有三五个类似的列表可以毫不客气地说把它们收敛到一个组件里是你最划算的投入。1.2 为什么自封装而不是直接用原生 List技术方案评审时团队里也讨论过要不要直接用官方组件。List 能滚动、支持懒加载、性能潜力大但它不做自动播放、不做卡片居中缩放、不做循环回绕。Swiper 倒是天生带自动播放和循环可是卡片宽度和可视区域的关系被固定死你要做“中间一张最大、左右有半张预览”的效果就得把宽高比、偏移量和缩放系数全部塞进它的属性里调试起来反而更绕。所以我最后选择了在 List 之上自封装。下面是当时对比过的一张方案表需求ListSwiperRcList手势滚动原生支持原生支持基于 List 继承自动播放自己写定时器内置内置居中刻度 / 高低缩放自己算较难调组件内统一计算无缝循环边缘会回弹内置哨兵副本 索引映射卡片尺寸定制自由受限自由且能量化计算看完这个表就清楚了Swiper 适合那种“一整屏轮播”的粗粒度需求而 RcList 适合“卡片队列手动控制”的细粒度需求。开发周期长并不是因为 List 做不了而是要达到“视觉上连续滚动、逻辑上永不越界、算法上可验证”的程度需要处理的边界条件比想象中多得多。这半年里推翻返工最多的方案几乎都集中在尺寸计算这一块。2. 尺寸计算整个组件的调度中枢2.1 视口、卡片与间距的关系推导RcList 看起来是个列表本质是个“可编程的度量问题”。所有视觉效果的起点是下面这四个参数containerWidth列表可视区宽度cardWidth单张卡片宽度gap卡片间隙normalize卡片宽度加间隙也就是滚动一次需要移动的基础步长如果你希望某个索引居中对齐滚动偏移量可以写成targetScroll virtualIndex * (cardWidth gap) - (containerWidth - cardWidth) / 2后半部分 (containerWidth - cardWidth) / 2 表示卡片居中时需要往左预留的余量。举个例子视口宽 360卡片宽 240间隙 12那么 normalize 是 252居中余量是 60。索引 0 的滚动目标就是 0 - 60 -60所以初始化时不能直接停在 0而是要用 scrollToIndex 配合 ScrollAlign.CENTER 把索引 0 卡到中间。那时候我踩过的一个坑是为了省事直接让第一个 item 从列表原点开始排列也就是把居中余量当作负偏移处理。但 List 在内容宽度不足时负偏移会被回弹逻辑吃掉表现为第一张卡片永远无法真正居中。后来我换了思路用“足够多”的哨兵副本把列表内容撑开让居中操作始终发生在离起点很远的位置回弹限制就再也碰不到边界了。2.2 哨兵副本与虚拟索引设计循环列表最忌讳的直接干法是“滚到末尾再跳回 0”。用户会肉眼看到一次生硬的跳变。正规做法是构造一条虚拟序列真实数据放在中间两侧预先铺上副本滚动只在虚拟序列上进行。副本数量不是随便定的我用的公式是sentinelCount ceil(containerWidth / normalize) 2还是以视口 360、卡片 240、间隙 12 为例normalize 是 252ceil(360 / 252) 2加 2 后哨兵数量是 4。意思是左右各铺 4 张副本就能保证从任意真实卡位滚过去时视口里看到的永远是“虚拟序列”的内容不会暴露出发白边。虚拟索引和业务索引的换算关系是virtualIndex sentinelCount businessIndex currentBusinessIndex ((virtualIndex - sentinelCount) % n n) % n末尾那个 n 是搞负数的如果业务索引算出来是 -1ArkTS 里取模会给你一个负数直接拿去渲染必定怪掉。统一写成 ((x % n) n) % n 是处理负数安全的写法。这一行看着小实际是索引问题的重灾区。2.3 动态尺寸适配不同屏幕如果 cardWidth 直接用设计稿里的 280 固定写死小屏上右侧会露出难看的白边大屏上卡片会显得又窄又空。我后来把 cardWidth 改成按容器宽度比例计算cardWidth Math.round(containerWidth * 0.72) gap Math.round(cardWidth * 0.06)如果一屏设计成 720 宽卡片宽就是约 518间隙约 31如果是 360 宽卡片宽约 259间隙约 16。比例方案到了不同尺寸设备上至少不会“破相”。组件内部需要响应容器尺寸变化。我在 List 最外层套了一个 Row 或者直接在组件 build 的根节点上监听 onAreaChange.onAreaChange((oldValue: Area, newValue: Area) { const width Number(newValue.width); if (width 0 Math.abs(width - this.containerWidth) 1) { this.containerWidth width; this.rebuildLoopData(); } })这里判断 Math.abs 是为了避免无意义的频繁赋值。每次容器宽度变化都要重新计算 sentinelCount、重建虚拟序列并且把当前业务索引重新映射到新的 virtualIndex。如果不做这一层重建屏幕旋转或者父容器折叠展开时循环位置会直接错乱。3. 实操过程把 RcList 从原型落到组件3.1 数据结构与对外接口我先定义数据模型。为了后续让外部接入灵活RcListItem 只要求有一个稳定 id这样渲染循环时能用来做 key。export interface RcListItem { id: string; title: string; subtitle?: string; imageUrl?: string; } export interface RcListAction { index: number; item: RcListItem; } Component export struct RcList { Prop items: RcListItem[] []; Prop cardWidth: number 240; Prop gap: number 12; Prop autoPlay: boolean true; Prop loopInterval: number 4000; Prop initialIndex: number 0; onItemClick: (action: RcListAction) void () {}; private scroller: Scroller new Scroller(); private timerId: number -1; State private currentVirtualIndex: number 0; State private containerWidth: number 360; State private loopItems: RcListItem[] []; State private count: number 0; }这些属性一眼望去都很直观。initialIndex 是业务侧的起始位置containerWidth 默认给 360是为了在 onAreaChange 回调触发前组件至少有一套可以渲染的临时尺寸避免首次布局出现 undefined 宽度。3.2 List 主体与循环索引实现组建核心部分是这样搭的build() { List({ scroller: this.scroller, layoutDirection: Axis.Horizontal }) { ForEach(this.loopItems, (item: RcListItem, virtualIndex: number) { ListItem() { RcCard({ model: item, active: this.currentVirtualIndex virtualIndex, onClick: () { const business this.toBusinessIndex(virtualIndex); this.onItemClick({ index: business, item: item }); } }) } .width(this.cardWidth) .margin({ right: this.gap }) }, (item: RcListItem, virtualIndex: number) ${item.id}-${virtualIndex}) } .width(100%) .height(100%) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.None) .onScrollIndex((first: number) { this.currentVirtualIndex first; }) .onAppear(() { this.currentVirtualIndex this.sentinelCount this.initialIndex; this.scroller.scrollToIndex(this.currentVirtualIndex, false, ScrollAlign.CENTER); if (this.autoPlay) { this.startAutoPlay(); } }) .onDetach(() { this.stopAutoPlay(); }) }key 里把虚拟索引拼进去是为了强制 ForEach 识别哨兵副本。业务上两个 item 的 id 可能相同但虚拟位置上它们不是同一个节点。有几个细节是需要特别注意的。edgeEffect 必须设置为 EdgeEffect.None否则滚动到虚拟序列边缘还是会出现回弹scrollBar 一定关掉否则卡片左右两侧会偶尔露出滚动条onScrollIndex 回调的 first 就是当前可视列表项里的第一个虚拟索引拿它当 currentVirtualIndex 足够满足后续业务计算。3.3 自动播放与手势互斥自动播放的核心是定时器但真正难的是和手势互斥。我在 List 上挂了一个 onTouch 事件.onTouch((event: TouchEvent) { if (event.type TouchType.Down) { this.stopAutoPlay(); } else if (event.type TouchType.Up || event.type TouchType.Cancel) { if (this.autoPlay) { this.startAutoPlay(); } } })定时器逻辑就两个方法private startAutoPlay(): void { this.stopAutoPlay(); this.timerId setInterval(() { this.nextCard(); }, this.loopInterval); } private stopAutoPlay(): void { if (this.timerId ! -1) { clearInterval(this.timerId); this.timerId -1; } } private nextCard(): void { const nextVirtual this.currentVirtualIndex 1; this.scroller.scrollToIndex(nextVirtual, true, ScrollAlign.CENTER); }我在 touch 的 Cancel 分支也做了重启因为手势在快速滑动时可能会被系统打断系统会把 TouchType.Cancel 派发下来。如果只处理 Up用户快速滑动停止后定时器不恢复列表会一直停着检查时最容易忽视这个分支。3.4 业务页面接入示例接入页面只关心数据和点击事件其他都交给 RcList 去处理private recList: RcListItem[] [ { id: 1, title: 城市夜景, subtitle: 精选, imageUrl: /res/img/city_night.png }, { id: 2, title: 海边公路, subtitle: 推荐, imageUrl: /res/img/coast_road.png }, { id: 3, title: 雪山日出, subtitle: 热门, imageUrl: /res/img/snow_mountain.png } ]; build() { Column() { RcList({ items: this.recList, cardWidth: 260, gap: 12, initialIndex: 1, onItemClick: (action: RcListAction) { this.showDetail(action.index); } }) .height(160) } .width(100%) .height(100%) }这里我把 RcList 的 height 固定在 160卡片图片按 cardWidth 等比缩放具体内容由 RcCard 内部自行绘制。整个列表组件只负责滚动和调度不做具体业务样式是它能够复用到多个页面的关键。4. 典型故障与排查技巧实录4.1 循环列表跳变与边缘回弹半年里最常被反馈的问题是“滚着滚着突然跳回第一张”。一开始我还以为是指针取模写错了后来在 onScrollIndex 里把 first 打出来看发现问题出在定时器触发时机上用户手指刚离开页面自动播放立刻执行 nextCard这时候惯性滚动还没结束两个滚动指令叠加视口就被强制拉回到一个靠近起点的虚拟位置视觉上就是一次跳动。我的解决办法是把自动播放的启动时机从 touch Up 改到 onScrollStop 回调里。也就是等惯性滚动彻底停下再重新启动定时器。这看起来只不过把启动挪了个位置但触感上完全不一样了。还有一类问题表现为滚到虚拟序列末尾时画面“抽一下”。原因是 sentinelCount 给的太小右侧副本数量不足以填满视口。解决方法是把 probe 值调大我可以明确告诉你宁可多放两个副本也别省这一点内存。几张卡片的性能开销几乎为零但视觉上的回弹白边让人非常难受。4.2 尺寸计算经典 Bug 速查表下面这些是我整理出来最常踩的四个坑现象原因修复方向首张卡片无法居中居中余量被 List 的边界回弹吃掉增加哨兵副本用 ScrollAlign.CENTER 定位最后一张卡片后有大片空白sentinelCount 不够视口露出尾部按 ceil(containerWidth / normalize) 2 加大副本屏幕旋转后位置错乱容器宽度变化未触发重建onAreaChange 里重新算 containerWidth 与 loopItems点击某项时下标总是错的virtualIndex 与 businessIndex 混用统一通过 toBusinessIndex 方法做转换第 4 个坑尤其隐蔽。因为 onItemClick 回调里传的是业务下标而渲染时用的是虚拟下标。如果两个没分开在新增两条数据和删除一条数据之后下标就会差一截。我在组件外暴露的一律是业务下标虚拟下标绝对不允许泄漏出去。4.3 性能优化与列表项复用循环列表的数据规模不大但要长期运行还是要把性能控制好。早期版本我在每个卡片内部直接根据 currentVirtualIndex 算透明度滚动时整个 ForEach 不断重新评估帧率掉得厉害。后来把卡片拆成 RcCard 子组件内部只接收 model 和 active 两个参数。active 变化时只有中间那张受影响其他卡片不会被脏检查波及。此外图片加载统一走项目里的缓存图库不在 ListItem 内直接加载大图避免图片解压阻塞 UI 线程。还有一个小心得不要用业务字符串 id 拼 key 时把时间戳也拼进去。有一版我为了“保险”在 key 里加了 Date.now()结果每次刷新所有 item 都被认为是不认识的节点整个列表重建滚动位置直接失控。这个坑让我意识到key 只应该由数据源本身的稳定性决定不能加任何易变因素。5. 半年打磨的一个心得体会5.1 组件稳定的前提是边界可以被计算RcList 的每一次定位、每一次尺寸分配都不是靠“试到差不多”得来的。我把视口的宽度、卡片的宽度、间隙、哨兵的数目全部定义成有公式可言的变量后心里才真正有底。有人问一个轮子值得花半年吗我的理解是前三个月都在踩坑后三个月是在把踩过的坑变成公式。一旦量化完成后面再做相似组件时复用速度会快得惊人。5.2 后续可以继续扩展的方向目前 RcList 只是横向版本。如果产品下次要一个竖向循环列表我会把轴方向改成参数把尺寸计算抽象成统一的测量管线。另一个想扩展的是卡片倾斜角度让非主卡片带上细微的 3D 透视。ArkUI 的 transform 已经支持这类接口只是需要把角度计算也纳入尺寸模型里。最后分享一个真正的工程习惯渲染逻辑和业务逻辑永远分文件。RcList 的尺寸计算我一律放在独立工具模块里组件文件只负责调用。这样即使有一天方案推倒重来尺寸算法也能作为基础能力继续保留下去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑