通讯录按字母分组、商品按类别分组、订单按日期分组——列表分组是高频需求。但分组不只是给数据排序加标题,还有吸顶效果、侧边索引条、分组折叠、跨分组全选。每个功能单独做不难,组合起来坑就来了。

数据分组

在这里插入图片描述

第一步是把扁平数据按字段分组。最常见的是按首字母分组:

interface Contact {
  id: number;
  name: string;
  phone: string;
  pinyin: string;
}

interface GroupedContact {
  letter: string;
  contacts: Contact[];
}

private groupByLetter(contacts: Contact[]): GroupedContact[] {
  let groups: GroupedContact[] = [];
  let groupMap: Record<string, Contact[]> = {};

  for (let i = 0; i < contacts.length; i++) {
    let firstChar = contacts[i].pinyin.charAt(0).toUpperCase();
    if (groupMap[firstChar] === undefined) {
      groupMap[firstChar] = [];
    }
    groupMap[firstChar].push(contacts[i]);
  }

  let letters = Object.keys(groupMap).sort();
  for (let i = 0; i < letters.length; i++) {
    groups.push({
      letter: letters[i],
      contacts: groupMap[letters[i]]
    });
  }
  return groups;
}

先建一个首字母→联系人的映射,再按字母排序输出分组。Object.keys返回的顺序不保证,必须sort。

按日期分组同理:

interface GroupedOrder {
  date: string;
  orders: Order[];
}

private groupByDate(orders: Order[]): GroupedOrder[] {
  let groups: GroupedOrder[] = [];
  let groupMap: Record<string, Order[]> = {};

  for (let i = 0; i < orders.length; i++) {
    let dateStr = this.formatDate(orders[i].createTime);
    if (groupMap[dateStr] === undefined) {
      groupMap[dateStr] = [];
    }
    groupMap[dateStr].push(orders[i]);
  }

  let dates = Object.keys(groupMap).sort();
  dates.reverse();
  for (let i = 0; i < dates.length; i++) {
    groups.push({
      date: dates[i],
      orders: groupMap[dates[i]]
    });
  }
  return groups;
}

日期分组通常是倒序——最新的在前。reverse()翻转排序结果。

分组列表渲染

用ListItemGroup实现分组渲染:

List() {
  ForEach(this.groupedContacts, (group: GroupedContact) => {
    ListItemGroup({
      header: this.groupHeader(group.letter),
      divider: { strokeWidth: 1, color: '#f0f0f0' }
    }) {
      ForEach(group.contacts, (contact: Contact) => {
        ListItem() {
          Row() {
            Text(contact.name)
              .fontSize(16)
              .fontColor('#333333')
            Text(contact.phone)
              .fontSize(14)
              .fontColor('#999999')
              .margin({ left: 12 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        }
      }, (contact: Contact) => contact.id.toString())
    }
  }, (group: GroupedContact) => group.letter)
}

@Builder
groupHeader(letter: string) {
  Row() {
    Text(letter)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#666666')
  }
  .width('100%')
  .padding({ left: 16, top: 8, bottom: 8 })
  .backgroundColor('#f5f5f5')
}

ListItemGroup自带header和divider参数,省去手动添加分组标题的麻烦。header接受@Builder,可以自定义样式。

吸顶效果

ListItemGroup的header默认随列表滚动而消失。要实现吸顶(sticky header),设sticky属性:

ListItemGroup({
  header: this.groupHeader(group.letter),
  divider: { strokeWidth: 1, color: '#f0f0f0' }
}) {
  // 列表项
}
.sticky(StickyStyle.Header)

StickyStyle.Header让header在滚动到顶部时"粘住",直到被下一个header推走。这是通讯录字母吸顶的标准实现。

吸顶的header会覆盖在列表内容上方,所以header要有背景色。透明的header会透出下面的内容,看起来很怪。

侧边字母索引

通讯录右侧的A-Z快速跳转:

Stack() {
  List() {
    ForEach(this.groupedContacts, (group: GroupedContact) => {
      ListItemGroup({
        header: this.groupHeader(group.letter)
      }) {
        ForEach(group.contacts, (contact: Contact) => {
          ListItem() {
            // 内容
          }
        }, (contact: Contact) => contact.id.toString())
      }
      .sticky(StickyStyle.Header)
    }, (group: GroupedContact) => group.letter)
  }
  .width('100%')
  .height('100%')

  // 侧边索引
  Column() {
    ForEach(this.alphabet, (letter: string) => {
      Text(letter)
        .fontSize(10)
        .fontColor(this.activeLetter === letter ? '#1a73e8' : '#666666')
        .fontWeight(this.activeLetter === letter ? FontWeight.Bold : FontWeight.Normal)
        .padding(2)
        .onClick(() => {
          this.scrollToLetter(letter);
        })
    }, (letter: string) => letter)
  }
  .position({ x: '92%', y: 60 })
  .height('70%')
  .justifyContent(FlexAlign.SpaceBetween)
}

alphabet是A-Z的字符串数组。点击字母时调用scrollToLetter滚动到对应分组。

List滚动到指定位置用Scroller:

private scroller: Scroller = new Scroller();
private letterToIndex: Record<string, number> = {};

// 初始化映射
aboutToAppear(): void {
  for (let i = 0; i < this.groupedContacts.length; i++) {
    this.letterToIndex[this.groupedContacts[i].letter] = i;
  }
}

// 滚动到指定字母
private scrollToLetter(letter: string): void {
  let index = this.letterToIndex[letter];
  if (index !== undefined) {
    this.scroller.scrollToIndex(index);
    this.activeLetter = letter;
  }
}

scroller.scrollToIndex跳转到指定ListItemGroup的位置。letterToIndex是字母→分组索引的映射表,在数据加载后构建。

注意:scrollToIndex的索引对应的是ListItemGroup在List中的位置,不是ListItem。 如果List中只有ListItemGroup没有单独的ListItem,索引就是ListItemGroup的序号。

滑动联动

手指滑动列表时,右侧索引高亮要跟着变:

List() {
  // 内容
}
.onScrollIndex((start: number, end: number) => {
  if (start >= 0 && start < this.groupedContacts.length) {
    this.activeLetter = this.groupedContacts[start].letter;
  }
})

onScrollIndex在滚动时回调当前可见区域的起始和结束索引。start就是当前吸顶的分组索引,用它更新activeLetter。

索引和高亮的联动是双向的:点击索引滚动列表,滑动列表更新索引。两边都要处理。

手指滑动索引

更流畅的体验是手指在索引条上滑动时实时跳转:

Column() {
  ForEach(this.alphabet, (letter: string) => {
    Text(letter)
      .fontSize(10)
      .fontColor(this.activeLetter === letter ? '#1a73e8' : '#666666')
      .padding(2)
  }, (letter: string) => letter)
}
.position({ x: '92%', y: 60 })
  .height('70%')
  .justifyContent(FlexAlign.SpaceBetween)
  .gesture(
    PanGesture()
      .onActionStart((event: GestureEvent) => {
        this.handleIndexTouch(event.y);
      })
      .onActionUpdate((event: GestureEvent) => {
        this.handleIndexTouch(event.y + event.offsetY);
      })
  )

private handleIndexTouch(y: number): void {
  let index = Math.floor(y / (this.indexHeight));
  if (index >= 0 && index < this.alphabet.length) {
    let letter = this.alphabet[index];
    this.scrollToLetter(letter);
  }
}

PanGesture滑动时根据y坐标计算手指在哪个字母上,实时跳转。indexHeight是每个字母的占用高度,需要根据索引条总高度和字母数量计算。

分组折叠

长列表分组可折叠,减少滚动距离:

@State collapsedGroups: Set<string> = new Set();

@Builder
groupHeader(letter: string, count: number) {
  Row() {
    Text(letter)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#666666')

    Text(count.toString() + '人')
      .fontSize(12)
      .fontColor('#999999')
      .margin({ left: 8 })

    Blank()

    Text(this.collapsedGroups.has(letter) ? '展开' : '收起')
      .fontSize(12)
      .fontColor('#1a73e8')
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 10, bottom: 10 })
  .backgroundColor('#f5f5f5')
  .onClick(() => {
    if (this.collapsedGroups.has(letter)) {
      this.collapsedGroups.delete(letter);
    } else {
      this.collapsedGroups.add(letter);
    }
    this.collapsedGroups = new Set(this.collapsedGroups);
  })
}

Set存储折叠的分组key。点击header时切换折叠状态。注意:Set的delete不会触发UI刷新,需要重新赋值一个新的Set。

折叠时只渲染header,不渲染children:

ListItemGroup({
  header: this.groupHeader(group.letter, group.contacts.length)
}) {
  if (!this.collapsedGroups.has(group.letter)) {
    ForEach(group.contacts, (contact: Contact) => {
      ListItem() {
        // 内容
      }
    }, (contact: Contact) => contact.id.toString())
  }
}

分组全选

勾选整个分组的所有项:

@State selectedIds: Set<number> = new Set();

private toggleGroup(letter: string, contacts: Contact[]): void {
  let allSelected = true;
  for (let i = 0; i < contacts.length; i++) {
    if (!this.selectedIds.has(contacts[i].id)) {
      allSelected = false;
      break;
    }
  }

  if (allSelected) {
    for (let i = 0; i < contacts.length; i++) {
      this.selectedIds.delete(contacts[i].id);
    }
  } else {
    for (let i = 0; i < contacts.length; i++) {
      this.selectedIds.add(contacts[i].id);
    }
  }
  this.selectedIds = new Set(this.selectedIds);
}

先检查分组内是否全选——全选则取消全选,否则全选。toggleGroup的调用放在header的onClick中(跟折叠互斥时用checkbox代替点击)。

搜索过滤与分组联动

搜索过滤后分组可能变空,要清理空分组:

private filterAndGroup(keyword: string): GroupedContact[] {
  let filtered: Contact[] = [];
  for (let i = 0; i < this.allContacts.length; i++) {
    if (this.allContacts[i].name.indexOf(keyword) >= 0 ||
        this.allContacts[i].phone.indexOf(keyword) >= 0) {
      filtered.push(this.allContacts[i]);
    }
  }
  let grouped = this.groupByLetter(filtered);
  return grouped;
}

先过滤再分组。如果某个字母下没有匹配的联系人,groupByLetter自然不会生成该分组,不需要额外清理。

搜索时侧边索引也要更新——只显示有数据的字母:

private get availableLetters(): string[] {
  let letters: string[] = [];
  for (let i = 0; i < this.groupedContacts.length; i++) {
    letters.push(this.groupedContacts[i].letter);
  }
  return letters;
}

踩坑清单

问题 原因 解决
吸顶header透字 header背景透明 给header加backgroundColor
scrollToIndex跳错位置 索引对应的是ListItem不是ListItemGroup 用ListItemGroup的序号
滑动时索引不高亮 没处理onScrollIndex 回调中更新activeLetter
Set操作不刷新UI delete/add不触发检测 重新赋值new Set(oldSet)
搜索后索引没更新 索引字母是固定的 从分组数据动态计算
分组数据为空 过滤后某分组无匹配项 过滤后重新分组
ListItemGroup的key重复 用index作key 用group.letter作key
索引条位置偏移 position用了百分比 跟容器宽度协调计算
PanGesture和List冲突 索引条手势被List消费 索引条用priorityGesture
折叠动画闪烁 数据变化和动画不同步 折叠直接切换不做动画

分组列表的核心是"数据分组→ListItemGroup渲染→Scroller跳转→索引联动"这条链路。吸顶用StickyStyle.Header,索引用Scroller+onScrollIndex,两者联动就构成了通讯录的完整体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐