引言

List 是移动应用中使用频率最高的容器组件之一。然而,当数据量较大且具有明确分类时,扁平的列表结构会让用户迷失在冗长的数据中。这时,分组列表(带组标题的 ListItemGroup)就显得尤为重要。HarmonyOS NEXT 的 List 组件支持通过 ListItemGroup 实现分组展示,配合粘性标题(Sticky Header)机制,让用户在滚动时始终能看到当前所在的分组。

本文将通过构建一个完整的"帮助中心"FAQ 页面,深入解析 List 的分组、粘性标题、展开/收起交互、搜索筛选等功能。这个页面非常贴近真实业务场景——每个应用几乎都需要一个帮助中心或常见问题页面。

List 分组相关的核心概念

ListItemGroup

ListItemGroup 是 List 中用于逻辑分组的容器。一个 ListItemGroup 包含一个组头部(header)和若干 ListItem。在视觉上,同一组的 ListItem 被组织在一起,头部区域显示分组的标识信息。

List() {
  ListItemGroup({ header: this.buildHeader('账号相关') }) {
    ListItem() { /* 问题1 */ }
    ListItem() { /* 问题2 */ }
  }
  ListItemGroup({ header: this.buildHeader('支付相关') }) {
    ListItem() { /* 问题3 */ }
    ListItem() { /* 问题4 */ }
  }
}

粘性标题(StickyStyle.Header)

当 List 启用 sticky(StickyStyle.Header) 后,每个 ListItemGroup 的 header 会在滚动时"粘"在列表顶部。当用户滚动到下一个分组时,旧的 header 被新的 header 顶替。这种效果在通讯录(分组显示姓氏)、设置页面(分组显示设置类别)中非常常见。

分割线控制

通过 .divider({ strokeWidth: 0 }) 可以隐藏 List 的默认分割线,让开发者自行控制各 ListItem 之间的间距和视觉分隔。在我们的 Demo 中,每个 FAQ 卡片使用 margin 产生间距,避免了默认分割线的视觉干扰。
在这里插入图片描述

Demo:帮助中心 FAQ 页面

页面结构

页面采用纵向布局:

  1. 顶部标题栏:深色背景,展示"帮助中心"标题
  2. 搜索工具栏:包含搜索输入框、问题计数、"展开全部/收起全部"按钮
  3. 分组列表:List + ListItemGroup,每组展示一类问题
  4. 空搜索结果:搜索无结果时展示的友好提示

数据模型

FAQ 的数据模型由两个类组成:

class FaqItem {
  id: number;
  question: string;
  answer: string;
  groupId: string;

  constructor(id: number, question: string, answer: string, groupId: string) {
    this.id = id;
    this.question = question;
    this.answer = answer;
    this.groupId = groupId;
  }
}

class FaqGroup {
  id: string;
  icon: string;
  name: string;

  constructor(id: string, icon: string, name: string) {
    this.id = id;
    this.icon = icon;
    this.name = name;
  }
}

FaqItem 代表一条常见问题,包含问题标题、详细解答和所属分组的 ID。FaqGroup 代表一个分组(如"账号相关"、“支付与订单”),包含分组标识、图标和名称。

这种外键关联式的数据结构(FaqItem.groupId → FaqGroup.id)让搜索逻辑清晰可维护:先按分组 → 按问题筛选,两层过滤层次分明。

模拟数据

我们准备了15条 FAQ,分布在4个分组中:

const FAQ_GROUPS: FaqGroup[] = [
  new FaqGroup('account', '👤', '账号相关'),
  new FaqGroup('payment', '💳', '支付与订单'),
  new FaqGroup('tech', '🔧', '技术问题'),
  new FaqGroup('privacy', '🔒', '隐私与安全'),
];

const FAQ_DATA: FaqItem[] = [
  new FaqItem(1, '如何注册新账号?', '打开应用后点击"注册"按钮...', 'account'),
  new FaqItem(2, '如何修改登录密码?', '进入"我的"页面...', 'account'),
  // ... 更多问题
];

每个分组有3-4条问题,涵盖了账号管理、支付流程、技术支持和隐私安全等常见主题。
在这里插入图片描述

搜索筛选机制

搜索是本 Demo 的核心交互之一。当用户在搜索框中输入关键词时,列表实时筛选出匹配的问题。

分组筛选

getFilteredGroups(): FaqGroup[] {
  if (!this.searchText.trim()) {
    return FAQ_GROUPS;
  }
  const keyword = this.searchText.trim().toLowerCase();
  return FAQ_GROUPS.filter((g: FaqGroup) => {
    return FAQ_DATA.some((item: FaqItem) =>
      item.groupId === g.id &&
      (item.question.toLowerCase().includes(keyword) ||
       item.answer.toLowerCase().includes(keyword)));
  });
}

这个方法首先检查搜索词是否为空。如果为空,返回所有分组;如果不为空,只返回那些包含匹配问题的分组。

筛选逻辑的关键点:

  • 大小写不敏感:使用 toLowerCase() 将所有文本转为小写后再比较
  • 同时搜索问题和答案item.question.includes(keyword) || item.answer.includes(keyword),确保用户无论搜索问题中的词汇还是答案中的关键词都能找到结果
  • 按分组聚合:只要分组内有一条匹配的问题,整个分组就会显示

问题筛选

getFilteredItems(groupId: string): FaqItem[] {
  if (!this.searchText.trim()) {
    return FAQ_DATA.filter((item: FaqItem) => item.groupId === groupId);
  }
  const keyword = this.searchText.trim().toLowerCase();
  return FAQ_DATA.filter((item: FaqItem) =>
    item.groupId === groupId &&
    (item.question.toLowerCase().includes(keyword) ||
     item.answer.toLowerCase().includes(keyword)));
}

在分组筛选之后,每个分组内部再次筛选匹配的问题。两层筛选使用相同的匹配规则,确保一致性。

搜索工具栏

Row() {
  Text('🔍').fontSize(16).margin({ right: Spacing.SM })
  TextInput({ text: this.searchText, placeholder: '搜索问题关键词...' })
    .fontSize(FontSize.BODY)
    .layoutWeight(1)
    .backgroundColor(Color.Transparent)
    .onChange((value: string) => {
      this.searchText = value;
      this.expandedIds = [];
      this.allExpanded = false;
    })
  if (this.searchText.trim()) {
    Button('✕').onClick(() => { this.searchText = ''; })
  }
}

搜索工具栏的几个设计要点:

  1. 搜索图标:🔍 图标提供视觉引导,告诉用户这是一个搜索功能
  2. 透明背景的 TextInput:配合父容器的白色背景,输入框呈现简洁的无边框风格
  3. 清除按钮:当搜索词不为空时,右侧出现 ✕ 按钮,点击可一键清空搜索词
  4. 搜索时收起所有展开:当用户输入新关键词时,自动收起所有已展开的答案,确保干净的搜索结果视图
    在这里插入图片描述

展开/收起交互

状态管理

展开/收起使用一个数组来追踪当前展开的问题 ID:

@State expandedIds: number[] = [];

isExpanded(id: number): boolean {
  return this.expandedIds.includes(id);
}

toggleExpand(id: number) {
  if (this.isExpanded(id)) {
    this.expandedIds = this.expandedIds.filter((v: number) => v !== id);
  } else {
    this.expandedIds = [...this.expandedIds, id];
  }
}

设计选择:使用 ID 数组而非在每个 FaqItem 上添加状态字段。原因有三:

  1. 数据源是不可变的常量:FAQ_DATA 是 const,不应在运行时被修改
  2. 多选支持:数组模式天然支持同时展开多个问题
  3. 搜索重置方便:清空数组即可收起所有问题

展开动画

当用户点击问题卡片时:

Column() {
  Row() {
    Text(item.question)
      .layoutWeight(1)
    Text(this.isExpanded(item.id) ? '▴' : '▾')
      .fontSize(14)
      .fontColor(AppColors.TEXT_TERTIARY)
  }

  if (this.isExpanded(item.id)) {
    // 答案区域
    Row() {
      Text(item.answer)
        .fontSize(FontSize.CAPTION)
        .fontColor(AppColors.TEXT_SECONDARY)
        .lineHeight(22)
    }
    .margin({ top: Spacing.MD })
    .padding({ top: Spacing.MD })
    .border({ width: { top: 1 }, color: '#F0F0F0' })
  }
}
.onClick(() => { this.toggleExpand(item.id); })

展开后答案区域出现在问题下方,通过顶部分割线与问题区域分隔。箭头图标 ▾(收起状态)和 ▴(展开状态)提供方向性的视觉提示。

展开/收起全部

toggleAll() {
  if (this.allExpanded) {
    this.expandedIds = [];
    this.allExpanded = false;
  } else {
    const filteredIds: number[] = [];
    FAQ_GROUPS.forEach((g: FaqGroup) => {
      this.getFilteredItems(g.id).forEach((item: FaqItem) => {
        filteredIds.push(item.id);
      });
    });
    this.expandedIds = filteredIds;
    this.allExpanded = true;
  }
}

"展开全部"按钮会收集当前所有可见问题的 ID(考虑搜索过滤),一次性展开。按钮文字在"展开全部"和"收起全部"之间切换,状态由 allExpanded 变量控制。按钮颜色也随状态变化——展开时为蓝色实心,收起时为浅灰色。

分组列表与粘性标题

List 结构

List() {
  ForEach(this.getFilteredGroups(), (group: FaqGroup) => {
    ListItemGroup({ header: this.groupHeader(group) }) {
      ForEach(this.getFilteredItems(group.id), (item: FaqItem) => {
        ListItem() {
          this.faqCard(item)
        }
      })
    }
  })
}
.sticky(StickyStyle.Header)
.divider({ strokeWidth: 0 })
.scrollBar(BarState.Off)

关键属性说明:

  • sticky(StickyStyle.Header):启用粘性标题。滚动时当前分组的 header 会固定在列表顶部。这是 ListItemGroup 最重要的配置项,也是我们 Demo 的核心演示点

  • divider({ strokeWidth: 0 }):隐藏 List 的默认分割线。如果不设置,每个 ListItem 之间会有系统默认的分割线。我们使用卡片化设计(每个 FAQ 卡片自带 margin 和圆角),所以需要隐藏默认分割线

  • scrollBar(BarState.Off):隐藏滚动条,让界面更简洁

分组头部(Header Builder)

@Builder
groupHeader(group: FaqGroup) {
  Row() {
    Text(group.icon)
      .fontSize(16)
      .margin({ right: Spacing.SM })
    Text(group.name)
      .fontSize(FontSize.BODY)
      .fontColor(AppColors.TEXT_PRIMARY)
      .fontWeight(FontWeight.Medium)
    Text(` · ${this.getFilteredItems(group.id).length}`)
      .fontSize(FontSize.CAPTION)
      .fontColor(AppColors.TEXT_TERTIARY)
  }
  .width('100%')
  .padding({ left: Spacing.LG, right: Spacing.LG, top: Spacing.LG,
    bottom: Spacing.SM })
  .backgroundColor('#F5F6FA')
}

分组头部显示三部分信息:

  1. 图标(emoji):提供视觉化的分类标识
  2. 分组名称:粗体主文字
  3. 问题计数:当前分组内的问题数量(已考虑搜索过滤)

头部背景色设为 #F5F6FA,与页面底色一致。当头部"粘"在顶部时,它会与下方的白色卡片形成清晰的视觉分层。

FAQ 卡片(ListItem Content)

@Builder
faqCard(item: FaqItem) {
  Column() {
    Row() {
      Text(item.question)
        .fontSize(FontSize.BODY)
        .fontColor(AppColors.TEXT_PRIMARY)
        .layoutWeight(1)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(this.isExpanded(item.id) ? '▴' : '▾')
        .fontSize(14)
        .fontColor(AppColors.TEXT_TERTIARY)
        .margin({ left: Spacing.MD })
    }

    if (this.isExpanded(item.id)) {
      Row() {
        Text(item.answer)
          .fontSize(FontSize.CAPTION)
          .fontColor(AppColors.TEXT_SECONDARY)
          .lineHeight(22)
      }
      .margin({ top: Spacing.MD })
      .padding({ top: Spacing.MD })
      .border({ width: { top: 1 }, color: '#F0F0F0' })
    }
  }
  .width('100%')
  .padding(Spacing.LG)
  .backgroundColor(Color.White)
  .borderRadius(BorderRadius.SM)
  .margin({ left: Spacing.LG, right: Spacing.LG, bottom: Spacing.SM })
  .onClick(() => { this.toggleExpand(item.id); })
}

每个 FAQ 卡片的设计细节:

  1. 白色圆角卡片:在灰底上使用白色卡片,margin 产生卡片间的间距,形成"卡片悬浮"的视觉效果
  2. 问题标题最多2行maxLines(2) + textOverflow(Ellipsis) 确保长问题不会撑破布局
  3. 箭头指示器:右侧的 ▾/▴ 箭头直观展示展开状态
  4. 答案区域带顶部分割线:展开后的答案通过一条浅灰分割线与问题区域分隔,视觉上层次分明
  5. 答案行高 22fp:比默认行高稍大,提升可读性

空搜索结果处理

当搜索词没有匹配到任何问题时,显示友好的空状态:

if (this.getTotalVisibleItems() === 0) {
  Column() {
    Text('🔍').fontSize(48).margin({ bottom: Spacing.MD })
    Text('未找到相关问题')
      .fontSize(FontSize.BODY)
      .fontColor(AppColors.TEXT_TERTIARY)
    Text(`"${this.searchText}" 没有匹配的结果`)
      .fontSize(FontSize.CAPTION)
      .fontColor(AppColors.TEXT_TERTIARY)
      .margin({ top: 4 })
    Button('尝试其他关键词')
      .fontSize(FontSize.BODY)
      .backgroundColor('#F5F6FA')
      .borderRadius(9999)
      .onClick(() => { this.searchText = ''; })
  }
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
}

空状态的三个组成部分:

  1. 大号图标:48fp 的 🔍,视觉冲击力强
  2. 提示文字:两层文字说明,主标题"未找到相关问题" + 副标题显示搜索词
  3. 清空按钮:"尝试其他关键词"按钮让用户快速回到完整列表

这个空状态使用条件渲染——当 getTotalVisibleItems() 返回 0 时,List 被替换为居中的空状态 Column。使用 layoutWeight(1) 让空状态占据剩余空间并居中显示。

Getter 方法的设计

Demo 中使用了三个 getter 方法来支撑筛选逻辑:

getFilteredGroups

返回经过搜索词筛选后的分组列表。如果搜索词为空,返回所有分组;否则只返回包含匹配问题的分组。

getFilteredItems

返回指定分组内经过搜索词筛选的问题列表。这个方法被两个地方调用:

  • ListItemGroup 的 ForEach(渲染每个分组内的问题)
  • groupHeader(计算每个分组的可见问题数量)

getTotalVisibleItems

统计所有可见分组的可见问题总数,显示在搜索工具栏中。这个方法让用户对搜索结果有宏观把握。

这三个 getter 方法都依赖 @State searchText,当搜索词变化时自动重新计算,UI 随之更新。

@Builder 方法的使用

Demo 中两个关键 UI 组件使用了 @Builder 装饰器:

groupHeader

@Builder groupHeader(group: FaqGroup) 接收分组对象,返回分组的头部 UI。它被 ListItemGroup 的 header 参数引用。

faqCard

@Builder faqCard(item: FaqItem) 接收问题对象,返回 FAQ 卡片的完整 UI。它被 ListItem 的内部内容引用。

使用 @Builder 的原因:

  1. 代码复用:避免在 ForEach 中内联大段 UI 代码
  2. 参数化组件:通过参数传递数据,@Builder 成为一个"函数式 UI 组件"
  3. 可读性:build() 方法保持简洁,具体 UI 逻辑分散到命名清晰的 @Builder 方法中

交互流程总结

用户在使用帮助中心时的典型操作路径:

  1. 浏览全部分组:进入页面,看到4个分组的15条问题,所有问题处于收起状态
  2. 展开某个问题:点击感兴趣的问题,答案展开,箭头变为 ▴。再次点击收起
  3. 搜索关键词:在搜索框输入"支付",列表实时筛选到4条支付相关问题,显示在1个分组中
  4. 展开搜索结果:在搜索结果中点击问题查看答案
  5. 展开/收起全部:点击"展开全部"按钮,当前可见的所有问题的答案全部展开;再次点击全部收起
  6. 搜索无结果:输入不存在的关键词,看到友好的空状态提示,点击按钮清空搜索
  7. 滚动浏览:向下滚动列表,观察粘性标题效果——当前分组的头部固定在列表顶部

这7个操作步骤覆盖了帮助中心的核心使用场景。

StickyStyle 的更多选项

除了 StickyStyle.Header(粘性头部),ArkUI 还支持:

  • StickyStyle.None:不启用粘性效果,所有内容正常滚动
  • StickyStyle.Footer:粘性底部,分组底部区域在滚动时固定

在实际项目中,Header 粘性是最常用的模式。通讯录(粘性字母索引)、设置页(粘性设置分类)、商品分类页(粘性分类标题)都是典型的应用场景。

总结

本文通过一个"帮助中心"FAQ 页面 Demo,深入讲解了 List 组件的分组与粘性标题功能:

  • ListItemGroup:将 ListItem 按逻辑分组,每组有自定义 header
  • StickyStyle.Header:滚动时分组头部固定在列表顶部,用户始终知道当前所在分组
  • @Builder 方法:将分组头部和 FAQ 卡片封装为可复用的 @Builder 组件
  • 搜索筛选:两层过滤(分组层 + 问题层),大小写不敏感,同时搜索问题和答案
  • 展开/收起:使用 ID 数组追踪展开状态,支持多问题同时展开和"展开全部"功能
  • 空状态处理:搜索无结果时显示友好的空状态提示和清空按钮
  • divider 控制:隐藏默认分割线,使用卡片 margin 实现自定义间距

Demo 的4个交互点:

  1. 展开/收起问题(点击卡片切换答案显示)
  2. 搜索筛选(输入关键词实时过滤问题和分组)
  3. 展开全部/收起全部(一键批量操作)
  4. 粘性标题滚动(分组头部在滚动时固定在顶部)

分组列表与粘性标题是移动端列表设计的核心模式之一。掌握了 ListItemGroup + StickyStyle 的组合用法,你就可以轻松构建出通讯录、设置页、商品分类、帮助中心等需要分组展示的各种页面。

Logo

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

更多推荐