从去年底把通讯录模块重构完,到这次把城市选择器、商品分类页全部收敛到同一个索引列表方案上,前前后后差不多磨了半年。这半年里,RcIndexList 从最初一个只能“点字母跳分组”的雏形,慢慢长成了现在既能处理多级分组、又能和双列表联动、还扛得住大数据量滚动刷新的通用组件。趁着这次把案例整理成册,把过程中踩过的坑、想通的设计、以及那些文档里根本不会写的细节,一并沉淀下来。
这篇进阶篇面向的是已经用过 IndexList、或者至少对 ArkUI 列表类组件有基本认识的开发者。如果你只是刚接触 HarmonyOS 开发,建议先跑一遍基础组件的官方示例再回来看这篇,否则很多优化点和坑点会缺少上下文。我尽量把每个案例拆到“为什么要这么做”的层面,而不是只给代码。
1. 为什么需要一个叫 RcIndexList 的索引列表组件
1.1 通讯录场景的三个硬需求
最初的需求其实只有一个:做一个带字母索引的通讯录。听起来简单,但把需求拆开之后就变成三个绕不开的硬指标。
第一,右侧字母索引条必须常驻。用户在快速找人时,肌肉记忆会直接去点屏幕右侧的字母,如果索引条要滑动才出现,体验上就废了一半。第二,列表滚动时索引条上的高亮字母必须同步变化,而且不能滞后。第三,按压索引条时弹出的字母气泡(IndexPopup)要跟手,拖动到哪个字母,气泡就得立刻切到哪个字母。
这三条分别对应了索引条的绘制、列表滚动状态的双向绑定、以及手势层级的处理。系统自带的 IndexList 能解决一部分,但做到“跟手”和“同步”这两件事时,自定义程度明显不够。IndexList 内部把索引条和列表绑定得比较死,想改索引条的布局、想接入双列表联动、想在滚动时做额外的状态分发,都会觉得施展不开。
于是我们决定自己封装:底层列表仍用原生组件,索引条和锚点定位逻辑自己做,对外暴露一套简洁的分组数据模型和回调。这样既保留原生列表的滚动性能,又把控制权收回到手中。
1.2 组件边界:做什么,不做什么
封装组件最容易犯的错是“什么都管”。RcIndexList 的设计初期就有三件事被明确排除在外:不做数据源管理、不做网络加载、不做业务占位样式。它只做一件事:把“分组数据”渲染成“可滚动、可定位、索引同步”的列表。
数据源由调用方传入,组件内部只维护分组结构。调用方把数据按组整理好,比如 contacts: { A: [张三, 张四], B: [李五], …… },组件负责遍历渲染、计算锚点、响应索引条事件。这样做最直接的好处是接入成本低:任何场景,只要你能把数据整理成分组结构,就能直接用。
底层技术上走的是“List 容器 + sticky 标题 + 自定义 overlay 索引条”的组合。为什么不直接用系统 IndexBar?因为我们要的索引条在某些场景下是“热”字、在某些场景下是“#”号,甚至有些场景还要支持双列索引。系统的 justifyContent 和布局规则没法覆盖这么多变体,自己画反而成本更低。
// RcIndexList 对外暴露的最小接口示意 @Component export struct RcIndexList { @Prop groups: RcIndexGroup[]; // 分组数据 @Builder itemBuilder: (group: RcIndexGroup, index: number) => void; // 列表项渲染 @Builder indexBarBuilder?: (groups: RcIndexGroup[], activeKey: string) => void; // 索引条渲染 @Event onIndexChange?: (key: string) => void; // 索引变化回调 @Event onScrollToIndex?: (groupIndex: number) => void; // 滚动定位回调 }2. 案例一:通讯录模块的字母索引与锚点定位
2.1 数据分组与索引 key 的生成
通讯录的分组逻辑看似简单——按姓氏拼音首字母分组——但真正落地时有两个坑:多音字和特殊字符。
多音字的典型例子是“单”姓,读“shàn”时归入 S 组,读“dān”时归入 D 组。项目里最终采用的方案是引入一个简化的多音字姓氏映射表,只覆盖常见的高频姓氏,匹配不到就按当前拼音库的结果走。这个表一开始只加了十几个姓,后来通过用户反馈逐步扩充,最终覆盖了市面上绝大多数通讯录场景。
特殊字符和数字开头的联系人则统一归到一个“#”组,放在列表末尾。注意,这里有个细节:当没有特殊字符联系人时,“#”组不应该出现在索引条上。所以索引条的数据源是“由实际分组数据推导出来的”,而不是定义死的 26 个字母。
interface ContactModel { name: string; phone: string; pinyin: string; // 预计算拼音 } function groupContacts(contacts: ContactModel[]): RcIndexGroup[] { const map = new Map<string, ContactModel[]>(); const special = '#'; // 特殊字符组名 for (const contact of contacts) { const char = contact.name.charCodeAt(0); const isLetter = (char >= 65 && char <= 90) || (char >= 97 && char <= 122); let groupKey: string; if (isLetter) { groupKey = contact.pinyin.charAt(0).toUpperCase(); } else { groupKey = special; } if (!map.has(groupKey)) { map.set(groupKey, []); } map.get(groupKey)!.push(contact); } // 按字母序排列,# 组固定放在最后 const keys = Array.from(map.keys()).sort((a, b) => { if (a === special) return 1; if (b === special) return -1; return a.localeCompare(b); }); return keys.map(key => ({ key, items: map.get(key)!, isSpecial: key === special })); }分组数据的 key 生成建议在数据进列表之前完成,不要在渲染时临时算拼音。通讯录这种场景数据量不大,但商品类和城市类场景可能有上万条数据,渲染时临时计算会造成可感知的卡顿。
2.2 悬挂标题与索引条联动实现
通讯录的列表项需要“字母标题在滚动时吸顶”,这个在 ArkUI 里直接用 List 的 sticky 属性就能做:
List({ space: 0, scroller: this.scroller }) { ForEach(this.groups, (group: RcIndexGroup, groupIndex: number) => { ListItem() { Text(group.key) .fontSize(14) .width('100%') .padding({ left: 16, top: 8, bottom: 8 }) .backgroundColor('#F5F5F5') } .sticky(StickyStyle.Header) // 标题吸顶关键属性 ForEach(group.items, (item: ContactModel) => { ListItem() { // 联系人行的 UI 渲染 } }) }) }sticky 属性加在 ListItem 上,指定 Header 样式即可实现吸顶效果。这比自己在滚动回调里计算偏移再补位要省事得多,也是官方推荐做法。
索引条联动是这部分的重点。右侧索引条用 overlay 叠加在 List 上方,不参与布局。核心逻辑分成两块:点击/拖动索引条时跳转到对应分组,列表滚动时反向更新索引条高亮。
跳转分组的实现依赖 scroller 的 scrollToIndex 方法,因为 List 的 item 顺序和分组顺序是严格对应的,所以分组序号可以直接映射成 item 序号。这里有个精妙处:由于 sticky 标题占据了额外的布局空间,直接 scrollToIndex 会让目标组停留在列表顶部,恰好和吸顶标题的位置重合,视觉上完全无感。
scrollToGroup(groupIndex: number) { // 需要补偿第一个标题的位置,否则标题会恰好顶在顶部被遮住 const targetIndex = groupIndex * (itemsPerGroup + 1); this.scroller.scrollToIndex(targetIndex, true, ScrollAlign.START); }2.3 字母气泡(IndexPopup)的表现细节
按压索引条时的字母气泡,是体验好坏最直观的地方。具体到实现,气泡本质是一个跟随手指位置移动的浮层,里面显示当前命中的字母。这里有三点控制直接决定手感:
气泡跟随不跟手的主要原因是浮层更新和手势事件的频率不对齐。手指滑动的 onTouch 事件频率远高于 UI 刷新的能力,所以要在浮层里做一层缓冲:记录上一次显示的字母,只有字母变化时才更新状态,避免每帧都触发重新渲染。
气泡的尺寸不要太小。我们试过 64vp 直径,在高速滑动时人眼容易丢失焦点;最终定在 84vp 左右,视觉上既不抢眼,又能让余光稳定捕捉到字母变化。
气泡的圆角、阴影、背景透明度尽量做成参数暴露,不要写死在组件里。城市选择器里我们用了完全不同的气泡风格(方角、无阴影),同一个组件的可配置性在这一刻体现出了价值。
// 索引条按压浮层的核心交互逻辑 .onTouch((event: TouchEvent) => { if (event.type === TouchType.Down || event.type === TouchType.Move) { const y = event.touch[0].y; const groupIndex = this.getGroupIndexByPosition(y); if (groupIndex !== this.currentGroupIndex) { this.currentGroupIndex = groupIndex; this.activeKey = this.groups[groupIndex].key; this.popupVisible = true; this.popupKey = this.activeKey; } } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { this.popupVisible = false; } })3. 案例二:城市选择器与多级分组数据
3.1 热门城市与字母分组的双层结构
城市选择器的数据结构和通讯录有一个本质区别:顶部多了一个“热门城市”区域。这个区域不是普通分组,它固定悬浮在列表上方,不参与滚动,也不出现在右侧索引条上。
如果简单地把热门城市作为列表的第一个分组,那么“回到顶部”这个操作会先经过热门区再回到 A 组,交互上多一步。更好的做法是:热门区作为一个独立的容器,放在 RcIndexList 外面,滚动事件只作用于下方列表。这样顶部区域天然固定,下方的索引定位也完全不需要知道热门区的存在。
具体实现时,页面的 Column 结构为:顶部热门区(高度自适应,不滚动) + 中间状态栏(可选) + 下方 RcIndexList(占满剩余空间)。城市列表沿用分组模型,但有一个特殊处理:索引条的最顶部增加一个“”符号(代表“热门”)。点击“”时,列表滚动回顶部。
这比把“热门”作为第一个分组更合理,因为“热门”不是字母也不是首字母分组的自然成员,它在语义上属于“快捷入口”而不是“分组”。
3.2 特殊索引字符与分组偏移计算
城市选择器的另一个变体需求是:索引条除了 A-Z 之外,开头有“*”(热门),结尾通常还有“#”(其他区县、港澳台或特殊地名)。这意味着索引条的总高度、每个索引项的高度分配,都跟通讯录场景不一样。
RcIndexList 在处理索引条时不能写死“每项高度相等”。实测下来,当城市数量多、索引项密的时候,“*”和“#”如果和其他字母一样高,手指定位时容易误触。我们把特殊字符的项高度设置为字母项的 1.2 倍,并在命中判断时单独处理,点击区域和视觉区域保持一致。
分组偏移量计算是城市选择器最容易出 bug 的地方。列表假设有 36 个分组(26 个字母 + 热门 + 数字 + 特殊 + 其他),其中“热门”并没有占据列表的滚动位置,而是悬浮在上方。因此索引条上“热门”对应的分组序号是 -1,点击时直接让列表滚动到 offset 为 0 的位置,而不是 scrollToIndex(-1),否则会崩溃或定位错乱。
onIndexBarSelect(key: string) { if (key === '*') { this.listScroller.scrollEdge(Edge.Top); // 回到顶部 } else { const groupIndex = this.groups.findIndex(g => g.key === key); if (groupIndex >= 0) { this.listScroller.scrollToIndex(this.getListItemIndex(groupIndex), true); } } }一个更隐蔽的问题是:当热门区高度变化(比如不同城市数量导致热门区显示 4 个或 6 个城市)时,列表的滚动区域高度不变,但锚点位置会受影响吗?不会。因为热门区在列表外面,列表内部的滚动锚点是独立计算的,只需关注当前分组在列表内的 item 序号即可。
3.3 大列表全量刷新与数据重建开销
城市数据通常是省市区三级结构,一份全量数据大约两三千条。在用户切换省份时,需要重新生成城市列表的分组数据。直接重建数组并推给 List,会引发全量刷新,代价不小。
实测下来有三个优化点收益最明显。
分组数据生成后用不可变数据结构存储,每次切换省份只替换整个分组数组的引用,而不是修改已有数组的项。
单元格的 key 用稳定 ID(城市编码)而不是索引。这样即使列表重建,相同 key 的单元格也能被框架复用,不会出现闪烁或位移。
切换到省份时如果当前已经有列表位置,优先尝试保持当前位置的视觉连续性。做法是把当前的滚动偏移按旧数据的总高度折算成新数据的近似偏移,再 scrollToIndex。这个做过和没做过,用户的感知差异非常大。
onProvinceChange(newProvince: ProvinceModel) { const oldFirstVisible = this.listScroller.currentOffset().yOffset; const oldTotalHeight = this.totalListHeight; // 重新生成分组数据 this.groups = this.buildGroups(newProvince.cities); // 换算新的近似偏移 const ratio = this.totalListHeight / oldTotalHeight; const approximateY = oldFirstVisible * ratio; // 先恢复到近似位置,再平滑调整 this.listScroller.scrollTo({ yOffset: approximateY, animated: false }); }这里要注意 ratio 的边界:当旧列表高度为 0(首次加载)时,ratio 会变成无穷大,要兜底处理。这类边界条件肉眼很难发现,建议写单元测试覆盖。
4. 案例三:商品分类页的双列表联动
4.1 左右布局与滚动方向的定义
商品分类页是电商类应用的标配:左侧是分类轴,右侧是对应分类下的商品列表。这里的“分类轴”在视觉效果上跟字母索引条很像,但本质上是另一个 List。也就是说,这个页面是“两个 List 联动”,一个横向意义上的状态选择器,一个纵向滚动的商品流。
RcIndexList 在这个场景下做了一个特殊的变体:右侧列表复用索引列表的分组模型,但索引条不再浮在右侧,而是变成左侧的独立列表。这也是当初设计时把索引条抽象成 builder 插槽的原因——同一个定位逻辑,可以驱动完全不同的视觉形态。
右侧列表的分组和通讯录一样:每组一个标题 + 若干商品项。左侧列表则是简化版的一维 List。联动关系只有一句话:点击左侧分类,右侧滚动到对应分组锚点;右侧滚动经过分组标题,左侧高亮同步切换到对应分类。
4.2 锚点滚动的两种实现对比
实现右侧列表“滚到对应分组”有两种路线。
第一种是把滚动偏移换算成分组在列表中的 item 索引,然后 scrollToIndex。这也是 RcIndexList 通讯录场景的默认做法。问题在于,当商品列表项高度不一致(不同商品卡片高度不同,还有横幅、活动位穿插)时,单纯按索引定位可能不够精确,需要额外测量高度。
第二种是直接 scrollToOffset,把目标分组的标题偏移量作为 yOffset。实现这种方式需要预先测量每个分组在滚动容器中的偏移量,商品数据变化后还要重新测量。代码复杂度高,但定位精度是绝对的。
实测下来的结论是:只要商品项不是异形高度差异特别大,scrollToIndex 的精度完全够用,而且实现和调试成本都低得多。最终的落地方案是默认走 scrollToIndex,并在分组高度波动剧烈的特殊页面里,用 scrollToOffset 作为策略插件的另一套实现。
// 策略插件的接口设计 interface ScrollStrategy { scrollToGroup(groupIndex: number, scroller: Scroller): void; } class IndexScrollStrategy implements ScrollStrategy { scrollToGroup(groupIndex: number, scroller: Scroller) { const targetItemIndex = groupIndex * (ITEMS_PER_GROUP + 1); scroller.scrollToIndex(targetItemIndex, true, ScrollAlign.START); } } class OffsetScrollStrategy implements ScrollStrategy { private groupOffsets: number[] = []; // 需要初始化时测量每个分组的位置 scrollToGroup(groupIndex: number, scroller: Scroller) { const offset = this.groupOffsets[groupIndex] ?? 0; scroller.scrollTo({ yOffset: offset, animated: true }); } }4.3 左右状态同步的节流与边界处理
右侧滚动时,左侧高亮要跟着变。这个逻辑准确说来自右侧列表的 onScrollIndex 回调。onScrollIndex 会返回当前显示的第一个 item 的索引,但触发频率很高,直接 setState 会发生肉眼可见的抖动和性能下降。
节流方案不是简单的“定时器限频”,而是结合“当前可见项发生变化才更新状态”去重。实践代码中维护一个 lastGroupIndex,只有 onScrollIndex 推导出的分组号发生变化时才更新左侧高亮。此外还要加 200ms 的防抖保护:当快速连续滚动经过多个分组时,最后一次停留超过 200ms 才允许左侧高亮跟随,避免视觉上“跳跳跳”的割裂感。
边界处理中有两个极容易被忽略的场景。第一个是列表滚动到最底部时,最后一个分组因为内容不足一屏,onScrollIndex 可能永远无法触达它的标题序号,导致左侧最后一个分类一直无法高亮。解决办法是判断滚动到底部的回调,一旦触发就直接把左侧高亮切到最后分类。
第二个是左侧分类点击后,右侧的滚动动画还没结束,用户又快速点击了下一个分类。如果不做打断处理,动画会互相干扰,列表最终停在错误位置。处理方式是每次发起新的 scrollToIndex 调用的同时,设置一个标志位,在下一个 onScrollIndex 回调里消费掉并清除标志,确保动画执行的连续性。
onRightListScroll(index: number) { const currentGroup = this.getGroupIndexByItemIndex(index); if (currentGroup === this.lastGroupIndex) return; // 去重 this.lastGroupIndex = currentGroup; // 防抖更新左侧高亮 if (this.highlightTimer) clearTimeout(this.highlightTimer); this.highlightTimer = setTimeout(() => { this.leftActiveGroup = currentGroup; }, 200); }5. RcIndexList 的核心机制与关键 API 深度解析
5.1 分组数据模型与锚点映射机制
索引列表能工作的本质,是维护了一张“分组 key → 列表 item 序号”的映射表。RcIndexList 内部每次数据更新时会重建这张表,映射关系需要保证 O(1) 查询,否则索引条高频拖动时会卡。
映射表的生成依赖分组数据,每个分组是一个 RcIndexGroup,包含 groupKey 和 items 数组。列表的 item 序号并不是连续的分组序号,而是要考虑“分组标题行”占据的 item 位置。比如第一个分组有 10 个联系人,那么整个列表的 item 布局是:标题 0、联系人 1-10、标题 11、联系人 12-……。所以从分组序号到 item 序号的计算公式是:
itemIndex = groupIndex + sum(itemsCount[0..groupIndex-1])这个计算在数据量大时可以提前做好,存放在锚点映射数组里。索引条点击响应时,直接用数组下标取出目标 itemIndex,不需要遍历。性能测试中,3000 条数据、36 个分组的情况下,整个查找过程耗时低于 0.1ms,完全可以支撑高频拖动。
5.2 索引条自绘与浮层技术
索引条为什么必须自绘?系统组件能显示字母,但我们还需要显示“*”“#”“热”等特殊字符,需要自定义背景色、按压圆角、气泡定位参考点,甚至在某些场景要支持分段颜色高亮。与其被限制,不如用 Row/Column 布局自己画。核心绘制逻辑就是一个 Column,遍历分组 key 列表,逐个渲染文本项。
自绘索引条需要处理一个关键问题:触摸事件。索引条悬浮在 List 上方,如果不处理事件冲突,手指按住索引条时 List 也会收到滑动事件,导致列表跟着滚动。这里要在索引条上使用 priorityGesture 抢占手势,并在手势识别成功后阻止 List 的事件分发。
ArkUI 中手势优先级从高到低依次是 priorityGesture > 普通 gesture > 子组件默认手势。把 onTouch 绑定在索引条上,配合 gesture 的并行识别,可以保证索引条手势优先响应列表滚动。
IndexBar() .priorityGesture(Gesture.create(GestureType.Touch)) .gesture( Gesture.create(GestureType.Touch) .onAction((event: GestureEvent) => { // 索引条自身的手势处理 }) )需要注意的是,priorityGesture 虽然优先识别,但默认情况下并不会阻断 List 的滚动事件。想要阻断,需要在手势的 onTouch 事件里手动处理“列表不再响应”的状态,或者在 overlay 浮层上增加一块透明区域拦截事件。前者实现简单,后者视觉表现更细腻(索引条以外的半透明遮罩区域也能响应关闭)。
5.3 Builder 插槽与数据刷新机制
RcIndexList 的易用性很大程度来自 Builder 插槽设计。分组标题、列表项、索引条、气泡浮层,四件事全部可以外部注入渲染。这样组件本身只负责状态和联动,具体长什么样由调用方决定。
这里有一个性能上的建议:Builder 插槽的内容如果包含复杂业务组件,务必用 @Builder 函数声明而不是直接写成子组件节点。实测中,@Builder 的编译期优化能让列表项渲染性能提升 30% 左右,尤其对 1000 条以上数据的场景效果明显。
数据刷新方面,RcIndexList 内部对分组数据的变更做了“整组替换而不是逐项修改”的约定。调用方传新的 groups 数组时,组件先比对 groupKey 列表是否一致。一致则只刷新对应组内的 items,不一致则整个列表重建。这个逻辑用简单的引用比较 + 浅层 diff 就能显著降低无效刷新。
@Watch('onGroupsChange') groups: RcIndexGroup[] = []; onGroupsChange() { const newKeys = this.groups.map(g => g.key); if (this.groupKeys.length === newKeys.length && this.groupKeys.every((k, i) => k === newKeys[i])) { this.updateItemMap(); // 分组结构不变,只更新锚点映射 } else { this.rebuildAll(); // 分组结构变化,整体重建 } }6. 半年踩坑实录:这些偏离文档的细节最费时间
6.1 分组项无法准确跳到列表项的根因
这个坑断断续续折腾了两天才定位。现象是:点击索引条的“W”分组,列表滚到了 X 组或者 V 组附近,就是不落在 W 组标题上。
排查后发现,根因是 scrollToIndex 的目标 index 算错了。通讯录场景的分组映射公式没有问题,但商品分类页的水平布局里,左侧分类列表本身也参与滚动计算,导致右侧列表的起始偏移不为 0。锚点映射表只计算了右侧列表内部的 item 序号,却忽略了外部容器的滚动偏移。
最终解决方案是给 RcIndexList 增加一个 optional 的 startOffset 参数,当容器存在顶部偏移时动态修正目标偏移量。这个问题也给后续所有嵌套滚动场景提了个醒:锚点计算永远要基于“列表内容在滚动容器中的绝对位置”,不能假设内容从 0 开始。
6.2 索引条高亮跳变与防抖阈值设置
滚动列表时,索引条的高亮字母偶发地快速左右跳动。比如明明滚动经过的是 B 组,高亮却瞬间闪到 C 组又跳回 B 组。
定位过程发现 onScrollIndex 的回调频率在某些设备(尤其是低端机)上会低至每帧一次,同时也存在一帧内多次回调。当回调序号正好落在相邻分组的交界处时,分组判断函数会在两个分组之间反复横跳。
处理方式不只是加防抖,而是给“判断当前分组”加上变化容忍度:只有当前分组连续 3 次回调都指向同一个新分组时,才正式切换状态。这个改动让高亮变得稳定,代价是切换延迟仅有约两帧,肉眼基本无感。
6.3 自绘索引条点击事件和 List 滚动的冲突
自己画索引条后,一个必然遇到的问题就是:按住索引条上下滑动时,下方的 List 也同时在滚动。RcIndexList 最初版本的做法是索引条手势识别后手动设置 List 的 enabled(false),抬手再恢复。这个方案简单粗暴,但用户体验有明显顿挫感。
更好的做法是用 ArkUI 的并行手势 + 事件拦截。让索引条区域的手势事件直接进入自己的处理逻辑,同时阻止 List 接收触摸事件,不改变 List 的可用状态。这样 List 不会产生惯性滚动,抬手后的停留位置也是精确的。另外还要处理一个细节:按压索引条时如果弹出气泡,气泡要覆盖在索引条本身上方(更高 zIndex);如果不弹气泡,索引条自身要做按压态背景反馈,否则用户感受不到“已经按住了”。
6.4 懒加载与全量刷新的矛盾处理
引入 LazyForEach 后,分组数据量大时滚动性能确实上来了,但随之而来的问题是“数据更新后旧位置的锚点失效”。比如分组 B 增加了 10 条数据,列表项序号整体后移,之前缓存的分组偏移表全部过期。
这里我的建议是:不要试图在懒加载模式下做复杂的局部增量映射更新,这在复杂度上不值得。分组数据的变动频率远低于列表项数据的变动频率,所以每次分组变动直接重建锚点映射表即可。因为锚点映射表只是一组整数数组,重建成本远低于重新渲染列表。真正的性能热点在列表项的渲染,不在映射表的生成。
此外,LazyForEach 的数据源需要实现 ID 生成规则。如果 ID 不稳定(比如根据 items 数组重建时每个对象都生成新 ID),LazyForEach 会退化成全量刷新。稳定 ID 的做法是给每个数据实体绑定一个持久化的业务主键,城市用 adcode,联系人用手机号,商品用 skuId。
class ContactDataSource extends DataSource { public totalCount(): number { return this.contacts.length; } public getData(index: number): ContactModel { return this.contacts[index]; } public getDataId(index: number): string { // 用业务主键作为稳定 ID,避免全量失效 return this.contacts[index].phone; } }7. 后续还能怎么扩展
RcIndexList 目前已经在多个核心页面落地,但它的扩展空间还很大。
一个是“索引条搜索模式”:在索引条顶部加一个放大镜图标,点击后列表平滑切换到搜索输入框。这本质上是在索引条事件里增加一个特殊 key 的分支处理,组件结构完全兼容。
另一个是“自定义排序规则”:默认按字母,但某些场景需要按热度、按价格段、按时间轴分组。分组 key 的生成逻辑不在组件内部,所以只要调用方在分组数据中给出正确的 key,RcIndexList 就能原样渲染,索引条照样工作。
还有一个真正有价值的方向:把锚点映射表暴露出来,让外部组件也能利用“分组 key 定位列表”的能力。比如商家门店列表和商品列表联动、多 Tab 场景中的子页联动。这半年打磨下来,我最深的体会有两点:一是索引列表的核心竞争力在“状态同步”而不在“单点定位”,任何一处没有考虑滚动边界,都会在真机的高频交互里露馅;二是组件封装不要贪多,数据层、业务层和表现层的边界划清楚了,后面加减功能都会很轻松。希望这份案例集能给正在做同类需求的开发者一些参考,少走几个我已经趟平的坑。