通讯录是移动端最常见的页面类型之一。本文用 ArkUI 构建一个完整通讯录——分组联系人列表、AlphabetIndexer 字母索引快速跳转、实时搜索筛选、以及联系人详情弹窗。AlphabetIndexer 是 ArkUI 内置的字母索引导航组件,本文将详细拆解其用法。


一、我们要做什么

一个"通讯录"页面,包含四个核心功能:

  1. 分组联系人列表 — 按姓名首字母 A-Z 分组,每组有灰色 section header,每个联系人显示圆形头像(首字母色块)+ 姓名 + 电话
  2. AlphabetIndexer 字母索引 — 右侧纵向字母栏,点击/滑动字母时列表跳转到对应分组,伴随蓝色弹出式字母提示
  3. 实时搜索 — 顶部搜索框输入关键字,实时筛选匹配的联系人,搜索时隐藏字母索引
  4. 联系人详情 — 点击联系人行 → 弹窗显示姓名和电话

交互点:

  1. 滚动浏览 — 分组列表,每组有 sticky 感的 section header(灰色背景 + 粗体字母)
  2. 字母索引跳转 — 点击/滑动右侧字母栏,列表自动滚动到对应分组
  3. 搜索筛选 — 输入关键字实时过滤,字母索引自动隐藏
  4. 查看详情 — 点击联系人弹窗展示完整信息

二、数据结构:分组模型

class Contact {
  name: string;
  phone: string;
  color: string;  // 头像背景色(预分配,保证同一人颜色不变)
}

class ContactGroup {
  letter: string;       // 分组字母 "A", "B", "C" ...
  contacts: Contact[];  // 该分组下的联系人
}

两层嵌套的数据结构天然映射到 UI 的两层嵌套:

  • ContactGroup → 一个 section(header + items)
  • Contact → 一个联系人行

模拟数据覆盖了 10 个字母分组、共 19 个联系人:

const CONTACT_GROUPS: ContactGroup[] = [
  new ContactGroup('A', [
    new Contact('阿明', '138 0000 1111', AVATAR_COLORS[0]),
    new Contact('安娜', '138 0000 2222', AVATAR_COLORS[1]),
  ]),
  new ContactGroup('B', [
    new Contact('白鸽', '138 0000 3333', AVATAR_COLORS[2]),
  ]),
  // ... C, D, F, H, L, M, W, Z
];

为什么颜色预先分配而不是随机生成? 每次重新渲染时随机颜色会变化——用户搜索后清除关键字,联系人的头像颜色会变,视觉上会产生"闪变"感。预分配颜色保证了数据不变 → 颜色不变。


在这里插入图片描述

三、头像设计:首字母色块

Text(contact.name.charAt(0))
  .fontSize(FontSize.MEDIUM)
  .fontColor(Color.White)
  .fontWeight(FontWeight.Bold)
  .width(42)
  .height(42)
  .borderRadius(21)          // 圆形 = 宽度的一半
  .backgroundColor(contact.color)
  .textAlign(TextAlign.Center)

42×42 的圆形色块,文字居中,白色粗体首字母。这是在没有真实头像图片时最常用的头像替代方案——Google Contacts、微信、Telegram 等应用的默认头像都是这个模式。

10 种颜色循环使用:

const AVATAR_COLORS: string[] = [
  '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#DDA0DD',
  '#F7DC6F', '#BB8FCE', '#85C1E9', '#F0B27A', '#82E0AA'
];

19 个联系人平分 10 种颜色,相邻联系人颜色不同,视觉上有足够的区分度。颜色选择策略:明亮 + 饱和度适中 + 白色文字对比度足够(避免了黄色太浅、深蓝太暗等问题)。


在这里插入图片描述

四、交互点1:分组列表的嵌套 ForEach

List({ scroller: this.scroller }) {
  ForEach(this.getDisplayGroups(), (group: ContactGroup, groupIndex: number) => {
    // Section header
    ListItem() {
      Text(group.letter)
        .fontSize(FontSize.CAPTION)
        .fontColor(AppColors.TEXT_SECONDARY)
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .padding({ left: Spacing.LG, top: Spacing.MD, bottom: Spacing.XS })
        .backgroundColor(AppColors.BACKGROUND)
    }

    // Contact items
    ForEach(group.contacts, (contact: Contact) => {
      ListItem() {
        Row() {
          Text(contact.name.charAt(0))   // 头像
            .fontSize(FontSize.MEDIUM)
            .fontColor(Color.White)
            .fontWeight(FontWeight.Bold)
            .width(42).height(42)
            .borderRadius(21)
            .backgroundColor(contact.color)
            .textAlign(TextAlign.Center)

          Column() {
            Text(contact.name)            // 姓名
              .fontSize(FontSize.BODY)
              .fontColor(AppColors.TEXT_PRIMARY)
              .fontWeight(FontWeight.Medium)
            Text(contact.phone)           // 电话
              .fontSize(FontSize.CAPTION)
              .fontColor(AppColors.TEXT_TERTIARY)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: Spacing.MD })
          .layoutWeight(1)
        }
        .width('100%')
        .padding({ left: Spacing.LG, right: Spacing.LG, top: Spacing.MD, bottom: Spacing.MD })
        .onClick(() => { this.showContactDetail(contact); })
      }
    })
  }, (group: ContactGroup) => group.letter)
}

嵌套 ForEach 的 key 函数

外层 ForEach 使用 (group: ContactGroup) => group.letter 作为 key。这意味着当数据变化时(如搜索过滤),ArkUI 会根据 group.letter 判断哪些分组是新增/删除/保留的,从而精确地复用 ListItem,而不是全部重建。

内层 ForEach 的 contacts 没有显式指定 key——ArkUI 会使用默认的索引 key。在搜索场景中,过滤后的 contacts 数组引用变了,所以内层 ListItem 会重建,这是正确的行为。

List vs Scroll 的选择

通讯录用 List 而不是 Scroll,因为:

  • List 支持 scrollToIndex() —— 这是字母索引跳转的关键 API
  • List 有虚拟滚动,大量联系人时性能更好
  • ListItem 是 List 的专用子组件,提供更好的复用机制

五、交互点2:AlphabetIndexer 字母索引跳转

AlphabetIndexer 基础用法

AlphabetIndexer({
  arrayValue: this.getAlphabet(),  // ['A','B','C','D','F','H','L','M','W','Z']
  selected: this.selectedIndex     // 当前选中字母的索引
})
  .itemSize(18)                                    // 每个字母的触控区域大小
  .color(AppColors.TEXT_SECONDARY)                 // 未选中字母颜色
  .selectedColor(Color.White)                      // 选中字母颜色(白字)
  .popupColor(AppColors.PRIMARY)                   // 弹出气泡背景色(蓝底)
  .selectedBackgroundColor(AppColors.PRIMARY)      // 选中项背景色(蓝圈)
  .margin({ right: 4 })
  .onSelected((index: number) => {
    this.selectedIndex = index;
    this.scrollToLetter(index);
  })

arrayValue 动态生成

private getAlphabet(): string[] {
  const letters: string[] = [];
  for (let i = 0; i < CONTACT_GROUPS.length; i++) {
    letters.push(CONTACT_GROUPS[i].letter);
  }
  return letters;
}

只显示有联系人的字母,而不是 A-Z 全部 26 个。这避免了用户点击"K"却跳转到空白分组的尴尬。

滚动跳转的核心:scrollToIndex

private groupStartIndices: number[] = [];

private computeGroupIndices(): void {
  this.groupStartIndices = [];
  let count = 0;
  for (let i = 0; i < CONTACT_GROUPS.length; i++) {
    this.groupStartIndices.push(count);
    count += 1 + CONTACT_GROUPS[i].contacts.length;
  }
}

private scrollToLetter(index: number): void {
  if (index >= 0 && index < this.groupStartIndices.length) {
    this.scroller.scrollToIndex(this.groupStartIndices[index]);
  }
}

关键点:groupStartIndices 数组存储了每个分组的 section header 在 List 中的扁平索引

计算逻辑:

  • 分组 A 有 2 个联系人 → 占 3 个 ListItem(1 header + 2 items)→ 起始索引 0
  • 分组 B 有 1 个联系人 → 占 2 个 ListItem → 起始索引 3
  • 分组 C 有 2 个联系人 → 占 3 个 ListItem → 起始索引 5

scrollToIndex(n) 会将 List 滚动到第 n 个 ListItem 的位置,让对应分组的 section header 出现在列表顶部。

为什么在 aboutToAppear 中计算?

computeGroupIndices()aboutToAppear 生命周期中调用一次。因为 CONTACT_GROUPS 是常量,索引只需要计算一次。不需要在每个 onSelected 回调中动态计算。


六、交互点3:实时搜索筛选

private getDisplayGroups(): ContactGroup[] {
  const keyword = this.searchText.trim();
  if (keyword.length === 0) {
    return CONTACT_GROUPS;  // 无搜索词 → 显示全部
  }
  const result: ContactGroup[] = [];
  for (let i = 0; i < CONTACT_GROUPS.length; i++) {
    const group = CONTACT_GROUPS[i];
    const matched: Contact[] = [];
    for (let j = 0; j < group.contacts.length; j++) {
      if (group.contacts[j].name.indexOf(keyword) >= 0) {
        matched.push(group.contacts[j]);
      }
    }
    if (matched.length > 0) {
      result.push(new ContactGroup(group.letter, matched));
    }
  }
  return result;
}

搜索逻辑

  1. 输入为空 → 返回原始数据引用(不是拷贝),保留 AlphabetIndexer 和分组结构
  2. 输入非空 → 遍历所有分组,用 indexOf 做子串匹配(“明” 能匹配 “阿明”)
  3. 匹配到的联系人收集到新的 ContactGroup 中,保留原字母分组
  4. 分组内没有匹配项 → 该分组不出现在结果中

搜索时隐藏 AlphabetIndexer

if (this.searchText.trim().length === 0) {
  AlphabetIndexer({ ... })
}

搜索状态下字母索引自动隐藏——搜索结果是一个被过滤过的分组列表,原来的字母索引不再准确(比如搜索"伟"只显示 C 组的陈伟,此时点击字母栏的 A 没有意义)。

为什么用 indexOf 而不是 includes

两者的语义相同(都是子串匹配),但 indexOf 兼容性更广。在 ArkTS 的严格模式下,String.prototype.includes 在某些 API 版本中可能不可用。indexOf 是 ES5 API,兼容性最好。


七、交互点4:联系人详情弹窗

private showContactDetail(contact: Contact): void {
  promptAction.showDialog({
    title: contact.name,
    message: `电话:${contact.phone}`,
    buttons: [
      { text: '确定', color: AppColors.PRIMARY }
    ]
  });
}

点击联系人弹出系统对话框,展示姓名(标题)+ 电话(正文)。真实应用中可以扩展为:拨打/发短信/复制号码/查看头像大图。


八、搜索框的设计

Row() {
  TextInput({ placeholder: '搜索联系人', text: $$this.searchText })
    .fontSize(FontSize.BODY)
    .layoutWeight(1)
    .backgroundColor(Color.Transparent)
}
.width('100%')
.padding({ left: Spacing.LG, right: Spacing.LG, top: Spacing.MD, bottom: Spacing.MD })
.backgroundColor(Color.White)
.margin({ bottom: Spacing.SM })

搜索框放在白色 Row 中,位于蓝色 Header 和联系人列表之间。$$this.searchText 双向绑定——用户输入时 searchText 自动更新,触发 getDisplayGroups() 重新计算,列表即时刷新。

backgroundColor(Color.Transparent) 让 TextInput 的背景融入白色 Row,视觉上看起来像是一个整体搜索栏而不是独立的输入框。


九、Scroll 布局:Stack 叠加 AlphabetIndexer

Stack() {
  List({ scroller: this.scroller }) { ... }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.White)
    .scrollBar(BarState.Off)          // 隐藏默认滚动条,避免和字母索引重叠
    .edgeEffect(EdgeEffect.Spring)    // 弹性回弹效果

  if (this.searchText.trim().length === 0) {
    AlphabetIndexer({ ... })
      .margin({ right: 4 })          // 右侧留 4vp 呼吸空间
  }
}
.layoutWeight(1)

Stack 的作用

Stack 将两个子组件叠放在同一空间内:

  • 底层:List(全宽全高)
  • 顶层:AlphabetIndexer(自动靠右对齐,垂直居中)

AlphabetIndexer 默认在 Stack 中靠右对齐(Alignment.End 的默认行

为)。无需手动设置 positionoffset

隐藏滚动条

scrollBar(BarState.Off) 关闭 List 的默认滚动条——字母索引本身就是一个"可视化滚动条",两者共存会产生视觉冲突。用户通过 AlphabetIndexer 的字母位置就能直观感知当前在列表中的位置(选中字母的蓝底高亮 = 当前位置指示器)。

edgeEffect(EdgeEffect.Spring) 弹性效果

列表滚动到顶部/底部时产生弹性回弹动画,和 iOS 通讯录的体验一致。


十、完整代码结构

ContactsPage (~230行)
├── 数据层
│   ├── class Contact                    — 联系人模型
│   ├── class ContactGroup               — 分组模型
│   ├── AVATAR_COLORS[10]                — 头像色板
│   └── CONTACT_GROUPS                   — 模拟数据(10组19人)
├── 状态层
│   ├── @State searchText                — 搜索关键字
│   └── @State selectedIndex             — 字母索引选中位
├── 工具方法
│   ├── computeGroupIndices()            — 计算分组起始索引
│   ├── scrollToLetter(index)            — 滚动到指定字母
│   └── getAlphabet()                    — 获取可用字母列表
├── 业务方法
│   ├── getDisplayGroups()               — 获取展示数据(含搜索过滤)
│   └── showContactDetail(contact)       — 弹窗展示联系人
└── UI 层
    ├── Header(返回 + 标题)
    ├── SearchBar(TextInput)
    └── Stack
        ├── List(嵌套 ForEach)
        │   ├── ListItem — section header
        │   └── ListItem — contact row(头像 + 姓名 + 电话)
        └── AlphabetIndexer(条件渲染)

十一、完整代码

import { AppColors, BorderRadius, FontSize, Spacing } from '../common/Constants';
import { promptAction, router } from '@kit.ArkUI';

class Contact {
  name: string;
  phone: string;
  color: string;

  constructor(name: string, phone: string, color: string) {
    this.name = name;
    this.phone = phone;
    this.color = color;
  }
}

class ContactGroup {
  letter: string;
  contacts: Contact[];

  constructor(letter: string, contacts: Contact[]) {
    this.letter = letter;
    this.contacts = contacts;
  }
}

const AVATAR_COLORS: string[] = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#DDA0DD',
  '#F7DC6F', '#BB8FCE', '#85C1E9', '#F0B27A', '#82E0AA'];

const CONTACT_GROUPS: ContactGroup[] = [
  new ContactGroup('A', [
    new Contact('阿明', '138 0000 1111', AVATAR_COLORS[0]),
    new Contact('安娜', '138 0000 2222', AVATAR_COLORS[1]),
  ]),
  new ContactGroup('B', [
    new Contact('白鸽', '138 0000 3333', AVATAR_COLORS[2]),
  ]),
  new ContactGroup('C', [
    new Contact('陈伟', '138 0000 4444', AVATAR_COLORS[3]),
    new Contact('程丽', '138 0000 5555', AVATAR_COLORS[4]),
  ]),
  new ContactGroup('D', [
    new Contact('邓超', '138 0000 6666', AVATAR_COLORS[5]),
  ]),
  new ContactGroup('F', [
    new Contact('冯悦', '138 0000 7777', AVATAR_COLORS[6]),
  ]),
  new ContactGroup('H', [
    new Contact('黄磊', '138 0000 8888', AVATAR_COLORS[7]),
    new Contact('韩雪', '138 0000 9999', AVATAR_COLORS[8]),
  ]),
  new ContactGroup('L', [
    new Contact('李华', '138 0001 0000', AVATAR_COLORS[9]),
    new Contact('刘洋', '138 0001 1000', AVATAR_COLORS[0]),
    new Contact('林芳', '138 0001 2000', AVATAR_COLORS[1]),
  ]),
  new ContactGroup('M', [
    new Contact('马丽', '138 0001 3000', AVATAR_COLORS[2]),
  ]),
  new ContactGroup('W', [
    new Contact('王芳', '138 0001 4000', AVATAR_COLORS[3]),
    new Contact('吴鑫', '138 0001 5000', AVATAR_COLORS[4]),
  ]),
  new ContactGroup('Z', [
    new Contact('张伟', '138 0001 6000', AVATAR_COLORS[5]),
    new Contact('赵敏', '138 0001 7000', AVATAR_COLORS[6]),
    new Contact('周杰', '138 0001 8000', AVATAR_COLORS[7]),
  ]),
];

@Entry
@Component
struct ContactsPage {
  @State searchText: string = '';
  @State selectedIndex: number = 0;
  private scroller: Scroller = new Scroller();

  aboutToAppear(): void {
    this.computeGroupIndices();
  }

  private groupStartIndices: number[] = [];

  private computeGroupIndices(): void {
    this.groupStartIndices = [];
    let count = 0;
    for (let i = 0; i < CONTACT_GROUPS.length; i++) {
      this.groupStartIndices.push(count);
      count += 1 + CONTACT_GROUPS[i].contacts.length;
    }
  }

  private scrollToLetter(index: number): void {
    if (index >= 0 && index < this.groupStartIndices.length) {
      this.scroller.scrollToIndex(this.groupStartIndices[index]);
    }
  }

  private getDisplayGroups(): ContactGroup[] {
    const keyword = this.searchText.trim();
    if (keyword.length === 0) {
      return CONTACT_GROUPS;
    }
    const result: ContactGroup[] = [];
    for (let i = 0; i < CONTACT_GROUPS.length; i++) {
      const group = CONTACT_GROUPS[i];
      const matched: Contact[] = [];
      for (let j = 0; j < group.contacts.length; j++) {
        if (group.contacts[j].name.indexOf(keyword) >= 0) {
          matched.push(group.contacts[j]);
        }
      }
      if (matched.length > 0) {
        result.push(new ContactGroup(group.letter, matched));
      }
    }
    return result;
  }

  private getAlphabet(): string[] {
    const letters: string[] = [];
    for (let i = 0; i < CONTACT_GROUPS.length; i++) {
      letters.push(CONTACT_GROUPS[i].letter);
    }
    return letters;
  }

  private showContactDetail(contact: Contact): void {
    promptAction.showDialog({
      title: contact.name,
      message: `电话:${contact.phone}`,
      buttons: [
        { text: '确定', color: AppColors.PRIMARY }
      ]
    });
  }

  build() {
    Column() {
      Row() {
        Text('← 返回')
          .fontSize(FontSize.BODY)
          .fontColor(Color.White)
          .onClick(() => { router.back(); })
        Text('通讯录')
          .fontSize(FontSize.TITLE)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
      }
      .width('100%')
      .height(52)
      .backgroundColor(AppColors.PRIMARY)
      .padding({ left: Spacing.LG, right: Spacing.LG })

      Row() {
        TextInput({ placeholder: '搜索联系人', text: $$this.searchText })
          .fontSize(FontSize.BODY)
          .layoutWeight(1)
          .backgroundColor(Color.Transparent)
      }
      .width('100%')
      .padding({ left: Spacing.LG, right: Spacing.LG, top: Spacing.MD, bottom: Spacing.MD })
      .backgroundColor(Color.White)
      .margin({ bottom: Spacing.SM })

      Stack() {
        List({ scroller: this.scroller }) {
          ForEach(this.getDisplayGroups(), (group: ContactGroup, groupIndex: number) => {
            ListItem() {
              Text(group.letter)
                .fontSize(FontSize.CAPTION)
                .fontColor(AppColors.TEXT_SECONDARY)
                .fontWeight(FontWeight.Bold)
                .width('100%')
                .padding({ left: Spacing.LG, top: Spacing.MD, bottom: Spacing.XS })
                .backgroundColor(AppColors.BACKGROUND)
            }

            ForEach(group.contacts, (contact: Contact) => {
              ListItem() {
                Row() {
                  Text(contact.name.charAt(0))
                    .fontSize(FontSize.MEDIUM)
                    .fontColor(Color.White)
                    .fontWeight(FontWeight.Bold)
                    .width(42).height(42)
                    .borderRadius(21)
                    .backgroundColor(contact.color)
                    .textAlign(TextAlign.Center)

                  Column() {
                    Text(contact.name)
                      .fontSize(FontSize.BODY)
                      .fontColor(AppColors.TEXT_PRIMARY)
                      .fontWeight(FontWeight.Medium)
                    Text(contact.phone)
                      .fontSize(FontSize.CAPTION)
                      .fontColor(AppColors.TEXT_TERTIARY)
                      .margin({ top: 2 })
                  }
                  .alignItems(HorizontalAlign.Start)
                  .margin({ left: Spacing.MD })
                  .layoutWeight(1)
                }
                .width('100%')
                .padding({ left: Spacing.LG, right: Spacing.LG, top: Spacing.MD, bottom: Spacing.MD })
                .onClick(() => { this.showContactDetail(contact); })
              }
            })
          }, (group: ContactGroup) => group.letter)
        }
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
        .scrollBar(BarState.Off)
        .edgeEffect(EdgeEffect.Spring)

        if (this.searchText.trim().length === 0) {
          AlphabetIndexer({
            arrayValue: this.getAlphabet(),
            selected: this.selectedIndex
          })
            .itemSize(18)
            .color(AppColors.TEXT_SECONDARY)
            .selectedColor(Color.White)
            .popupColor(AppColors.PRIMARY)
            .selectedBackgroundColor(AppColors.PRIMARY)
            .margin({ right: 4 })
            .onSelected((index: number) => {
              this.selectedIndex = index;
              this.scrollToLetter(index);
            })
        }
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor(AppColors.BACKGROUND)
  }
}

十二、常见面试题 / 踩坑点

12.1 AlphabetIndexer 的 arrayValue 应该放什么?

只放当前数据中实际存在的字母,而不是固定的 [‘A’, ‘B’, ‘C’, … ‘Z’]。如果放了没有对应数据的字母,用户点击后会跳转到空白区域,体验很差。

可以在 getAlphabet() 中动态从 CONTACT_GROUPS 提取字母列表。数据变化时(如搜索过滤),AlphabetIndexer 也会自动更新。

12.2 scrollToIndex 的参数是什么?

是 ListItem 在扁平列表中的索引,不是分组编号。需要预先计算每个分组的 section header 在 List 中的位置。计算公式:

groupStartIndices[0] = 0
groupStartIndices[i] = groupStartIndices[i-1] + 1 + contacts[i-1].length

12.3 为什么搜索时隐藏 AlphabetIndexer 而不是更新它?

搜索结果是数据子集。如果更新 AlphabetIndexer 的字母列表,用户点击"Z"时可能只找到一个联系人——这和字母索引"快速导航到大量数据中某个位置"的定位不符。

搜索状态下的交互模式应该是"输入 → 过滤 → 点击结果",不需要字母索引辅助。隐藏索引让用户感知到"当前是搜索模式"。

12.4 Stack 中的 AlphabetIndexer 为什么不需要手动定位?

AlphabetIndexer 自带右对齐行为。在 Stack 中,子组件默认居中对齐,但 AlphabetIndexer 内部有自动定位逻辑,会吸附到 Stack 的右侧边缘。唯一需要的手动样式是 .margin({ right: 4 }) 给右侧留一点呼吸空间。

12.5 $$this.searchTextthis.searchText.trim() 的配合

TextInput 的 $$ 双向绑定会自动更新 searchText,但搜索逻辑使用 this.searchText.trim()。用户输入空格时,trim() 后为空字符串,getDisplayGroups() 返回全部数据——这意味着输入纯空格等于"没搜索"。

这是期望的行为。如果用户输入"张 “(末尾有空格),indexOf('张') 仍然能匹配到"张伟”。


十三、扩展方向

  • 字母索引导航弹出气泡 — 开启 .popupEnabled(true) 并使用 .onRequestPopup() 自定义弹出文本样式
  • 长按字母索引连续滚动 — AlphabetIndexer 本身就支持滑动选择,无需额外实现
  • 联系人收藏/置顶 — 在数据中加 pinned: boolean 字段,置顶联系人排在最前面(# 分组)
  • 拼音搜索 — 引入拼音库,支持输入拼音首字母搜索中文姓名(如输入 “zw” 匹配 “张伟”)
  • 拨号/发短信快捷操作 — 点击联系人弹窗中增加"呼叫"和"发短信"按钮,调用系统电话和短信 API
  • Sticky Header — 用 List 的 sticky 属性让 section header 在滚动时吸顶,不随列表滚出屏幕
  • 加入 AlphabetIndexer 的 onRequestPopup — 在滑动字母索引时显示大号字母浮层,类似 iOS 通讯录的体验
  • 从系统通讯录读取 — 使用 @kit.Contacts 读取系统通讯录数据,替换模拟数据

十四、运行方式

代码位于 dev/entry/src/main/ets/pages/ContactsPage.ets

用 DevEco Studio 打开 dev/ 项目,首页点击"通讯录 — 字母索引与分组列表"即可体验:

  1. 进入页面 → 看到蓝色顶栏"通讯录" + 搜索框 + 联系人列表
  2. 滚动列表 → 看到 A-Z 分组的 section header 和彩色头像联系人
  3. 点击右侧字母"L" → 列表自动滚动到 L 分组(李华、刘洋、林芳)
  4. 点击右侧字母"Z" → 列表跳转到 Z 分组(张伟、赵敏、周杰)
  5. 在搜索框输入"伟" → 字母索引消失,只显示 C 组陈伟和 Z 组张伟
  6. 清空搜索框 → 字母索引重新出现,列表恢复
  7. 点击"安娜" → 弹窗显示"安娜 电话:138 0000 2222"
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐