购物比价/商城App最常见的首页长相:最顶上一行自定义标题栏("首页"/logo/消息图标…),往下是轮播Banner/运营位/快捷入口,再往下是一条分类Tab(同城|推荐|活动|玩机),Tab下面挂着商品列表。上滑时Banner先收走,Tab行滚到标题栏下沿就"吸住",标题栏同时由透明/浅色慢慢变成实色白底。
这个效果看起来华丽,但拆开只有两件事:谁来承载标题栏的层级 +滚动权在外层Scroll和内层列表之间怎么交接。
华为官方购物比价实践专门点出了关键点:Stack层叠 +nestedScroll交接 +onDidScroll驱动标题栏属性动画。下面用"最小骨架图"把它讲透,不陷进百行源码。
一、先把布局层级钉死:标题栏必须"浮在顶层",而不是跟内容一起滚
多数人第一次写,会把标题栏放进Column → Banner → ...的流式里,于是它天然随着内容一起上滚——后面再想"让它固定"就只能靠监听偏移反拉,写法很快就脏了。
正确姿势是一个Stack 页面根:
Stack(页满屏) ├─ 内容层(最底):Scroll(整页可滚内容) │ ├─ 前置区:Banner / 快捷入口 / 运营横幅(高度 = H_pre) │ ├─ TabBar行:"同城/推荐/活动/玩机"(这就是要吸顶的那条) │ └─ 内容区:List / WaterFlow / TabContent区 │ └─ 标题栏层(最顶,靠 Stack 盖在上面) ├─ 背景:rgba(255,255,255, opacity) ├─ 左侧"首页"/返回箭头 └─ 右侧消息/搜索图标核心就一句:
标题栏是 Stack 的"最上子节点",它不参与 Scroll 的流式排版;它只是用
opacity/backgroundColor/translate做视觉变化。而 Scroll 是 Stack 的下一层子节点,负责把"前置区+TabBar行+列表"正常往上滚。
这样 TabBar 行滚到标题栏下沿时,你不用做任何"把标题栏钉住"的动作——它本来就在那。你只要保证:TabBar行到达顶部的那一刻,外层的Scroll别再把TabBar行继续卷出屏幕,也就是让TabBar行在那个位置变成"逻辑上的顶"。
二、Scroll嵌套列表:吸顶的本质是"滚动权的父子交接"
你面对的是一个经典嵌套滚动场景:
外层:
Scroll(管 Banner区 + TabBar行 的位移)内层:
List / WaterFlow / TabContent里的列表(管商品流的滚动)
"吸顶"不是靠一个属性开关:stickyHeader之类只是ListItem组的行为;这里更通用的解法是控制谁在当前手势下有权滚动——这就是nestedScroll的两个方向:
手势方向 | 参数 | 含义(口语版) |
|---|---|---|
手指上滑(内容往上走,scrollForward) |
| 外层Scroll先滚:先把Banner区卷走,直到TabBar行贴到标题栏下沿;到了边缘后,滚动权才给内层列表 |
手指下滑(内容往下走,scrollBackward) |
| 内层列表先滚:先把列表拉到底(视觉上是"先让列表到顶"),到顶后滚动权还给外层,让Banner区重新拉回来、TabBar行从吸顶位置落下去 |
写成代码只写这几行"骨架"(你不想要大段源码,我给它压到最少可读量):
// 内层列表 / WaterFlow 的 nestedScroll .nestedScroll({ scrollForward: NestedScrollMode.PARENT_FIRST, // 向上滑:外层优先 scrollBackward: NestedScrollMode.SELF_FIRST // 向下滑:内层先回顶,再交外层 }) .edgeEffect(EdgeEffect.None, { alwaysEnabled: true })这里有个很容易踩的坑:
.edgeEffect(EdgeEffect.None)必须配alwaysEnabled:true。否则列表到顶时边缘感知会"断片",外层Scroll一时接不到权,你看到的就是 TabBar 在顶上轻微抖一下——像弹簧"咔"一下。
三、标题栏渐变:驱动源不是"TabBar到哪了",而是"Scroll滚了多少"
标题栏要变的是:背景从透明→实色、文字从浅色→深色、甚至高度/阴影随滚动出现。
你需要一个可靠的"滚动偏移",而不是去算TabBar离顶距离:
// 外层 Scroll @State scrolledY: number = 0 @State titleOpacity: number = 0 // 0=全透, 1=实底 .onDidScroll((xOff, yOff, state) => { this.scrolledY = this.scroller.currentOffset().yOffset // fadeEnd 你可以取:前置区高度(Banner区),或前置区高度×0.8 做柔和区间 const fadeEnd = this.bannerZoneHeight const ratio = Math.max(0, Math.min(1, this.scrolledY / fadeEnd)) // 用 animateTo 包一下,避免每帧硬跳造成"闪烁" animateTo(0, () => { this.titleOpacity = ratio }) })标题栏那层的样式写成"由titleOpacity驱动":
backgroundColor: rgba(255,255,255, titleOpacity)或blendAlpha文字颜色:
titleOpacity < 0.5 ? '#FFFFFF' : '#111111'阴影:只在
titleOpacity > 0.95时显示(可选),避免滚动中一直画shadow
这样做的好处是:标题栏变化跟滑动是同一帧来源(onDidScroll),不会出现"Tab吸住了但标题栏晚半拍"。
四、Stack + 安全区:标题栏千万别忘了 expandSafeArea 与状态栏高度
商城首页标题栏几乎必做两件事:
吃状态栏高度:用
expandSafeArea([SafeAreaType.SYSTEM], SafeAreaEdge.TOP)让标题栏内容区真正顶到屏幕上沿标题栏背景的"实色层"要独立于内容层:因为内容层会滚,你在Stack里用
Column做一个"标题栏壳":底层:
Rectangle/Row当背景(背景色由titleOpacity控制)上层:操作图标/文字(不受内容滚动影响)
如果你不处理安全区,滚到顶时标题栏文字就会"顶进状态栏",或者在刘海屏上偏半截。
五、最小完整心智模型(你照这个搭就不会乱)
把它当成三句话去验收:
Stack根:标题栏 = 最顶层节点(不参与Scroll),内容 = 下层 Scroll
Scroll内容顺序:前置区 → TabBar行 → 列表区(TabBar行是"内容的一部分",不是barPosition那种Tabs内置bar)
滚动权:内层列表用
PARENT_FIRST / SELF_FIRST交接,别自己写if-else抢事件
满足这三条,吸顶自然成立:TabBar行会在它该停的位置(标题栏下沿)停下——因为它没被额外拉走,只是Scroll把前面的兄弟卷上去而已。
六、最容易踩的坑速查表
现象 | 根因 | 修法 |
|---|---|---|
TabBar吸到一半抖一下 | edgeEffect默认Spring回弹把"到顶交接"搞出微反冲 |
|
标题栏文字顶进状态栏 | 没做expandSafeArea / 没补状态栏高度 paddingTop | 标题栏壳加 |
内层列表在上滑时"抢滚",Banner区没卷完Tab就动了 | nestedScroll没配,或配反了 | 确认 |
标题栏背景变化"跳格" | 在onDidScroll里直接赋值不插值 | 用 |
吸顶在折叠屏展开/旋转后偏 | 前置区高度写死像素,没按 | 前置区高度用布局约束或 |
七、总结
商城首页"上滑吸顶 + 标题栏渐变"不是黑魔法:
Stack 解决层级:标题栏悬浮,内容滚在下面
nestedScroll(PARENT_FIRST / SELF_FIRST) 解决滚动权:Banner先走、Tab再吸、列表再接
onDidScroll + 归一化比值 + animateTo 解决"标题栏从透到实"的连续感
把这三块钉住,这个首页给人的感觉就不是"组件凑出来的",而是像主流电商App那种顺滑、稳、不抖的手感——用户说不出来为什么舒服,但他们能感觉到。
下一篇可以延伸的方向:把这条TabBar行的吸顶改成真·stickyHeader(当你的TabBar不是简单一行文字,而是带筛选下拉/胶囊组时),以及
onDidScroll在低帧率设备上的节流策略。