Appearance
Jetpack Compose 嵌套滚动与滚动协同
回到总览:状态管理、路由、架构
相关模块:Jetpack Compose 状态模型、StateFlow 与重组优化 · Flutter 路由:go_router / Navigator 体系 · 深链 (Deep Link)、App Link 与合成返回栈治理
一句话定义
Jetpack Compose 的嵌套滚动(Nested Scroll)不是“再套一层可滚动容器”,而是通过 Modifier.nestedScroll()、NestedScrollConnection 与滚动事件消费链,让父子滚动节点按约定协商“谁先吃位移、谁后吃位移、剩余多少继续传递”;同一题在 Android View 体系通常落到 CoordinatorLayout / nested scrolling parent-child 协议,在 Flutter 则更常表现为 Sliver 体系与 NestedScrollView 的协同。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| Android 滚动协同(Compose + View) | android-nested-scroll-coordination-demo | AndroidNestedScrollCoordinationActivity.kt |
| Flutter 滚动协同(Sliver + 手工联动) | flutter-nested-scroll-coordination | flutter_nested_scroll_coordination_page.dart |
为什么需要
- 为什么
LazyColumn外面再包一层Column(verticalScroll())往往会出现手势冲突、头部折叠异常、滚动体验别扭?- 一句话答:因为问题不在“有没有两层滚动”,而在“滚动位移由谁先消费”;没有消费协商时,父子容器会争抢同一份手势位移。
nestedScroll()到底解决了什么?- 一句话答:它建立了一条父子滚动协作链,让父级能在子级滚动前后分别预消费(pre-scroll)或补消费(post-scroll)剩余位移。
- 为什么折叠头部、吸顶区、底部面板这些交互经常写着写着就卡顿或行为错乱?
- 一句话答:因为这些需求本质上都是“多个节点共享同一段滚动位移”,如果不明确消费顺序、状态边界和读取时机,就会出现抖动、跳变和重组过大。
- 嵌套滚动和状态管理有什么关系?
- 一句话答:滚动偏移本身是高频状态;若在 Composition 里粗暴读取,会放大重组范围,正确做法是把“高频位移”和“低频派生 UI 状态”分层处理。
- 为什么 Android View 和 Flutter 也必须认真理解这题,而不能只记 Compose API?
- 一句话答:因为真实项目常同时维护旧 View 页面、Compose 页面和 Flutter 页面,滚动协同的问题形态不同,但底层都是“共享位移 + 边界移交”的同一类事故。
这页解决什么问题
- Compose 里的 nested scroll 到底是协议、修饰符,还是特殊容器?
- 一句话答:它更像滚动事件协商协议,
Modifier.nestedScroll()只是把节点接入这条协商链的入口。
- 一句话答:它更像滚动事件协商协议,
- 什么时候该用
NestedScrollConnection,什么时候只是普通LazyListState就够了?- 一句话答:只有当“一个节点的滚动需要影响另一个节点”时才需要 nested scroll;纯列表显隐按钮、回到顶部按钮通常只要
LazyListState即可。
- 一句话答:只有当“一个节点的滚动需要影响另一个节点”时才需要 nested scroll;纯列表显隐按钮、回到顶部按钮通常只要
- Android View、Compose、Flutter 三套体系里的嵌套滚动可以怎么对照理解?
- 一句话答:三者本质都在解决“父子节点共享滚动位移”,只是 Android View 偏接口协议与容器行为编排、Compose 偏显式消费链、Flutter 偏 Sliver 布局协同。
- 工程里最常见的误判是什么?
- 一句话答:把“需要消费协同”误写成“再包一层滚动容器”或“随便监听 scrollController”,结果是逻辑能跑但边界、性能和 fling 全乱掉。
底层机制
1. 嵌套滚动不是双层滚动,而是消费链
图注补充:pre-scroll 常用于“头部先折叠再让列表滚”;post-scroll 常用于“列表滚不动后由父级继续接手”,例如下拉头图回弹、外层容器补位移。
对应 Lab:android-nested-scroll-coordination-demo · AndroidNestedScrollCoordinationActivity.kt · flutter-nested-scroll-coordination · flutter_nested_scroll_coordination_page.dart
2. 四段核心回调要分清
| 回调 | 发生时机 | 典型用途 | 常见误解 |
|---|---|---|---|
onPreScroll() | 子节点滚动前 | 头部先折叠、父级先吃位移 | 误以为它会自动滚动子列表 |
onPostScroll() | 子节点消费后 | 接手剩余位移、边界回弹 | 误以为它能拿到“总位移”而不是剩余位移 |
onPreFling() | 子节点 fling 前 | 父级先抢一部分惯性 | 误以为只和拖拽有关 |
onPostFling() | 子节点 fling 后 | 父级补处理剩余惯性 | 误以为 fling 不走 nested scroll |
3. 最常见的父子协同模型:头部折叠 + 列表继续滚
4. Android View、Compose、Flutter 分别是怎么做的
| 体系 | 核心机制 | 原理重心 | 真正难点 |
|---|---|---|---|
| Compose | nestedScroll() + NestedScrollConnection | 显式决定每一段位移吃多少 | 高频状态读取与 UI 分层 |
| Android View | NestedScrollingParent/Child、CoordinatorLayout、AppBarLayout.Behavior | 容器行为决定子 View 如何响应滚动 | 调试成本高、行为链隐式 |
| Flutter | NestedScrollView、SliverAppBar、ScrollController、NotificationListener | outer/inner scrollPosition 与 Sliver 协同 | 盒模型滚动和 Sliver 滚动容易混淆 |
Flutter 这一侧我会沿用“box protocol vs sliver protocol”的理解口径:SingleChildScrollView 这类更偏 box 布局,NestedScrollView / SliverAppBar 才是滚动协同真正稳定的主战场;这点与外部分析文章的切入是一致的,可参考 JI,XIAOYONG's Blog。
代码示例与关键判断
1. Compose:折叠头部的最小思路
对应 Lab:android-nested-scroll-coordination-demo · AndroidNestedScrollCoordinationActivity.kt
kotlin
@Composable
fun CollapsingHeaderList() {
val listState = rememberLazyListState()
val maxHeaderOffset = 160f
var headerOffsetPx by remember { mutableFloatStateOf(0f) }
val connection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
if (delta >= 0f) return Offset.Zero
val newOffset = (headerOffsetPx + delta).coerceAtLeast(-maxHeaderOffset)
val consumed = newOffset - headerOffsetPx
headerOffsetPx = newOffset
return Offset(x = 0f, y = consumed)
}
}
}
Column(Modifier.nestedScroll(connection)) {
Header(
modifier = Modifier
.height(160.dp)
.graphicsLayer { translationY = headerOffsetPx }
)
LazyColumn(state = listState) {
items(100) { index ->
Text("Item $index")
}
}
}
}2. Android View:CoordinatorLayout 版的最小骨架
对应 Lab:android-nested-scroll-coordination-demo · activity_android_nested_scroll_coordination.xml
xml
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBar"
android:layout_width="match_parent"
android:layout_height="220dp">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="Android View Header" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />这套方案的关键不是 RecyclerView 本身,而是 AppBarLayout 与 ScrollingViewBehavior 在 nested scrolling 协议里接管了“头部先折叠,列表后滚”的行为。
3. Flutter:推荐主方案是 NestedScrollView + SliverAppBar
对应 Lab:flutter-nested-scroll-coordination · flutter_nested_scroll_coordination_page.dart
dart
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(innerBoxIsScrolled ? '已折叠' : '展开中'),
background: Container(color: Colors.blueGrey),
),
),
],
body: ListView.builder(
padding: EdgeInsets.zero,
itemCount: 40,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
)4. Flutter:手工联动方案是 ScrollController + NotificationListener
dart
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.metrics.axis != Axis.vertical) return false;
final next = notification.metrics.pixels.clamp(0.0, 160.0);
if (next != _manualHeaderOffset) {
setState(() => _manualHeaderOffset = next);
}
return false;
},
child: ListView.builder(
controller: _manualController,
itemCount: 30,
itemBuilder: (context, index) => ListTile(title: Text('Manual $index')),
),
)这个方案能工作,但它只是在监听结果,天然不如 Sliver 方案那样把协同写进布局结构本身,所以在 fling、Tab 嵌套、吸顶头等复杂场景里更容易抖。
5. 这个例子真正想说明什么
NestedScrollConnection决定的是“消费多少位移”,不是直接替代列表滚动。headerOffsetPx属于高频滚动状态,不应直接衍生一堆大范围 UI 分支。- Android View 体系把很多协同行为藏在容器与 behavior 里,优点是少写代码,缺点是隐式。
- Flutter 推荐优先把协同写进 Sliver 结构;只有结构无法表达时,才退回 controller / notification 手工联动。
6. 与 Compose 状态模型的连接点
kotlin
@Composable
fun ScrollTopShadow(listState: LazyListState) {
val showShadow by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
if (showShadow) {
Divider()
}
}这里的关键不是 API,而是判断:
firstVisibleItemScrollOffset是高频连续量- 阴影显隐只是低频布尔量
- 用
derivedStateOf把高频量压成低频量,避免滚一点点就整块重组
Android / Flutter / Web / Backend 对照
| 维度 | Jetpack Compose | Android View 体系 | Flutter | iOS / SwiftUI | Web |
|---|---|---|---|---|---|
| 核心问题 | 父子共享滚动位移怎么协商 | CoordinatorLayout / nested scrolling 接口怎么协商 | NestedScrollView / Sliver 怎么协同 | ScrollView 位移读取与 UIKit 委托如何分工 | overflow 容器与 sticky/scroll listener 如何配合 |
| 主要机制 | nestedScroll() + NestedScrollConnection | CoordinatorLayout / AppBarLayout / Behavior | NestedScrollView / CustomScrollView / SliverAppBar | GeometryReader / PreferenceKey,复杂场景回到 UIScrollViewDelegate | 事件监听 + sticky + 容器滚动 |
| 适合场景 | 折叠头部、BottomSheet、PullToRefresh、内外列表协同 | 传统 AppBar 折叠、RecyclerView 联动 | Tab + Sliver 头部 + 内部列表 | 动态导航栏透明度、下拉头图、分页列表 | 管理后台表格、文档区滚动联动 |
| 容易踩坑 | 状态高频读取放大重组 | 行为依赖容器组合且调试痛苦 | inner/outer scroll 协同复杂 | SwiftUI 原生可控度不如 UIKit | 滚动监听过多导致抖动 |
常见场景
1. 折叠头图 / 吸顶搜索栏
对应 Lab:android-nested-scroll-coordination-demo · flutter-nested-scroll-coordination
最典型:头图上滑先收起,内容区再继续滚。
判断重点:
- 如果头图只是视觉位移,优先局部读状态(
graphicsLayer/offset {}) - 如果还涉及“吸顶后切换 toolbar 结构”,再把低频结构状态单独派生
- Android View 若已是传统页面,优先先看
CoordinatorLayout是否就能表达 - Flutter 若已是复杂头部,优先
NestedScrollView + SliverAppBar
2. 下拉刷新 + 内部列表
不是所有刷新都必须手写 nested scroll;Material3 已有成品 pullRefresh / PullToRefreshBox 场景优先复用。
要点:
- 成品能解决时优先成品
- 自定义刷新头时才自己接 nested scroll
- 刷新进度是高频量,刷新中/非刷新中是低频态
- Flutter 同理:能用
RefreshIndicator时不要先写 controller 拼装
3. BottomSheet 内部再放长列表
这类最容易写出“拖不动/抢手势”的问题。
优先判断:
- 是“sheet 本身在拖”,还是“内部列表在滚”
- 到边界后剩余位移由谁接手
- fling 时是否需要父级继续接管
常见误配、事故后果与排障
1. 事故:把 verticalScroll() 和 LazyColumn / RecyclerView 粗暴叠在一起
- 误配原因:把“需要联动”误写成“多套一层滚动容器”。
- 后果:滚动距离计算异常、性能差、手势行为难预测。
- 排障与修法:先拆清谁是主滚动体;能用单一
LazyColumn+stickyHeader、CoordinatorLayout或NestedScrollView解决的,不要先上双层滚动。
2. 事故:滚动偏移在顶层被大量读取
- 误配原因:把
scrollOffset、firstVisibleItemScrollOffset或 controller offset 直接驱动多个大组件。 - 后果:滚一下就整页重组 / 整棵 Widget 树 rebuild,折叠头抖动、动画卡顿。
- 排障与修法:高频位移只在最小作用域读取;视觉位移用
offset {}/graphicsLayer {}/AnimatedBuilder,结构显隐用derivedStateOf或低频状态派生。
3. 事故:把 nested scroll 当成“所有复杂滚动都必须上”的默认方案
- 误配原因:没先判断问题是“事件协同”还是“普通列表布局”。
- 后果:代码复杂度无意义上升,调试难度变高。
- 排障与修法:只有存在父子节点共同消费同一段滚动位移时,才引入
NestedScrollConnection、CoordinatorLayout或NestedScrollView。
4. 事故:Flutter 手工监听 offset,却忘了它不等于真正的 outer/inner 协同
- 误配原因:把
ScrollController监听当成所有滚动联动的万能解。 - 后果:Tab 切换、吸顶、惯性滚动边界都容易不稳。
- 排障与修法:先判断是不是 Sliver 问题;如果是折叠头部或 outer/inner 协同,优先
NestedScrollView + SliverAppBar。
与相近概念对比
| 概念 | 解决什么问题 | 什么时候优先用 |
|---|---|---|
LazyListState / RecyclerView / ScrollController | 单个列表自身滚动状态读取 | 只做列表显隐按钮、定位、回顶 |
stickyHeader / SliverPersistentHeader | 吸顶展示 | 头部只是吸顶,不需要复杂消费协商 |
nestedScroll() / CoordinatorLayout / NestedScrollView | 父子节点共享滚动位移 | 折叠头、BottomSheet、刷新头、联动滚动 |
Modifier.offset {} / graphicsLayer {} / Transform.translate | 高频视觉位移 | 只改位置/透明度,不改 UI 结构 |
derivedStateOf / 低频派生状态 | 高频状态压成低频派生态 | 由滚动量派生布尔显隐、分段状态 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| android-nested-scroll-coordination-demo | Android Compose 折叠头部 + Android View CoordinatorLayout 对照 | AndroidNestedScrollCoordinationActivity.kt |
| flutter-nested-scroll-coordination | Flutter NestedScrollView + SliverAppBar 主方案与手工 controller 协同对照 | flutter_nested_scroll_coordination_page.dart |
复习检查题
为什么说嵌套滚动的核心不是“双层滚动”,而是“滚动位移消费链”?
答:因为真正要解决的是同一段手势位移如何在父子节点之间分配,而不是单纯多放几个可滚动容器;没有消费协商时就会出现抢手势和行为错乱。
onPreScroll()和onPostScroll()的职责差异是什么?答:
onPreScroll()发生在子节点消费前,适合父级先吃一部分位移;onPostScroll()发生在子节点消费后,适合父级接手剩余位移或边界补处理。为什么滚动偏移这类状态特别容易引发性能问题?
答:因为它是高频连续变化量,若在 Composition 顶层被大范围读取,会导致整块 UI 高频重组;应下沉到局部读取并用
derivedStateOf派生低频状态。Flutter 里为什么很多折叠头部问题优先该想
NestedScrollView + SliverAppBar,而不是先想ScrollController?答:因为这类题本质是 outer/inner scroll 与 Sliver 结构协同,不只是拿到 offset 后自己改值;controller 更适合做手工补充,而不是代替结构化滚动协同。
Flutter 复杂场景:TabBar + 内层列表
对应 Lab:flutter-nested-scroll-coordination · flutter_nested_scroll_coordination_page.dart
折叠头 + Tab + 每个 Tab 各自长列表,是 Flutter 工程里最常见的「复杂嵌套滚动」形态之一。若仍用 Scaffold.appBar.bottom: TabBar + 普通 ListView,头部折叠与 Tab 内列表的 outer/inner 协同很容易脱节;正确做法是把 TabBar 放进 NestedScrollView.headerSliverBuilder,body 放 TabBarView,每个 Tab 用带 SliverOverlapInjector 的 CustomScrollView(或等价 Sliver 列表)。
为什么需要两层 TabController?
- 为什么外层和内层各要一个
TabController,不能共用一个?- 一句话答:外层切换「主方案 / 对照方案」,内层切换「推荐 / 最新」等业务 Tab;职责不同,生命周期与
vsync也应分离,用TickerProviderStateMixin托管两个 controller。
- 一句话答:外层切换「主方案 / 对照方案」,内层切换「推荐 / 最新」等业务 Tab;职责不同,生命周期与
SliverOverlapAbsorber/SliverOverlapInjector解决什么?- 一句话答:TabBar 作为 pinned sliver 会占用布局高度;Injector 在内层列表顶部「垫」同等占位,避免首项被 TabBar 遮住或滚动起点错位。
- 为什么这类场景不建议退回
NotificationListener手工改 header?- 一句话答:Tab 切换、fling 边界、吸顶后内层接力滚动都需要 outer/inner 协议协同,监听 offset 只能补简单联动,撑不住多 Tab 长列表。
最小骨架(折叠头 + TabBar + 内层列表)
dart
class _PageState extends State<Page> with TickerProviderStateMixin {
late final TabController _mainTabController = TabController(length: 2, vsync: this);
late final TabController _innerTabController = TabController(length: 2, vsync: this);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
bottom: TabBar(
controller: _mainTabController,
tabs: const [Tab(text: 'Sliver 主方案'), Tab(text: '手工对照')],
),
),
body: TabBarView(
controller: _mainTabController,
children: [
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: 220,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(innerBoxIsScrolled ? '已折叠' : '展开中'),
),
),
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
pinned: true,
toolbarHeight: 0,
bottom: TabBar(
controller: _innerTabController,
tabs: const [Tab(text: '推荐'), Tab(text: '最新')],
),
),
),
],
body: TabBarView(
controller: _innerTabController,
children: [
_innerList('推荐'),
_innerList('最新'),
],
),
),
_manualNotificationDemo(),
],
),
);
}
Widget _innerList(String label) {
return Builder(
builder: (context) => CustomScrollView(
key: PageStorageKey<String>(label),
slivers: [
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('$label #$index')),
childCount: 30,
),
),
],
),
);
}
}观察重点
| 信号 | 含义 | 误读 |
|---|---|---|
innerBoxIsScrolled | 折叠头是否已让出主滚动区,内层列表开始接力 | 不等于「列表滚到顶部」 |
SliverOverlapInjector 首项位置 | TabBar 占位是否与内层列表对齐 | 漏加会导致首项被挡或跳动 |
PageStorageKey | 各 Tab 滚动偏移是否独立保留 | 共用 key 会导致 Tab 切换丢位置 |
手工 NotificationListener 对照 | 简单 offset 联动可行,但 Tab + fling 易脆 | 不能替代 NestedScrollView 协议 |
速记
- 嵌套滚动 = 位移消费协商,不是多套滚动容器
- 先分清 pre-scroll / child scroll / post-scroll
- 头部折叠、BottomSheet、刷新头都是同一类问题
- Compose 重在消费链,View 重在 behavior,Flutter 重在 Sliver
- 高频滚动量局部读,低频结构态派生读
- 能用单一列表或结构化 Sliver 解决,就别默认上手工监听