HarmonyOS7 左边点类目右边跟着动,怎么做顺?分类页联动实战讲透
文章目录前言分类数据模型整体布局左侧导航列表右侧内容区域左右联动核心逻辑嵌套滚动处理SectionHeader 吸顶效果完整的数据加载流程几点经验分享前言分类页面是电商 App 的经典布局左边一列一级分类右边是对应的二级分类和商品。看着简单做起来坑不少——尤其是左右联动那块点左边右边要跟着切换滚右边左边要跟着高亮做好需要花点心思。这篇把这个页面从头到尾拆清楚。分类数据模型先把数据结构定义好后面写 UI 的时候心里有底// lib_core/src/main/ets/model/Category.etsexportinterfaceCategoryGroup{id:stringname:stringicon:stringchildren:SubCategory[]}exportinterfaceSubCategory{id:stringname:stringicon:stringproducts:CategoryProduct[]}exportinterfaceCategoryProduct{id:stringname:stringprice:numberimageUrl:string}三层结构一级分类 → 二级分类 → 商品。一级分类是左侧列表的数据源二级分类和商品组成右侧的内容区域。整体布局页面分成左右两栏。左边固定宽度 90vp右边占剩余空间// entry/src/main/ets/pages/CategoryPage.etsComponentexportstruct CategoryPage{Statecategories:CategoryGroup[][]StateselectedIndex:number0StaterightScrollToIndex:number0privateleftScroller:ScrollernewScroller()privaterightScroller:ScrollernewScroller()// 记录右侧每个一级分类区块的起始偏移量StatesectionOffsets:number[][]// 标记是否由左侧点击触发的右侧滚动避免循环联动StateisLeftClicking:booleanfalseaboutToAppear(){this.loadCategories()}build(){Row(){// 左侧一级分类列表this.LeftNav().width(90).height(100%)// 右侧内容区域this.RightContent().layoutWeight(1).height(100%)}.width(100%).height(100%).backgroundColor(#F5F5F5)}privateasyncloadCategories(){// TODO: 调用 ProductRepository.getCategories()this.categoriesgenerateMockCategories()// 计算各区块偏移this.calculateSectionOffsets()}}整体就是一个Row左边固定宽度右边layoutWeight(1)自适应。简单直接。左侧导航列表左侧列表的关键是选中态当前选中项有个左边的竖条指示器 白色背景未选中项是灰色背景。BuilderLeftNav(){List({scroller:this.leftScroller}){ForEach(this.categories,(item:CategoryGroup,index:number){ListItem(){Stack({alignContent:Alignment.Start}){// 选中指示条if(this.selectedIndexindex){Rect().width(3).height(20).fill(#FF6B35).borderRadius(2).margin({left:0,top:18})}Row(){Text(item.name).fontSize(13).fontColor(this.selectedIndexindex?#333333:#999999).fontWeight(this.selectedIndexindex?FontWeight.Medium:FontWeight.Normal).maxLines(2).textAlign(TextAlign.Center)}.width(100%).height(56).justifyContent(FlexAlign.Center)}.width(100%).height(56).backgroundColor(this.selectedIndexindex?Color.White:#F5F5F5).onClick((){this.selectedIndexindexthis.isLeftClickingtrue// 右侧滚动到对应区块this.scrollToSection(index)// 延迟重置标记setTimeout((){this.isLeftClickingfalse},500)})}},(item:CategoryGroup,index:number)item.id)}.width(100%).height(100%).scrollBar(BarState.Off).edgeEffect(EdgeEffect.None).backgroundColor(#F5F5F5)}左侧列表禁用了滚动条scrollBar(BarState.Off)分类不多的时候不需要显示。选中项左边的橘色竖条用 Stack 叠加在左侧3vp 宽圆角 2vp。右侧内容区域右侧是一个 List每个一级分类是一个 Section包含标题 二级分类 Grid 商品列表BuilderRightContent(){List({scroller:this.rightScroller}){ForEach(this.categories,(group:CategoryGroup,groupIndex:number){// 每个一级分类是一个大区块ListItemGroup({header:this.SectionHeader(group.name)}){ListItem(){Column({space:16}){// 每个二级分类ForEach(group.children,(sub:SubCategory){Column(){// 二级分类标题Text(sub.name).fontSize(14).fontWeight(FontWeight.Medium).fontColor(#333333).width(100%).padding({bottom:8})// 二级分类图标 GridGrid(){ForEach(sub.products,(product:CategoryProduct){GridItem(){Column({space:4}){Image(product.imageUrl).width(56).height(56).objectFit(ImageFit.Cover).borderRadius(8)Text(product.name).fontSize(11).fontColor(#666666).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})}.onClick((){// 跳转商品详情})}},(product:CategoryProduct)product.id)}.columnsTemplate(1fr 1fr 1fr).rowsGap(12).columnsGap(8).height(this.calcGridHeight(sub.products.length))}},(sub:SubCategory)sub.id)}.padding(12).backgroundColor(Color.White).borderRadius(8).margin({left:8,right:8})}}},(group:CategoryGroup,index:number)group.id)}.width(100%).height(100%).scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring).onScrollIndex((firstIndex:number){if(!this.isLeftClicking){// 右侧滚动触发的联动this.selectedIndexfirstIndex// 左侧列表跟随滚动this.leftScroller.scrollToIndex(firstIndex)}})}ListItemGroup搭配header参数可以给每个分组加一个吸顶标题。这个效果很常见——你滚动右侧的时候当前分类的标题会吸在顶部。二级分类的商品用 Grid 三列排列。calcGridHeight是我写的一个辅助方法根据商品数量算出 Grid 需要的高度行数 × 行高 间距因为 Grid 放在 ListItem 里不会自动撑开。左右联动核心逻辑这是整个页面最有技术含量的部分。联动有两个方向方向一点左侧 → 右侧滚到对应位置privatescrollToSection(index:number){// 滚到对应的一级分类区块this.rightScroller.scrollToIndex(index)}点击左侧列表项时设置isLeftClicking true然后调scrollToSection让右侧 List 滚到对应 index。滚动过程中onScrollIndex会触发但因为isLeftClicking为 true会跳过反向联动避免循环。500ms 后重置标记。方向二滚右侧 → 左侧高亮跟随.onScrollIndex((firstIndex:number){if(!this.isLeftClicking){this.selectedIndexfirstIndexthis.leftScroller.scrollToIndex(firstIndex)}})右侧滚动时onScrollIndex回调给出当前可见的第一个 ListItemGroup 的 index。如果不是左侧点击触发的滚动就更新selectedIndex并让左侧列表跟着滚。这个isLeftClicking标记是关键。没有它的话点左侧 → 右侧开始滚 → 滚动触发 onScrollIndex → 又去改 selectedIndex → 左右互相更新就乱套了。加个时间窗口锁住反向联动问题就解决了。嵌套滚动处理分类页有一个经典的滚动问题右侧的内容区域本身是一个 List但如果某个二级分类下有大量商品Grid 的高度可能超出屏幕。这时候 Grid 的内部滚动和外层 List 的滚动会冲突。解决方案是把 Grid 的高度算死让它不需要内部滚动privatecalcGridHeight(itemCount:number):number{constcolumns3constrowsMath.ceil(itemCount/columns)constitemHeight80// 图标 文字 间距constrowGap12returnrows*itemHeight(rows-1)*rowGap}Grid 的高度 行数 × (单个 item 高度) (行数 - 1) × 行间距。算好之后直接设给 Grid 的heightGrid 内部就不会有滚动了所有滚动都由外层 List 统一接管。这是处理嵌套滚动最干净的方式。如果你让 Grid 自己也能滚手势冲突会让你调到手软。SectionHeader 吸顶效果BuilderSectionHeader(title:string){Row(){Text(title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(#333333)}.width(100%).height(40).padding({left:12}).backgroundColor(#F5F5F5).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Start)}ListItemGroup 的 header 天生支持吸顶。滚动到某个分组时header 会贴在列表顶部直到下一个分组把它顶走。不需要额外处理ArkUI 的 List 组件自动搞定。完整的数据加载流程aboutToAppear(){this.loadCategories()}privateasyncloadCategories(){try{this.categoriesawaitProductRepository.getCategories()if(this.categories.length0){this.selectedIndex0}}catch(e){// 加载失败时展示错误态或缓存数据console.error(加载分类失败:,JSON.stringify(e))}}分类数据一般不会太频繁变化首次加载后可以做个本地缓存。我用的方案是存到 Preferences 里下次打开先读缓存秒开再静默请求接口更新。几点经验分享左侧列表项高度要固定。如果高度不一致scrollToIndex的定位会不准。我统一用 56vp两行文字也够放。联动锁的时间窗口。500ms 是我试出来的比较合适的值。太短比如 200ms右侧滚动动画还没结束锁就解了太长比如 1s用户快速连续点击左侧会感觉不灵敏。右侧内容区不要嵌套 Scroll。整个右侧就是一个 List里面放 ListItemGroup所有滚动都交给这个 List。嵌套 Scroll 是万恶之源。二级分类的 Grid 高度一定要算好。这是最容易翻车的地方Grid 高度不对要么显示不全要么和外部 List 滚动冲突。老老实实算行数和高度别偷懒。分类页做完之后四个 Tab 页面就剩购物车和「我的」了。不过在那之前下一篇我们先把商品详情页搞定——毕竟用户从首页、搜索、分类点进去都要落到这个页面。