#星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(AlphabetIndexer)的“爱恨情仇” 原创 精华

倩倩千骑卷平冈
发布于 2025-10-23 20:49
浏览
0收藏

@[toc]

前言

Hey,兄弟们,快来看我最新的鸿蒙学习笔记!

鸿蒙6.0发布了,丝滑的感觉,是不是很爽?
别畅想了,咱们直接来敲代码吧。空谈误国,实战兴邦,咱们多为鸿蒙社区做点贡献!
#星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(AlphabetIndexer)的“爱恨情仇”-鸿蒙开发者社区
设想一个问题,如果列表数据超级多,比如一个音乐App,按艺术家排,从A到Z,用户想找个周杰伦(Jay Chou)是不是得划拉半天,手指头都快搓出火星子了?

#星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(AlphabetIndexer)的“爱恨情仇”-鸿蒙开发者社区

别慌,鸿蒙ArkUI早就给咱们准备好了“降龙十八掌”里的“神龙摆尾”——AlphabetIndexer组件!这玩意儿就是咱们手机联系人右边那个A-Z的快速索引条。
#星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(AlphabetIndexer)的“爱恨情仇”-鸿蒙开发者社区
今天这个Demo,咱们就把List的分组列表(ListItemGroup)和AlphabetIndexer撮合到一块儿,实现一个“通讯录版”的音乐艺术家索引。
#星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(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 信使

#星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(AlphabetIndexer)的“爱恨情仇”-鸿蒙开发者社区

梦开始的地方就这两行。

  • scroller: 这就是AlphabetIndexer用来指挥List滚动的“遥控器”。
  • @State currentIndex: 灵魂中的灵魂@State装饰器一上,它就成了响应式的“天选之子”。它就是那个连接ListAlphabetIndexer的“红娘”,负责在它俩之间“传情达意”。

<!-- end list -->

@Entry
@Component
export struct AlphabetMusicIndexDemo {
  private scroller: Scroller = new Scroller();
  @State currentIndex: number = 0; // 当前选中字母索引

2. “弹药”准备:数据结构

#星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(AlphabetIndexer)的“爱恨情仇”-鸿蒙开发者社区
这部分不难,就是定义好我们的“艺术家”数据结构。一个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() 瞬间返回了 undefinedAlphabetIndexer 一拿到 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当根组件,把ListIndexer当兄弟塞进去。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 来“堆叠”ListIndexer

第三个天坑也在这!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 用了我们 @BuilderGroupHeader,实现了“粘性头部”。

第一个魔法来了:.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时,它会触发:

  1. 更新 this.currentIndex(让自己高亮)。
  2. 用“遥控器” 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 结束
}

总结

实现一个“通讯录滚动”效果,技术逻辑很简单,就是 ListIndexer 靠一个 @State 变量双向绑定。

但真正的难点全在布局初始化上:

  1. 实例属性private indexLetters = ...)准备数据,别用 get
  2. 用**Column > Stack** 的布局,别让 List 覆盖 Indexer
  3. 用**Stack({ alignContent: Alignment.End })** 去“指挥”Indexer 的位置,而不是在 Indexer 上用 .align()
    #星光不负 码向未来# 鸿蒙ArkUI笔记:List分组 + 索引器(AlphabetIndexer)的“爱恨情仇”-鸿蒙开发者社区
    好了,这几个天坑一填,代码瞬间就清爽了!今天的笔记就到这,赶紧拷代码去试试吧!叫我雷锋,咱们下个知识点见!

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
已于2025-10-23 20:52:47修改
收藏
回复
举报
回复
    相关推荐