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

第一步是把扁平数据按字段分组。最常见的是按首字母分组:
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,两者联动就构成了通讯录的完整体验。
更多推荐



所有评论(0)