HarmonyOS 6.1 实战:Scroll + Stack 联动实现商品页吸顶 Header
📅 2026/7/22 16:00:38
👁️ 阅读次数
📝 编程学习
前言
电商应用中最常见的交互之一:页面顶部有大图 Banner,其下紧跟分类标签栏,当用户向下滚动超过 Banner 高度后,标签栏“吸顶“固定显示;继续滚动商品列表时,标签栏始终可见。本文用Stack + Scroll + onScroll实现这一联动效果,无需原生 StickyList API。
运行效果
初始状态(Banner + 分类标签)
滚动后(标签栏吸顶,显示商品列表)
核心实现原理
Stack (alignContent: Alignment.Top) ├── Scroll(主内容:Banner + 内联标签栏 + 商品列表) │ └── 监听 onScroll → 更新 scrollOffsetY └── if (scrollOffsetY >= HEADER_HEIGHT) Column (固定吸顶标签栏,position y=0, zIndex=10)当scrollOffsetY >= HEADER_HEIGHT(Banner 高度)时,在 Stack 顶部渲染固定的标签栏覆盖层,产生“吸顶“效果。
完整示例代码
interface ProductItem { name: string price: string category: string icon: string } @Entry @Component struct Index { @State scrollOffsetY: number = 0 @State activeTab: number = 0 private scroller: Scroller = new Scroller() private HEADER_HEIGHT: number = 220 private TAB_HEIGHT: number = 44 private tabs: string[] = ['全部', '电子', '服装', '食品', '家居'] private allProducts: ProductItem[] = [ { name: '无线蓝牙耳机 Pro', price: '¥299', category: '电子', icon: '🎧' }, { name: '智能手表 GT3', price: '¥799', category: '电子', icon: '⌚' }, { name: '机械键盘 87 键', price: '¥459', category: '电子', icon: '⌨️' }, { name: '运动 T 恤', price: '¥89', category: '服装', icon: '👕' }, { name: '休闲牛仔裤', price: '¥199', category: '服装', icon: '👖' }, { name: '运动鞋 Air', price: '¥349', category: '服装', icon: '👟' }, { name: '有机燕麦片 1kg', price: '¥39', category: '食品', icon: '🥣' }, { name: '进口坚果礼盒', price: '¥128', category: '食品', icon: '🥜' }, { name: '绿茶 250g', price: '¥58', category: '食品', icon: '🍵' }, { name: '北欧风台灯', price: '¥168', category: '家居', icon: '💡' }, { name: '简约书架', price: '¥289', category: '家居', icon: '📚' }, { name: '香薰蜡烛套装', price: '¥79', category: '家居', icon: '🕯️' }, { name: '4K 显示器', price: '¥1899', category: '电子', icon: '🖥️' }, { name: '羽绒外套', price: '¥599', category: '服装', icon: '🧥' }, { name: '手冲咖啡豆', price: '¥88', category: '食品', icon: '☕' }, ] private getFilteredProducts(): ProductItem[] { let activeTabName = this.tabs[this.activeTab] if (activeTabName === '全部') { return this.allProducts } let result: ProductItem[] = [] for (let i = 0; i < this.allProducts.length; i++) { if (this.allProducts[i].category === activeTabName) { result.push(this.allProducts[i]) } } return result } @Builder tabBar(isFixed: boolean) { Row({ space: 0 }) { ForEach(this.tabs, (tab: string, idx: number) => { Column({ space: 0 }) { Text(tab) .fontSize(14) .fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal) .fontColor(this.activeTab === idx ? '#0066ff' : '#555') .height(this.TAB_HEIGHT - 2) .textAlign(TextAlign.Center) // 下划线指示器 Text('') .width(this.activeTab === idx ? 24 : 0) .height(2) .backgroundColor('#0066ff') .borderRadius(1) } .layoutWeight(1) .height(this.TAB_HEIGHT) .justifyContent(FlexAlign.Center) .onClick(() => { this.activeTab = idx }) }) } .width('100%') .height(this.TAB_HEIGHT) .backgroundColor('#ffffff') .border({ width: { bottom: 1 }, color: '#f0f0f0' }) // 吸顶时显示投影 .shadow(isFixed ? { radius: 6, color: '#00000015', offsetX: 0, offsetY: 3 } : { radius: 0, color: 'transparent', offsetX: 0, offsetY: 0 }) } @Builder productCard(item: ProductItem) { Row() { Column() { Text(item.icon).fontSize(28) } .width(56).height(56) .backgroundColor('#f5f8ff').borderRadius(12) .justifyContent(FlexAlign.Center) .margin({ right: 12 }) Column({ space: 4 }) { Text(item.name) .fontSize(15).fontColor('#222').fontWeight(FontWeight.Medium) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) Row({ space: 8 }) { Text(item.category) .fontSize(11).fontColor('#0066ff') .backgroundColor('#f0f5ff') .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(4) Text('满 200 减 30').fontSize(11).fontColor('#ff4444') } } .layoutWeight(1).alignItems(HorizontalAlign.Start) Column({ space: 6 }) { Text(item.price) .fontSize(17).fontWeight(FontWeight.Bold).fontColor('#ff4444') Text('加入购物车') .fontSize(11).fontColor('#ffffff') .backgroundColor('#0066ff') .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(12) } .alignItems(HorizontalAlign.End) } .width('100%') .padding({ left: 16, right: 16, top: 14, bottom: 14 }) .backgroundColor('#ffffff') .border({ width: { bottom: 1 }, color: '#f8f8f8' }) } build() { Stack({ alignContent: Alignment.Top }) { // ── 主滚动体 ── Scroll(this.scroller) { Column({ space: 0 }) { // Hero Banner(可滚走) Column({ space: 12 }) { Row() { Column({ space: 4 }) { Text('发现好物').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#ffffff') Text('精选好货,每日更新').fontSize(13).fontColor('#ffffffcc') } .alignItems(HorizontalAlign.Start).layoutWeight(1) Column({ space: 4 }) { Text('🔍').fontSize(24) Text('搜索').fontSize(11).fontColor('#ffffffbb') } .alignItems(HorizontalAlign.Center) } .width('100%').padding({ left: 20, right: 20, top: 28, bottom: 8 }) Row({ space: 10 }) { Column({ space: 4 }) { Text('⚡ 限时特惠').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#ff8800') Text('今日好价 低至 5 折').fontSize(11).fontColor('#664400') } .layoutWeight(1).height(60).backgroundColor('#fff8e8').borderRadius(10) .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center) .border({ width: 1, color: '#ffcc66' }) Column({ space: 4 }) { Text('🎁 新人礼包').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#6600cc') Text('首单立减 50 元').fontSize(11).fontColor('#440088') } .layoutWeight(1).height(60).backgroundColor('#f8eeff').borderRadius(10) .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center) .border({ width: 1, color: '#cc88ff' }) } .padding({ left: 20, right: 20, bottom: 16 }) } .width('100%').height(this.HEADER_HEIGHT) .backgroundColor('#0066ff') .linearGradient({ angle: 135, colors: [['#0044cc', 0], ['#0088ff', 1]] }) // 内联标签栏(随页面滚动,Banner 消失后被吸顶层覆盖) this.tabBar(false) // 商品列表 Column({ space: 0 }) { Row() { Text(this.tabs[this.activeTab] === '全部' ? '全部商品' : this.tabs[this.activeTab] + ' 分类') .fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333') Text(' · ' + this.getFilteredProducts().length.toString() + ' 件') .fontSize(13).fontColor('#999') } .width('100%').height(40) .padding({ left: 16, right: 16 }) .backgroundColor('#f8f8f8') .border({ width: { bottom: 1 }, color: '#eeeeee' }) ForEach(this.getFilteredProducts(), (item: ProductItem) => { this.productCard(item) }) Column().width('100%').height(32).backgroundColor('#f5f5f5') } .width('100%').backgroundColor('#ffffff') } .width('100%') } .onScroll((_xOffset: number, _yOffset: number) => { this.scrollOffsetY = this.scroller.currentOffset().yOffset }) .scrollBar(BarState.Off) .width('100%').height('100%').backgroundColor('#f5f5f5') // ── 吸顶标签栏(当滚过 Banner 后才显示)── if (this.scrollOffsetY >= this.HEADER_HEIGHT) { Column() { this.tabBar(true) } .width('100%') .position({ x: 0, y: 0 }) .zIndex(10) } // 滚动状态角标 Row() { Text('↕ ' + Math.round(this.scrollOffsetY).toString() + 'px') .fontSize(11).fontColor('#ffffff') .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(this.scrollOffsetY >= this.HEADER_HEIGHT ? '#00aa55' : '#0066ff') .borderRadius(10) Text(this.scrollOffsetY >= this.HEADER_HEIGHT ? ' 吸顶中' : ' 滚动中') .fontSize(11).fontColor('#ffffff') .padding({ right: 8, top: 3, bottom: 3 }) .backgroundColor(this.scrollOffsetY >= this.HEADER_HEIGHT ? '#00aa55' : '#0066ff') .borderRadius(10) } .position({ x: 16, y: 8 }) .zIndex(20) .borderRadius(12) .shadow({ radius: 4, color: '#00000020', offsetX: 0, offsetY: 2 }) } .width('100%').height('100%') } }关键知识点
1. Stack + 条件渲染实现吸顶
核心思路:在Stack顶部用if条件渲染一个固定在 y=0 的覆盖层:
Stack({ alignContent: Alignment.Top }) { Scroll() { ... } .onScroll(() => { this.scrollOffsetY = this.scroller.currentOffset().yOffset }) // 吸顶覆盖层 if (this.scrollOffsetY >= HEADER_HEIGHT) { Column() { this.tabBar(true) } .position({ x: 0, y: 0 }) // 固定在顶部 .zIndex(10) // 覆盖在滚动内容之上 } }2. onScroll 获取绝对偏移
.onScroll(dx, dy)的参数是增量,用scroller.currentOffset().yOffset获取绝对位移:
.onScroll((_x: number, _y: number) => { this.scrollOffsetY = this.scroller.currentOffset().yOffset })3. 吸顶时显示阴影
@Builder tabBar(isFixed: boolean)接收参数,当isFixed=true时给标签栏加投影,增强层次感:
@Builder tabBar(isFixed: boolean) { Row() { ... } .shadow(isFixed ? { radius: 6, color: '#00000015', offsetX: 0, offsetY: 3 } : { radius: 0, color: 'transparent', offsetX: 0, offsetY: 0 }) }4. RowAttribute 不支持 .overflow()
Row组件没有.overflow()属性(.overflow()仅存在于Text等文本组件),如果需要裁剪超出内容,改用.clip(true):
// 错误:Row 没有 .overflow() Row() { ... }.overflow(Overflow.Hidden) // 正确:用 .clip() Row() { ... }.clip(true)5. @Builder 参数传递
@Builder方法支持传参,可以根据参数渲染不同状态的 UI:
@Builder tabBar(isFixed: boolean) { // 根据 isFixed 调整 shadow、backgroundColor 等 } // 调用 this.tabBar(false) // 内联标签栏 this.tabBar(true) // 吸顶覆盖层小结
- 吸顶效果的关键:
Stack包裹 +onScroll监听偏移量 +if条件渲染固定覆盖层 - 获取绝对滚动位置用
scroller.currentOffset().yOffset,不要用onScroll的差量参数累加 - 吸顶覆盖层通过
.position({ x:0, y:0 })+.zIndex(n)固定在最顶部 Row没有.overflow()属性,裁剪用.clip(true)- 同一个
@Builder方法传不同参数可渲染“滚动版“和“吸顶版“两种状态,避免代码重复
编程学习
技术分享
实战经验