Skip to content

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-demoAndroidNestedScrollCoordinationActivity.kt
Flutter 滚动协同(Sliver + 手工联动)flutter-nested-scroll-coordinationflutter_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 即可。
  • 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 分别是怎么做的 ​

体系核心机制原理重心真正难点
ComposenestedScroll() + NestedScrollConnection显式决定每一段位移吃多少高频状态读取与 UI 分层
Android ViewNestedScrollingParent/Child、CoordinatorLayout、AppBarLayout.Behavior容器行为决定子 View 如何响应滚动调试成本高、行为链隐式
FlutterNestedScrollView、SliverAppBar、ScrollController、NotificationListenerouter/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 ComposeAndroid View 体系FlutteriOS / SwiftUIWeb
核心问题父子共享滚动位移怎么协商CoordinatorLayout / nested scrolling 接口怎么协商NestedScrollView / Sliver 怎么协同ScrollView 位移读取与 UIKit 委托如何分工overflow 容器与 sticky/scroll listener 如何配合
主要机制nestedScroll() + NestedScrollConnectionCoordinatorLayout / AppBarLayout / BehaviorNestedScrollView / CustomScrollView / SliverAppBarGeometryReader / 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-demoAndroid Compose 折叠头部 + Android View CoordinatorLayout 对照AndroidNestedScrollCoordinationActivity.kt
flutter-nested-scroll-coordinationFlutter NestedScrollView + SliverAppBar 主方案与手工 controller 协同对照flutter_nested_scroll_coordination_page.dart

复习检查题 ​

  1. 为什么说嵌套滚动的核心不是“双层滚动”,而是“滚动位移消费链”?

    答:因为真正要解决的是同一段手势位移如何在父子节点之间分配,而不是单纯多放几个可滚动容器;没有消费协商时就会出现抢手势和行为错乱。

  2. onPreScroll() 和 onPostScroll() 的职责差异是什么?

    答:onPreScroll() 发生在子节点消费前,适合父级先吃一部分位移;onPostScroll() 发生在子节点消费后,适合父级接手剩余位移或边界补处理。

  3. 为什么滚动偏移这类状态特别容易引发性能问题?

    答:因为它是高频连续变化量,若在 Composition 顶层被大范围读取,会导致整块 UI 高频重组;应下沉到局部读取并用 derivedStateOf 派生低频状态。

  4. 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。
  • 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 解决,就别默认上手工监听

站点构建时间:2026/8/24 23:43:17