HarmonyOS 6.0 列表分组与吸顶索引

发布时间:2026/7/27 21:02:28

HarmonyOS 6.0 列表分组与吸顶索引 通讯录按字母分组、商品按类别分组、订单按日期分组——列表分组是高频需求。但分组不只是给数据排序加标题还有吸顶效果、侧边索引条、分组折叠、跨分组全选。每个功能单独做不难组合起来坑就来了。数据分组第一步是把扁平数据按字段分组。最常见的是按首字母分组interfaceContact{id:number;name:string;phone:string;pinyin:string;}interfaceGroupedContact{letter:string;contacts:Contact[];}privategroupByLetter(contacts:Contact[]):GroupedContact[]{letgroups:GroupedContact[][];letgroupMap:Recordstring,Contact[]{};for(leti0;icontacts.length;i){letfirstCharcontacts[i].pinyin.charAt(0).toUpperCase();if(groupMap[firstChar]undefined){groupMap[firstChar][];}groupMap[firstChar].push(contacts[i]);}letlettersObject.keys(groupMap).sort();for(leti0;iletters.length;i){groups.push({letter:letters[i],contacts:groupMap[letters[i]]});}returngroups;}先建一个首字母→联系人的映射再按字母排序输出分组。Object.keys返回的顺序不保证必须sort。按日期分组同理interfaceGroupedOrder{date:string;orders:Order[];}privategroupByDate(orders:Order[]):GroupedOrder[]{letgroups:GroupedOrder[][];letgroupMap:Recordstring,Order[]{};for(leti0;iorders.length;i){letdateStrthis.formatDate(orders[i].createTime);if(groupMap[dateStr]undefined){groupMap[dateStr][];}groupMap[dateStr].push(orders[i]);}letdatesObject.keys(groupMap).sort();dates.reverse();for(leti0;idates.length;i){groups.push({date:dates[i],orders:groupMap[dates[i]]});}returngroups;}日期分组通常是倒序——最新的在前。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)}BuildergroupHeader(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.activeLetterletter?#1a73e8:#666666).fontWeight(this.activeLetterletter?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滚动到指定位置用Scrollerprivatescroller:ScrollernewScroller();privateletterToIndex:Recordstring,number{};// 初始化映射aboutToAppear():void{for(leti0;ithis.groupedContacts.length;i){this.letterToIndex[this.groupedContacts[i].letter]i;}}// 滚动到指定字母privatescrollToLetter(letter:string):void{letindexthis.letterToIndex[letter];if(index!undefined){this.scroller.scrollToIndex(index);this.activeLetterletter;}}scroller.scrollToIndex跳转到指定ListItemGroup的位置。letterToIndex是字母→分组索引的映射表在数据加载后构建。注意scrollToIndex的索引对应的是ListItemGroup在List中的位置不是ListItem。如果List中只有ListItemGroup没有单独的ListItem索引就是ListItemGroup的序号。滑动联动手指滑动列表时右侧索引高亮要跟着变List(){// 内容}.onScrollIndex((start:number,end:number){if(start0startthis.groupedContacts.length){this.activeLetterthis.groupedContacts[start].letter;}})onScrollIndex在滚动时回调当前可见区域的起始和结束索引。start就是当前吸顶的分组索引用它更新activeLetter。索引和高亮的联动是双向的点击索引滚动列表滑动列表更新索引。两边都要处理。手指滑动索引更流畅的体验是手指在索引条上滑动时实时跳转Column(){ForEach(this.alphabet,(letter:string){Text(letter).fontSize(10).fontColor(this.activeLetterletter?#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.yevent.offsetY);}))privatehandleIndexTouch(y:number):void{letindexMath.floor(y/(this.indexHeight));if(index0indexthis.alphabet.length){letletterthis.alphabet[index];this.scrollToLetter(letter);}}PanGesture滑动时根据y坐标计算手指在哪个字母上实时跳转。indexHeight是每个字母的占用高度需要根据索引条总高度和字母数量计算。分组折叠长列表分组可折叠减少滚动距离StatecollapsedGroups:SetstringnewSet();BuildergroupHeader(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.collapsedGroupsnewSet(this.collapsedGroups);})}Set存储折叠的分组key。点击header时切换折叠状态。注意Set的delete不会触发UI刷新需要重新赋值一个新的Set。折叠时只渲染header不渲染childrenListItemGroup({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())}}分组全选勾选整个分组的所有项StateselectedIds:SetnumbernewSet();privatetoggleGroup(letter:string,contacts:Contact[]):void{letallSelectedtrue;for(leti0;icontacts.length;i){if(!this.selectedIds.has(contacts[i].id)){allSelectedfalse;break;}}if(allSelected){for(leti0;icontacts.length;i){this.selectedIds.delete(contacts[i].id);}}else{for(leti0;icontacts.length;i){this.selectedIds.add(contacts[i].id);}}this.selectedIdsnewSet(this.selectedIds);}先检查分组内是否全选——全选则取消全选否则全选。toggleGroup的调用放在header的onClick中跟折叠互斥时用checkbox代替点击。搜索过滤与分组联动搜索过滤后分组可能变空要清理空分组privatefilterAndGroup(keyword:string):GroupedContact[]{letfiltered:Contact[][];for(leti0;ithis.allContacts.length;i){if(this.allContacts[i].name.indexOf(keyword)0||this.allContacts[i].phone.indexOf(keyword)0){filtered.push(this.allContacts[i]);}}letgroupedthis.groupByLetter(filtered);returngrouped;}先过滤再分组。如果某个字母下没有匹配的联系人groupByLetter自然不会生成该分组不需要额外清理。搜索时侧边索引也要更新——只显示有数据的字母privategetavailableLetters():string[]{letletters:string[][];for(leti0;ithis.groupedContacts.length;i){letters.push(this.groupedContacts[i].letter);}returnletters;}踩坑清单问题原因解决吸顶header透字header背景透明给header加backgroundColorscrollToIndex跳错位置索引对应的是ListItem不是ListItemGroup用ListItemGroup的序号滑动时索引不高亮没处理onScrollIndex回调中更新activeLetterSet操作不刷新UIdelete/add不触发检测重新赋值new Set(oldSet)搜索后索引没更新索引字母是固定的从分组数据动态计算分组数据为空过滤后某分组无匹配项过滤后重新分组ListItemGroup的key重复用index作key用group.letter作key索引条位置偏移position用了百分比跟容器宽度协调计算PanGesture和List冲突索引条手势被List消费索引条用priorityGesture折叠动画闪烁数据变化和动画不同步折叠直接切换不做动画分组列表的核心是数据分组→ListItemGroup渲染→Scroller跳转→索引联动这条链路。吸顶用StickyStyle.Header索引用ScrolleronScrollIndex两者联动就构成了通讯录的完整体验。

相关新闻