#星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(AlphabetIndexer)的“爱恨情仇” 原创 精华
@[toc]
前言
Hey,兄弟们,快来看我最新的鸿蒙学习笔记!
鸿蒙6.0发布了,丝滑的感觉,是不是很爽?
别畅想了,咱们直接来敲代码吧。空谈误国,实战兴邦,咱们多为鸿蒙社区做点贡献!

设想一个问题,如果列表数据超级多,比如一个音乐App,按艺术家排,从A到Z,用户想找个周杰伦(Jay Chou)是不是得划拉半天,手指头都快搓出火星子了?

别慌,鸿蒙ArkUI早就给咱们准备好了“降龙十八掌”里的“神龙摆尾”——AlphabetIndexer组件!这玩意儿就是咱们手机联系人右边那个A-Z的快速索引条。

今天这个Demo,咱们就把List的分组列表(ListItemGroup)和AlphabetIndexer撮合到一块儿,实现一个“通讯录版”的音乐艺术家索引。

不过,高能预警!这俩组件的“联姻”可不是一帆风顺,有几个天坑,是我请教技术大佬花了九牛二虎之力才爬出来的。
老规矩,先上最终的、正确的、能跑通的完整代码!
@Entry
@Component
export struct AlphabetMusicIndexDemo {
private scroller: Scroller = new Scroller();
@State currentIndex: number = 0; // 当前选中字母索引
// 联系人式音乐数据(按艺术家首字母分组)
private contactGroups: AlphabetIndexerType[] = [
{
key: 'A',
contacts: [
{ name: 'Adele', phone: '《Hello》', avatar: $r('app.media.avatar') },
{ name: 'Alan Walker', phone: '《Faded》', avatar: $r('app.media.avatar2') },
{ name: 'Avicii', phone: '《Wake Me Up》', avatar: $r('app.media.startIcon') },
]
},
{
key: 'B',
contacts: [
{ name: 'Beyoncé', phone: '《Halo》', avatar: $r('app.media.avatar') },
{ name: 'Bruno Mars', phone: '《Just The Way You Are》', avatar: $r('app.media.avatar2') },
{ name: 'Backstreet Boys', phone: '《I Want It That Way》', avatar: $r('app.media.startIcon') },
]
},
{
key: 'C',
contacts: [
{ name: 'Coldplay', phone: '《Yellow》', avatar: $r('app.media.avatar') },
{ name: 'Calvin Harris', phone: '《Feel So Close》', avatar: $r('app.media.avatar2') },
{ name: 'Camila Cabello', phone: '《Havana》', avatar: $r('app.media.startIcon') },
]
},
{
key: 'D',
contacts: [
{ name: 'Daft Punk', phone: '《Get Lucky》', avatar: $r('app.media.startIcon') },
{ name: 'Dua Lipa', phone: '《Levitating》', avatar: $r('app.media.avatar2') },
]
},
{
key: 'E',
contacts: [
{ name: 'Ed Sheeran', phone: '《Shape of You》', avatar: $r('app.media.avatar') },
{ name: 'Eminem', phone: '《Lose Yourself》', avatar: $r('app.media.avatar2') },
]
},
{
key: 'F',
contacts: [
{ name: 'Faye Wong', phone: '《红豆》', avatar: $r('app.media.avatar') },
{ name: 'Foo Fighters', phone: '《The Pretender》', avatar: $r('app.media.startIcon') },
]
},
{
key: 'G',
contacts: [
{ name: 'G.E.M. 邓紫棋', phone: '《泡沫》', avatar: $r('app.media.avatar2') },
{ name: 'Green Day', phone: '《Wake Me Up When September Ends》', avatar: $r('app.media.startIcon') },
]
},
{
key: 'H',
contacts: [
{ name: 'Halsey', phone: '《Without Me》', avatar: $r('app.media.avatar') },
{ name: 'Hans Zimmer', phone: '《Time》', avatar: $r('app.media.avatar2') },
]
},
{
key: 'I',
contacts: [
{ name: 'Imagine Dragons', phone: '《Believer》', avatar: $r('app.media.startIcon') },
{ name: 'IU', phone: '《Palette》', avatar: $r('app.media.avatar') },
]
},
{
key: 'J',
contacts: [
{ name: 'Jay Chou 周杰伦', phone: '《晴天》', avatar: $r('app.media.avatar') },
{ name: 'Justin Bieber', phone: '《Love Yourself》', avatar: $r('app.media.avatar2') },
]
},
{
key: 'K',
contacts: [
{ name: 'Katy Perry', phone: '《Firework》', avatar: $r('app.media.avatar') },
{ name: 'Keane', phone: '《Somewhere Only We Know》', avatar: $r('app.media.startIcon') },
]
},
{
key: 'L',
contacts: [
{ name: 'Linkin Park', phone: '《In the End》', avatar: $r('app.media.avatar2') },
{ name: 'Lady Gaga', phone: '《Bad Romance》', avatar: $r('app.media.avatar') },
]
},
{
key: 'M',
contacts: [
{ name: 'Maroon 5', phone: '《Sugar》', avatar: $r('app.media.startIcon') },
{ name: 'Michael Jackson', phone: '《Beat It》', avatar: $r('app.media.avatar2') },
]
},
];
// 【踩坑点 1 在这里】
// 不要用 get 计算属性!
// private get indexLetters(): string[] { ... } // (这种写法会崩!)
//
// 要用实例属性,在构造时就初始化
private indexLetters: string[] = this.contactGroups.map(group => group.key);
@Builder
GroupHeader(key: string) {
Text(key)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.backgroundColor('#F1F3F5')
.width('100%')
.padding({ left: 16, top: 6, bottom: 6 })
}
build() {
// 【踩坑点 2 在这里】
// 根组件必须是 Column,负责“上(标题)”+“下(内容)”的布局
// 绝对不能用 Stack 当根组件!
Column() {
// 顶部标题栏
Row() {
Text('音乐索引列表')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#2c3e50')
Blank()
Image($r('app.media.startIcon')).width(20).height(20).margin({ right: 12 })
Image($r('app.media.ic_public_search')).width(20).height(20).margin({ right: 12 })
}
.width('100%')
.height(52)
.padding({ left: 16 })
.backgroundColor('#EDF2F7')
// 【踩坑点 3 在这里】
// 内容区必须用 Stack,并且要显式设置 alignContent
Stack({ alignContent: Alignment.End }) {
// Alignment.End = 水平靠右 + 垂直居中
// 分组列表 (作为 Stack 的第1层)
List({ scroller: this.scroller }) {
ForEach(this.contactGroups, (group: AlphabetIndexerType, groupIndex) => {
ListItemGroup({ header: this.GroupHeader(group.key), space: 0 }) {
ForEach(group.contacts, (contact: ContactType) => {
ListItem() {
Row() {
Image(contact.avatar || $r('app.media.avatar'))
.width(40).height(40).borderRadius(20)
.margin({ right: 12 })
Column() {
Text(contact.name).fontSize(16).fontWeight(FontWeight.Medium)
Text(contact.phone).fontSize(14).fontColor('#666').margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
Blank()
Text('>')
.fontColor('#999')
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
}
.height(60)
})
}
})
}
.width('100%') // List 必须 100% 撑满 Stack
.height('100%')
.onScrollIndex((firstIndex: number) => {
// List -> Indexer 的同步
this.currentIndex = firstIndex;
})
.divider({ strokeWidth: 1, color: '#E5E5E5', startMargin: 72, endMargin: 16 })
// 右侧字母索引器 (作为 Stack 的第2层)
AlphabetIndexer({ arrayValue: this.indexLetters, selected: this.currentIndex })
.itemSize(16)
.font({ size: 14 })
.selectedFont({ size: 16, weight: FontWeight.Bold })
.popupFont({ size: 28, weight: FontWeight.Bold })
.selectedBackgroundColor('#007DFF')
.popupColor('#CCCCCC')
.usingPopup(true)
.margin({ right: 8 }) // 向左偏移8,留出边距
.onSelect((index: number) => {
// Indexer -> List 的同步
this.currentIndex = index;
this.scroller.scrollToIndex(index);
})
.zIndex(1) // 必须强制置顶,防止被 List 遮挡
// (不再需要 alignStyle 或 align)
}
.width('100%')
.layoutWeight(1) // Stack 占满 Column 的剩余空间
}
.backgroundColor('#FFFFFF')
.width('100%')
.height('100%')
}
}
// 类型定义
interface ContactType {
name: string,
phone: string,
avatar?: Resource
}
interface AlphabetIndexerType {
key: string,
contacts: ContactType[]
}
接下来咱们就来“庖丁解牛”,把这个AlphabetMusicIndexDemo的最终正确代码拆开揉碎了看。
开整!代码拆解(和填坑)走起
代码是贴上来了,但魔鬼全在细节里。这个Demo的核心逻辑(双向绑定)超级简单,但它藏着三个“史诗级”的巨坑。咱们一个一个看。
1. 灵魂变量:@State 红娘 和 Scroller 信使

梦开始的地方就这两行。
scroller: 这就是AlphabetIndexer用来指挥List滚动的“遥控器”。@State currentIndex: 灵魂中的灵魂!@State装饰器一上,它就成了响应式的“天选之子”。它就是那个连接List和AlphabetIndexer的“红娘”,负责在它俩之间“传情达意”。
<!-- end list -->
@Entry
@Component
export struct AlphabetMusicIndexDemo {
private scroller: Scroller = new Scroller();
@State currentIndex: number = 0; // 当前选中字母索引
2. “弹药”准备:数据结构

这部分不难,就是定义好我们的“艺术家”数据结构。一个key(字母)带一堆contacts(艺术家列表)。我这里用interface定义了两个类型,代码洁癖,看着舒服。
// 类型定义
interface ContactType {
name: string,
phone: string, // 我偷懒用phone字段放歌曲名了,大家领会精神
avatar?: Resource
}
interface AlphabetIndexerType {
key: string,
contacts: ContactType[]
}
3. 【巨坑一】get vs 实例属性
数据本体(contactGroups)就是一堆体力活,不贴了。但紧接着就是第一个天坑!
我一开始为了“优雅”,把索引字母写成了 get 计算属性 (private get indexLetters() { ... })。结果App直接白屏,日志里写着 TypeError: Cannot read property length of undefined。
原因:在 build() 首次执行时,this.contactGroups 尚未完全“就绪”,get indexLetters() 瞬间返回了 undefined,AlphabetIndexer 一拿到 undefined 就崩了。
解决方案:别用 get!改成实例属性,在组件构造时就让它俩一起被初始化,干脆利落!
// 【踩坑点 1 在这里】
// 不要用 get 计算属性!
// private get indexLetters(): string[] { ... } // (这种写法会崩!)
//
// 要用实例属性,在构造时就初始化
private indexLetters: string[] = this.contactGroups.map(group => group.key);
4. @Builder:DRY原则
List里每个字母分组(A, B, C…)都需要一个灰底的头部。用@Builder封装一个GroupHeader方法,DRY(Don’t Repeat Yourself)原则走起,代码更清爽。
@Builder
GroupHeader(key: string) {
Text(key)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.backgroundColor('#F1F3F5')
.width('100%')
.padding({ left: 16, top: 6, bottom: 6 })
}
5. 【巨坑二】布局“乾坤大挪移”:Column 必须是爹
build函数来了。第二个天坑就在这里!
错误示范:用Stack当根组件,把List和Indexer当兄弟塞进去。List是个“霸道”组件,它会强行覆盖Indexer,导致Indexer消失(zIndex都不好使)。
正确姿势:页面的根组件必须是 Column,它负责“上下”布局(标题栏 + 内容区)。
build() {
// 【踩坑点 2 在这里】
// 根组件必须是 Column,负责“上(标题)”+“下(内容)”的布局
Column() {
// 顶部标题栏
Row() {
// ... (省略标题栏代码) ...
}
.width('100%')
.height(52)
.padding({ left: 16 })
.backgroundColor('#EDF2F7')
// ... 接下来是 Stack ...
6. 【巨坑三】Stack 的“王权”:alignContent
Column的第二个孩子,就是咱们的内容区。这里必须用 Stack 来“堆叠”List 和 Indexer。
第三个天坑也在这!Stack 的默认对齐方式是 Alignment.Center(万恶之源)!这就是为什么 Indexer 之前总在屏幕中间“葛优躺”。
解决方案:不跟 Indexer 较劲(给它加.align没用),直接修改 Stack 它爹的规矩!
Stack({ alignContent: Alignment.End }) —— 这行代码就是“圣旨”,命令所有孩子“水平靠右,垂直居中”。
// 【踩坑点 3 在这里】
// 内容区必须用 Stack,并且要显式设置 alignContent
Stack({ alignContent: Alignment.End }) {
// Alignment.End = 水平靠右 + 垂直居中
// ... 接下来是 List 和 Indexer ...
}
.width('100%')
.layoutWeight(1) // Stack 占满 Column 的剩余空间
}
.backgroundColor('#FFFFFF')
.width('100%')
.height('100%')
}
7. 魔法时刻(一):List 到 Indexer
Stack 的第一个孩子是 List。
List 因为设置了 width/height('100%'),它会“霸道地”撑满 Stack,无视 alignContent(这正好是我们想要的)。
ListItemGroup 用了我们 @Builder 的 GroupHeader,实现了“粘性头部”。
第一个魔法来了:.onScrollIndex()!当用户手动滑动List时,它会把当前分组的索引 firstIndex 扔给“红娘” this.currentIndex。
// 分组列表 (作为 Stack 的第1层)
List({ scroller: this.scroller }) {
ForEach(this.contactGroups, (group: AlphabetIndexerType, groupIndex) => {
ListItemGroup({ header: this.GroupHeader(group.key), space: 0 }) {
ForEach(group.contacts, (contact: ContactType) => {
ListItem() {
// ... (省略列表行布局) ...
}
.height(60)
})
}
})
}
.width('100%') // List 必须 100% 撑满 Stack
.height('100%')
.onScrollIndex((firstIndex: number) => {
// List -> Indexer 的同步
this.currentIndex = firstIndex;
})
.divider(...) // 分割线
8. 魔法时刻(二):Indexer 到 List
Stack 的第二个孩子 AlphabetIndexer 来了。
它没有设置宽高,所以它会“乖乖地”听从 Stack 它爹的 alignContent: Alignment.End 规矩,跑到右侧中间去。
第二个魔法来了:.onSelect()!当用户点击或拖动Indexer时,它会触发:
- 更新
this.currentIndex(让自己高亮)。 - 用“遥控器”
this.scroller.scrollToIndex(index),命令List瞬移!
别忘了 .zIndex(1),这是“免死金牌”,必须加,防止被 List 遮挡。
// 右侧字母索引器 (作为 Stack 的第2层)
AlphabetIndexer({ arrayValue: this.indexLetters, selected: this.currentIndex })
.itemSize(16)
.font({ size: 14 })
// ... (省略一堆样式) ...
.usingPopup(true)
.margin({ right: 8 }) // 向左偏移8,留出边距
.onSelect((index: number) => {
// Indexer -> List 的同步
this.currentIndex = index;
this.scroller.scrollToIndex(index);
})
.zIndex(1) // 必须强制置顶,防止被 List 遮挡
// (不再需要 alignStyle 或 align,听爹的)
} // Stack 结束
} // Column 结束
} // build 结束
}
总结
实现一个“通讯录滚动”效果,技术逻辑很简单,就是 List 和 Indexer 靠一个 @State 变量双向绑定。
但真正的难点全在布局和初始化上:
- 用实例属性(
private indexLetters = ...)准备数据,别用get。 - 用**
Column>Stack** 的布局,别让List覆盖Indexer。 - 用**
Stack({ alignContent: Alignment.End })** 去“指挥”Indexer的位置,而不是在Indexer上用.align()。

好了,这几个天坑一填,代码瞬间就清爽了!今天的笔记就到这,赶紧拷代码去试试吧!叫我雷锋,咱们下个知识点见!



















