花半年时间打磨一个列表组件,听起来像是一件性价比很低的事情。但如果你真的在 HarmonyOS6 上做过横向循环卡片列表,又要中间卡片始终居中、两侧卡片半露出、自动播放和手势切换互不打扰,就会明白原生组件能给你的只是一层“底子”,剩下的几乎全是工程细节。这篇文章要说的 RcList,就是我基于 ArkUI 的 List 与 Scroller 封装出来的一个横向循环列表容器。它的核心职责可以归纳成三件事:尺寸怎么算、索引怎么循环、自动播放与手势怎么协同。如果你最近也在鸿蒙项目里做类似的卡片旋转列表,正被 scrollToIndex 的偏移算不准、列表滚动到边缘回弹这些问题困扰,这篇文章应该能帮你省掉几天的排查时间。
1. 整体设计与思路拆解
1.1 RcList 到底解决什么问题
先还原我遇到的实际场景。项目里需要一个首页运营推荐位,视觉稿画的是横向排列若干张卡片,中间那张最大,两边的卡片露出窄边,每 4 秒自动向前滚动一张,手指触摸时自动播放停下来,手指松开后再恢复。这个形态在车载大屏、商品推荐流、Banner 轮播里都很常见。
一开始我直接用 List 组件写,确实能滚动,但离“可用”差得远。最主要的问题是状态逻辑散落得到处都是:自动播放要一个定时器,滚动停止要监听回调,居中放大要计算当前索引,卡片越界要处理循环边界。改着改着代码就成了一团乱麻。RcList 就是在那个节点被提出来做组件化的,目标是把横向循环列表的公共能力收敛到一个容器组件里,让上层页面只需要传入数据、卡片宽度和点击回调,剩下的事情都由组件内部消化。
这个组件的受众并不是写第一行鸿蒙代码的新手,而是已经在用 ArkUI 做业务、开始觉得重复代码太多的同学。如果你的页面里有三五个类似的列表,可以毫不客气地说,把它们收敛到一个组件里是你最划算的投入。
1.2 为什么自封装而不是直接用原生 List
技术方案评审时,团队里也讨论过要不要直接用官方组件。
List 能滚动、支持懒加载、性能潜力大,但它不做自动播放、不做卡片居中缩放、不做循环回绕。Swiper 倒是天生带自动播放和循环,可是卡片宽度和可视区域的关系被固定死,你要做“中间一张最大、左右有半张预览”的效果,就得把宽高比、偏移量和缩放系数全部塞进它的属性里,调试起来反而更绕。
所以我最后选择了在 List 之上自封装。下面是当时对比过的一张方案表:
| 需求 | List | Swiper | RcList |
|---|---|---|---|
| 手势滚动 | 原生支持 | 原生支持 | 基于 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 是 252,ceil(360 / 252) = 2,加 2 后哨兵数量是 4。意思是左右各铺 4 张副本,就能保证从任意真实卡位滚过去时,视口里看到的永远是“虚拟序列”的内容,不会暴露出发白边。
虚拟索引和业务索引的换算关系是:
virtualIndex = sentinelCount + businessIndex currentBusinessIndex = ((virtualIndex - sentinelCount) % n + n) % n末尾那个 + n 是搞负数的:如果业务索引算出来是 -1,ArkTS 里取模会给你一个负数,直接拿去渲染必定怪掉。统一写成 ((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 的尺寸计算我一律放在独立工具模块里,组件文件只负责调用。这样即使有一天方案推倒重来,尺寸算法也能作为基础能力继续保留下去。