1. 引言
上节《三十四、玩转Flutter手势机制✋》扒完 Flutter 的 "手势机制 ",有点意犹未尽,那就趁热打铁,本节来把 "滑动机制" 也冲了 🔥

✨"滑动机制 " 的出现是为了解决 "有限区域 " 显示 "超量/无限内容" 的问题。
🤔 打个比方:
想象下你面前有一幅延绵百米的巨型壁画,你的眼睛一次只能看到笔画的一部分 (如正前方2米宽的区域),这个"眼睛能看到的区域 " 可以称作你的 "视野窗口 (Viewport)",当你左右移动身体,"视口" 也会随之移动,让你看到壁画的其它部分。
😶 贴近现实,现代数字内容的丰富性 (如长文本、图片列表、网页、数据表格等) 远超 屏幕的物理显示范围 ,此时,用户也需要一种 "移动视口 " 的方式,以访问被屏幕边缘遮挡的内容。💁♂️ "滑动机制 " 应运而生,其本质是通过 用户交互 (如手指拖动、鼠标滚轮、触控板手势) 改变内容在屏幕上的显示位置 (视口偏移 ),从而让用户逐步 "扫过" 超量内容。技术实现通常分为这三个部分:
- 内容容器与视口分离 :将内容渲染在一个更大的 "虚拟画布" 中,屏幕仅显示其中一部分。
- 输入事件驱动偏移 :用户通过 滑动手势 触发系统计算位移量,调整内容容器的位置 (如向上偏移100像素),使得原本被遮挡的内容进入视口。
- 边界反馈:当内容已完全显示 (到达顶部/底部) 时,通过阻力反馈或动画提示用户"无更多内容"。
😄 而在 Flutter 中,视口 (Viewport) 的概念不在局限于屏幕级别的显示区域,而是扩展到任意一个 "构建其可视范围内子项的可滚动区域 "。为此,Flutter 设计了一套 "滑动处理机制",其中的重要参与者:
Scrollable -专注交互,监听手势并驱动滚动、Viewport -管理并提供可见视口,Slivers-提供内容,在Viewport按需渲染自己的一部分。
这种清晰的职责划分 (分层设计) 使得 Flutter 在实现无限列表、复杂联动特效时,仍能够保持出色的性能与强大的灵活性。

😏 本节就来系统学习下 Flutter 的这套 "滑动机制 ",依旧是 "概念名词+API详解+源码剖析" 的叙述套路~
2. 三大核心构件
💡 这部分涉及到比较多的 API,可以选择性阅读 (跳着看),大概知道是都是干嘛的,用到再查也可以。🤔对 "Sliver协议的工作流程" 建立一个基础认知,在后面分析各种具体滚动视图时会更加得心应手。
2.1. Scrollable - 滑动控制器
所有 可滚动组件 的 "基石 ",作用是将 "用户的输入 (手势) 转化为滚动的视觉变化",具体功能:
- 手势识别 :通过内置的 RawGestureDetector,识别垂直或水平方向的拖拽手势。
- 滚动物理模拟 :通过 ScrollPhysics ,定义了滚动的 "感觉",比如:滚动到边缘时的效果 (Android-蓝色辉光-ClampingScrollPhysics、iOS-回弹-BouncingScrollPhysics)、滑动停止时的惯性动画 (Fling)。
- 状态管理 :管理滚动的核心状态,如:当前滚动位置 (pixel)、滚动范围 (min/max scroll extent)。这个状态由一个叫做 ScrollPosition 的对象维护。
- 外部控制接口 :通过 ScrollController,允许开发者从外部读取滚动位置、监听滚动事件或命令式地控制滚动 (如跳转到指定位置、执行动画)。
- 构建视口 :Scrollable 本身不渲染任何可滚动的内容。它通过一个名为 viewportBuilder 的回调函数,将滚动的能力 (ViewportOffset) "嫁接" 给一个负责渲染部分内容的 Viewport 组件。
2.1.1. 属性/方法
构造方法:
dart
class Scrollable extends StatefulWidget {
const Scrollable({
super.key,
this.axisDirection = AxisDirection.down, // 滚动方向
this.controller, // 滚动控制器
this.physics, // 滚动物理效果
required this.viewportBuilder, // 视口构建器(必需)
this.incrementCalculator, // 增量计算器,
this.excludeFromSemantics = false, // 是否在语义树 (用于辅助功能,如屏幕阅读器) 中可见。
this.semanticChildCount, // 向辅助功能提供一个提示,告知总共有多少个子项。
this.dragStartBehavior = DragStartBehavior.start, // 拖拽开始行为
this.restorationId, // 恢复ID
this.scrollBehavior, // 滚动行为
this.clipBehavior = Clip.hardEdge, // 裁剪行为
})
}
参数详解:
① axisDirection :AxisDirection
定义了【滚动轴的方向】& ScrollPosition 的 pixel 为 0 时,内容所处的位置,可选值:
- down:垂直方向,内容从上到下排列 (0.0在顶部)
- up:垂直方向,内容从下到上排列 (0.0在底部)
- right::水平方向,内容从左到右排列 (0.0在左侧)
- left:水平方向,内容从右到左排列 (0.0在右侧)
② physics :ScrollPhysics?
定义了【组件滚动时的物理特性 】,决定了滚动时的"手感",如果为null,Scrollable 会通过 ScrollConfiguration.of(context) 获取一个平台默认的 ScrollPhysics 。Android 上是ClampingScrollPhysics (边界钳制,有辉光),在 iOS 上是 BouncingScrollPhysics (边界回弹)。常见的还有:NeverScrollableScrollPhysics (禁止滚动)、AlwaysScrollableScrollPhysics (内容不足一屏也可滚动)。
③ incrementCalculator :ScrollIncrementCalculator?
用于计算非指针(如键盘箭头、鼠标滚轮)滚动事件的滚动增量。通常无需关注此参数,框架有默认实现,在需要自定义键盘/滚轮滚动行为时才使用。
④ dragStartBehavior :DragStartBehavior
定义了【拖拽开始行为】决定滚动操作何时开始被识别。
- start:默认值,用户的手指按下并移动了一段微小的距离后才会被识别为滚动开始。
- down:用户手指按下并开始移动的瞬间就被立即识别为滚动,除非有极致及时反馈的交互时才设置,否则建议还是保持默认,以保证最佳和最符合预期的用户体验。
⑤ clipBehavior :Clip
定义了【如何对超出边界的内容进行裁剪】,可选值:
- hardEdge:默认,以最快的方式裁剪掉超出边界的内容。裁剪的边缘是硬的,可能会有锯齿,但GPU负载最低,性能最好,特别适合滚动视图是矩形且没特殊视觉效果的场景。
- antiAlias:裁剪内容,并对裁剪的边缘进行抗锯齿处理,使其看起来更平滑。视觉效果更好,特别是当滚动视图有圆角时,可以避免边缘的锯齿感。性能开销比 hardEdge 稍高。
- antiAliasWithSaveLayer:使用最高质量的抗锯齿裁剪,但也是性能开销最大的。它会创建一个临时的离屏缓冲区 (save layer) 来执行裁剪操作。能处理复杂的裁剪场景,提供最平滑、最准确的视觉效果。但严重影响性能。只有当Clip.antiAlias 仍然出现视觉问题 (如复杂的透明度和变换组合下) 时,才作为最后的手段使用。
- none: 完全不裁剪,内容可以绘制到滚动视图的边界之外。 性能最差,因为它可能需要绘制更多内容,而且,溢出的内容可以会覆盖页面上其它UI元素,导致混乱的视觉布局。
⑥ restorationId :String?
用于【为滚动视图提供一个唯一的ID 】以便在应用被系统杀死并恢复后,能够自动恢复其滚动位置。属于 Flutter 状态恢复 (State Restoration) 框架的一部分。应用场景:包含长内容页面 (如文章),当应用被挂起时 RestorationManager 会找到所有带 restorationId 的 Widget,并向它们请求需要保存的数据,(对于滚动视图,就是当前的 scrollOffset )。这些数据被保存到系统中,当应用恢复时,RestorationManager 会找到具体相同 restorationId 的Widget,并将保存的数据交还给它,使其能够恢复到之前的状态 (即滚动到之前的位置)。
⑦ viewportBuilder :ViewportBuilder
这是 ViewportBuilder 的定义代码:
dart
typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);
这个参数是【构建Viewport的回调 】,Scrollable.build() 时会创建一个 ViewportOffset 对象 (实际上是 ScrollPosition 的实例),并将其作为 参数 传递到这个回调中,回调执行后返回 Viewport 或其它自定义组件,需要通过这个 position 参数来决定其它子组件 (通常是 Sliver) 的布局偏移。
⑧ controller :ScrollController?
可选的【外部滚动控制器 】继承 ChangeNotifier ,可以通过它来监听滚动位置、驱动滚动。多个 Scrollable 还可以共享 同一个 controller 来实现同步滚动效果。不传这个参数 (null ),Scrollable 会在内部自动创建一个 ScrollController ,如果自己创建了 ScrollController ,记得在 State 的 dispose() 中销毁它。它的构造函数:
dart
class ScrollController extends ChangeNotifier {
ScrollController({
double initialScrollOffset = 0.0, // 初始滚动偏移量,默认为 0.0
this.keepScrollOffset = true,
this.debugLabel,
this.onAttach,
this.onDetach,
})
}
typedef ScrollControllerCallback = void Function(ScrollPosition position);
参数详解:
- keepScrollOffset :是否使用 PageStorage 保存滚动位置,默认true, 当 ScrollController 被附加到多个滚动视图时非常有用,它决定了控制器是否应该在切换附加对象时,尝试保持当前的 scrollOffset。
- onAttach :ScrollControllerCallback? ,当一个 ScrollPosition 被附加到 ScrollController 时会触发这个回调,可在回调中可以获得刚附加的 ScrollPosition对象,进行一些初始化操作或记录。
- onDetach:ScrollControllerCallback? ,当一个 ScrollPosition 从 ScrollController 中分离时会触发这个回调。可在回调中可以获得即将被分离的 ScrollPosition对象,进行一些清理或记录。
属性/方法:
dart
// 当前附加的所有 ScrollPosition 对象
Iterable<ScrollPosition> get positions => _positions;
// 是否有附加的滚动视图
bool get hasClients => _positions.isNotEmpty;
// 获取唯一的 ScrollPosition(仅在单个视图时使用)
ScrollPosition get position {return _positions.single}
// 当前滚动偏移量 (滚了多少像素)
double get offset => position.pixels;
// ✨ 滚动控制方法
// 动画滚动到指定位置,参数:偏移量、动画时长和曲线
// 顶部 (position为0),底部 (最大滚动距离-_scrollController.position.maxScrollExtent)
Future<void> animateTo(double offset, {
required Duration duration,
required Curve curve,
});
// 立即跳转到指定位置 (没有动画效果)
void jumpTo(double value);
// ✨ 添加滚动监听
_scrollController.addListener(() {
print('offset: ${_scrollController.position.pixels}');
});
💡 注:要精确地判断滑动状态,推荐使用 NotificationListener ,它比 ScrollController 的 addListener 提供了更丰富、更具体的事件信息,如滚动停止。另外,上拉加载更多 (滑动到底部,用户还往上拉),常见的错误做法:在 addListener 里判断 position.pixels == position.maxScrollExtent ,这只在到达底部时触发一次,而不是在到达底部后继续拉动时触发。正确的做法是:监听 OverscrollNotification ,当用户试图滚动超过 maxScrollExtent 时,就会触发这个通知。
ScrollController 内部维护了一个 ScrollPosition 列表 _positions ,ScrollPosition 存储了 "单个滚动视图 " 的 "状态信息 & 控制逻辑"。
dart
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
required this.physics, // 滚动的物理模拟效果
required this.context, // 滚动上下文
this.keepScrollOffset = true, // 是否通过 PageStorage 保存和恢复滚动位置
ScrollPosition? oldPosition, // 用于在 Widget 重建时迁移状态
String? debugLabel, // 调试标签
}
参数详解:
- context :ScrollContext ,充当 Scrollable 和 ScrollPosition 间的桥梁,为滚动操作提供必要的 上下文信息 。包括:分发ScrollNotification -notificationContext、搜索PageStorage -storageContext、动画支持 -vsync、滚动方向 -axisDirection、设备像素比 -devicePixelRatio。除此之外,还提供了交互控制相关的方法:是否忽略指针事件 -setIgnorePointer()、是否可以拖拽 -setCanDrag()、setPixels (double offset)-当 ScrollPosition 内部的 pixels 值改变时,用它来通知 Viewport 更新其偏移量,从而真正移动视图。
- oldPosition :ScrollPosition? ,当 Scrollable 的配置发生变化 (如ScrollController 或 ScrollPhysics 被替换) 时,ScrollableState 会创建一个新的 ScrollPosition,oldPosition 参数允许新 Position 从 旧 Position "吸收(absorb) " 状态,如当前的 pixels值,从而实现平滑过渡。
属性/方法:
dart
// ================ 🔔 存储滚动核心数据 ================
double pixels: 最核心的属性。表示当前滚动位置距离滚动起点的偏移量(逻辑像素)。
double minScrollExtent: 最小滚动范围。通常是 0.0。
double maxScrollExtent: 最大滚动范围。它由 (内容总高度 - 视口高度) 计算得出。
double viewportDimension: 视口(可见区域)在滚动轴上的尺寸。例如,对于一个垂直滚动的 ListView,这就是它的高度。
double extentBefore: 在视口之前(上方或左方)的不可见区域的长度。等于 pixels - minScrollExtent。
double extentInside: 在视口内部的区域长度,就是 viewportDimension。
double extentAfter: 在视口之后(下方或右方)的不可见区域的长度。
bool outOfRange: 当前 pixels 是否超出了 minScrollExtent 和 maxScrollExtent 的范围。
bool atEdge: 当前 pixels 是否正好等于 minScrollExtent 或 maxScrollExtent。
Axis axis: 滚动的轴(Axis.vertical 或 Axis.horizontal)。
- ScrollDirection userScrollDirection: 用户最近一次的滚动方向。有四个值:
- ScrollDirection.idle: 静止。
- ScrollDirection.forward: 向前滚动(例如,在垂直列表中向上滚动,内容向下移动)。
- ScrollDirection.reverse: 向后滚动(例如,在垂直列表中向下滚动,内容向上移动)。
- 这个属性对于实现"滚动时隐藏/显示AppBar或FAB"等效果至关重要。
ScrollActivity activity: 内部状态机。表示当前滚动正在进行的"活动"。它是一个抽象类,常见的实现有:
- IdleScrollActivity: 静止状态。
- DragScrollActivity: 用户手指按住并拖动时的活动。
- BallisticScrollActivity: 用户手指抬起后,列表根据速度进行惯性滚动的活动。
// ================ 🎪 核心滚动操作 ================
jumpTo(double value): 瞬间将滚动位置改变到指定值,不带动画。
animateTo(double to, ...): 以动画方式将滚动位置平滑移动到指定值。
moveTo(double to, ...): jumpTo 和 animateTo 的内部实现,负责应用新的像素值。
correctBy(double correction): 当滚动位置不正确时(例如,子元素增删导致 maxScrollExtent 变化),用于修正 pixels。
applyNewDimensions(): 当视口或内容尺寸变化时,被调用来更新 min/maxScrollExtent 等维度信息,并可能修正 pixels。
// ================ 🎪 驱动滚动物理和动画 ================
它内部持有一个 ScrollPhysics 对象,用于决定滚动的物理行为(如在滚动到边缘时是阻尼效果还是回弹效果)。
它使用 Ticker 来驱动滚动动画,如用户手指离开屏幕后的惯性滚动(Fling/Ballistic)。
它定义了如 goIdle()、goBallistic(double velocity) 等方法来控制滚动的动态行为。
// ================ 🎪 通知机制 ================
// 它继承自 ChangeNotifier,当 pixels 发生变化时,
// 会调用 notifyListeners()。ScrollController 就是通过监听这个通知来得知滚动位置的变化。
// 它负责向上冒泡派发 ScrollNotification
// (如 ScrollStartNotification, ScrollUpdateNotification, ScrollEndNotification 等),
// 让父组件(如 NotificationListener)可以监听到滚动事件。
// 💡 可以调用 controller.positions 属性来访问这个ScrollPosition列表
// 当控制器只附加到一个滚动视图上时,你可以直接调用 controller.offset 来获取ScrollPosition对象
// 实际上返回的:controller.position.pixels。
Scrollable 里还有一个 scrollBehavior 属性,也顺带提一嘴。ScrollBehavior 用于【统一整个App或子树中所有滚动组件的默认行为 】。当一个 可滚动的Widget 被创建时,它会调用 ScrollConfiguration.of() 向上查找离它最近的 ScrollConfiguration ,并返回其 behavior 对象,Scrollable 根据它来配置自己的滚动物理效果、滚动条演示、越界指示器、指针设备交互等。构造方法:
dart
// 构造方法,没有任何参数,创建的是一个不可变(immutable)的对象。
ScrollBehavior copyWith({
bool? scrollbars, // 是否显示滚动条
bool? overscroll, // 是否显示过度滚动效果
Set<PointerDeviceKind>? dragDevices, // 支持拖拽的设备类型
MultitouchDragStrategy? multitouchDragStrategy, // 多点触控策略
Set<LogicalKeyboardKey>? pointerAxisModifiers, // 滚动轴修饰键
ScrollPhysics? physics, // 滚动物理特性
TargetPlatform? platform, // 目标平台
})
2.1.2. ScrollableState
Scrollable 的核心逻辑都在 ScrollableState 中,内部维护一个 ScrollPosition 类型的 _position 属性,所有对滚动的操作,最终都作用于这个属性。ScrollPosition 是抽象类,在 ScrollableState 里的具体实现是 ScrollPositionWithSingleContext,核心实现:
- 与 Scrollable 建立连接 :"Single Context " 指的是它持有一个 "ScrollableState" 引用,通过它可以获取到:ScrollPhysics、BuildContext、TickerProvider 及 axisDirection。
- 与 Viewport 通信:applyNewDimensions() 的具体实现就是通过 ScrollableState 找到对应的 Viewport,并从 Viewport 获取 minScrollExtent/maxScrollExtent,这是 ScrollPosition 获得滚动范围信息的关键。
- 处理用户输入 :实现了 beginActivity() 和 applyUserOffset() 等方法,当用户在 Scrollable 上拖动时,Scrollable 会将 拖动偏移量(delta) 传递给 applyUserOffset ,然后 ScrollPositionWithSingleContext 会根据 ScrollPhysics 的规则更新 pixels 。并且在用户开始拖动时会创建一个 DragScrollActivity 来响应用手操作。当用户松开手指时,调用 goBallistic() 来启动一个 BallisticScrollActivity,实现惯性滚动。
- 管理生命周期 :当一个 ScrollController 被附加到 Scrollable 上时,ScrollableState 会调用 attach() ,将自己作为参数传递给 ScrollPosition,从而完成绑定,解绑时则调用 death()。
ScrollPositionWithSingleContext 是实际工作的 滚动引擎 ,连接 ScrollController的意图 和 Scrollable视图表现 的桥梁与执行者。ScrollableState 的核心方法如下:
dart
class ScrollableState extends State<Scrollable> with , RestorationMixin
implements ScrollContext {
// 类似于 Theme.of(context)。它允许子组件沿着组件树向上查找到最近的 ScrollableState 实例
static ScrollableState? of(BuildContext context):
// 手势识别器 (DragGestureRecognizer) 的回调,当手势发生时,这些方法被调用,然后它们会调用
// position.drag() 和 position.dragEnd(),将手势的细节传递给 ScrollPosition。
_handleDragStart, _handleDragUpdate, _handleDragEnd
// 用户如果没提供controller,创建一个备用的ScrollController实例
initState()
// 调 _updatePosition() → controller.createScrollPosition() 创建
// ScrollPositionWithSingleContext 实例。controller.attach(position)。
didChangeDependencies()
// 构建Viewport
Widget build(BuildContext context){
// 套_ScrollableScope (继承InheritedWidget,便于实现 Scrollable.of() 查找 ScrollableState)
Widget result = _ScrollableScope(
scrollable: this,
position: position,
// 套Listener-监听指针信号、套 RawGestureDetector-手势识别
child: Listener(
onPointerSignal: _receivedPointerSignal,
child: RawGestureDetector(
gestures: _gestureRecognizers,
child: Semantics(
// 套IgnorePointer-忽略指针事件让其直接穿透到下层
child: IgnorePointer(
ignoring: _shouldIgnorePointer,
child: widget.viewportBuilder(context, position), // ✨ 实际内容视口
),
),
),
),
);
result = _buildChrome(context, result); // 添加滚动条、过度滚动指示器等
...
}
}
😄 在 ScrollableState.build() 中调用了 Scrollable 构造方法中传入的 viewportBuilder 回调,在创建 Viewport Widget 的同时传入了 position ,使得 Viewport 可以根据 position.pixels 来计算子元素的偏移量,从而实现滚动效果。梳理下方法调用的流程,先是 初始化 (选择 ScrollController → 配置 Physics → 处理 ScrollPostion):
dart
Widget 创建
↓
ScrollableState.initState()
↓
检查 widget.controller
├─ 如果为 null → 创建 _fallbackScrollController = ScrollController() // 第629行
└─ 如果不为 null → 使用提供的 controller
↓
super.initState()
↓
didChangeDependencies()
↓
获取 MediaQuery 设置和设备像素比
↓
_updatePosition()
↓
获取 ScrollConfiguration 和基础 ScrollPhysics
↓
检查自定义 physics
├─ 有 widget.physics → _physics = widget.physics!.applyTo(_physics)
├─ 有 widget.scrollBehavior → 应用 scrollBehavior 的 physics
└─ 都没有 → 使用默认 physics
↓
检查旧的 _position
├─ 存在旧 position → detach 旧 position → scheduleMicrotask 销毁
└─ 不存在 → 直接进入下一步
↓
_effectiveScrollController.createScrollPosition(_physics!, this, oldPosition)
↓
_effectiveScrollController.attach(position)
↓
初始化完成 ✅
用户手指拖动事件 (状态转换:空闲→Hold→Drag→结束,正常结束-可能惯性滚动、取消操作-立即停止)
dart
用户手指按下屏幕
↓
GestureDetector 识别触摸事件
↓
_handleDragDown()
↓
状态检查 assert(_drag == null && _hold == null)
↓
_hold = position.hold(_disposeHold) // 创建保持控制器
↓
停止当前滚动动画
↓
用户开始移动手指
↓
_handleDragStart()
↓
状态检查
├─ _hold 可能为 null(用户代码触发了其他活动)→ 直接返回
└─ _hold 存在 → 继续处理
↓
_drag = position.drag(details, _disposeDrag)
↓
_hold 自动变为 null(转换为拖拽状态)
↓
用户继续拖拽移动
↓
_handleDragUpdate() (持续调用)
↓
状态检查
├─ _drag 为 null(拖拽已结束)→ 不处理
└─ _drag 存在 → _drag.update(details)
↓
ScrollPosition.setPixels()
↓
应用 ScrollPhysics 约束
↓
更新滚动位置 + 发送 ScrollNotification
↓
触发 UI 重建
↓
检查用户操作
├─ 继续拖拽 → 回到 _handleDragUpdate()
├─ 松开手指 → _handleDragEnd()
└─ 取消操作 → _handleDragCancel()
松开手指分支:
_handleDragEnd()
↓
状态检查
├─ _drag 为 null → 不处理
└─ _drag 存在 → _drag.end(details)
↓
根据结束速度判断
├─ 速度足够大 → 开始惯性滚动 (BallisticScrollActivity)
└─ 速度不够 → 停止滚动 (IdleScrollActivity)
↓
_drag 变为 null
↓
拖拽流程完成 ✅
取消操作分支:
_handleDragCancel()
↓
检查 _gestureDetectorKey.currentContext
├─ 为 null(组件被销毁)→ 直接返回
└─ 存在 → 继续处理
↓
清理状态
├─ _hold?.cancel() → _hold = null
└─ _drag?.cancel() → _drag = null
↓
取消流程完成 ✅
数据流向:
dart
用户手指拖动
↓
DragUpdateDetails.delta (比如: Offset(0, -10))
↓
ScrollDragController.update()
↓
ScrollPosition.setPixels(oldPixels + delta)
↓
position.pixels 变化 (100.0 → 90.0)
↓
didUpdateScrollPositionBy(-10.0)
↓
dispatchScrollStartedNotification 发送通知
↓
notifyListeners 通知监听器,ViewportOffset 继承自 ChangeNotifier
↓
┌─────────────────────┬─────────────────────┬─────────────────────┐
│ Viewport重新布局 │ Scrollbar更新位置 │ 子组件可见性变化 │
└─────────────────────┴─────────────────────┴─────────────────────┘
↓ ↓ ↓
RenderSliver计算可见范围 滚动条thumb位置更新 Widget build/dispose
↓ ↓ ↓
子组件的renderObject更新 滚动条重绘 新的UI呈现给用户
2.2. Sliver - 滑动片段
"Sliver" 不是具体的类,而是一个协议/概念,它是 RenderSliver 和它的容器 (通常是 RenderViewport ) 间沟通的方式。这个协议主要由 两个核心数据结构 + performLayout() 构成。
2.2.1. 输入-SliverConstraints
RenderViewport (滚动视口) 传递给 RenderSliver 的 "布局约束信息 "。它告诉 Sliver:"这是你当前所处的环境,请根据这些信息结算你的布局"。SliverConstraints 的关键属性:
dart
// ================ 🔄 滚动相关 ================
// 当前滚动偏移量,Sliver 根据它来判断自己哪一部分是可见的。
final double scrollOffset;
// 前面所有 Sliver 消耗的滚动距离总和。用于计算当前 Sliver 在整个可滚动区域中的起始位置。
final double precedingScrollExtent;
// 剩余可绘制的像素数量。Sliver 应该根据这个值来决定绘制多少内容,不应该超过这个限制
final double remainingPaintExtent;
// 前一个 Sliver 重叠的像素数量,当前一个 Sliver 的 paintExtent > layoutExtent 时
// 会产生重叠,通常用于固定头部等效果。如:SliverAppBar 收起时会与列表重叠
final double overlap;
// ================ 🎯 视口和缓存相关 ================
// 视口在主轴方向上的像素数量,对垂直列表来说,就是视口的高度。
final double viewportMainAxisExtent;
// 缓存区域的起始位置,相对于 scrollOffset。总是负数或零,
// 表示需要在当前可见区域之前预渲染多少内容。
final double cacheOrigin;
// 剩余缓存区域的大小。Sliver 应该从 cacheOrigin 开始,
// 尽可能提供 remainingCacheExtent 范围内的内容以优化滚动性能。
final double remainingCacheExtent;
// ================ 🧭 坐标系统信息 ================
// 滚动方向,决定了 scrollOffset 和 remainingPaintExtent 的增长方向
final AxisDirection axisDirection;
// Sliver 内容的排列方向,相对于 axisDirection 而言,forward-相同,reverse反向
final GrowthDirection growthDirection;
// 用户滚动的方向,用于判断用户是在向前滚动还是向后滚动,某些Sliver(如浮动头部)会根据此信息调整行为
final ScrollDirection userScrollDirection;
// 交叉轴的可用空间。对于垂直列表来说就是宽度,对于水平列表来说就是高度。
final double crossAxisExtent;
// 交叉轴的方向。通常用于垂直列表中描述文字方向是从左到右还是从右到左。
final AxisDirection crossAxisDirection;
2.2.2. 输出-SliverGeometry
当 RenderSliver 的 performLayout() 被调用后,它必须计算并设置自己的 geometry 属性,这是它返回给 RenderViewport 的 "布局结果 "。SliverGeometry 的关键属性:
dart
class SliverGeometry {
// ================ 🎨 核心尺寸信息 ================
// Sliver 总的可滚动范围,表示用户需要滚动多少距离才能从这个 Sliver 的开始滚动到结束。
final double scrollExtent;
// 当前实际绘制的像素范围,表示这个 Sliver 在当前滚动位置下实际占用的可见区域大小。
final double paintExtent;
// 布局占用的空间大小,决定下一个 Sliver 的布局位置,默认等于 paintExtent。
// 当需要 "挤压"后续 Sliver 时会小于 paintExtent。
final double layoutExtent;
// 该 Sliver 能够绘制的最大范围。用于支持收缩包装的视口,
表示如果有无限空间时这个 Sliver 最多能绘制多大。
final double maxPaintExtent;
// 当 Sliver 被固定在边缘时,能够阻挡内容滚动的最大范围,应用栏就是最典型的例子。
final double maxScrollObstructionExtent;
// ================ 📦 位置和交互信息 ================
// 交叉轴占用的空间大小,如果为null,则使用约束中的 crossAxisExtent。
// 用于某些需要自定义交叉轴大小的 Sliver。
final double crossAxisExtent;
// 绘制起始位置的偏移量,如果 Sliver 想要在其布局位置之前开始绘制
// (如阴影效果),这个值就是负数
final double paintOrigin;
// 可以响应点击事件的范围,默认等于 paintExtent,但某些情况下
// 可能需要扩大或缩小点击区域。
final double hitTestExtent;
// 缓存区域消耗的大小。表示这个 Sliver 从剩余缓存区域中消耗了多少空间,用于优化滚动性能。
final double cacheExtent;
// ================ 🔧 状态信息 ================
// 该 Sliver 是否应该被绘制。默认情况下,paintExtent > 0 时为 true,否则为 false。
final bool visible;
// 是否有视觉溢出。如果为 true,视口需要对子组件进行裁剪以避免内容溢出到视口边界之外。
final bool hasVisualOverflow;
// 滚动偏移修正值。如果不为 null,父组件会调整滚动位置并重新布局。
// 用于处理滚动位置需要修正的特殊情况。
final double? scrollOffsetCorrection;
}
2.2.3. 协议流程总结
- RenderViewport 调 子RenderSliver.layout() ,并传入 SliverConstraints (输入)。
- RenderSliver.performLayout() 被触发,RenderSliver 的具体子类 (如RenderSliverList) 会根据 SliverConstraints 计算出自己需要展示哪些子元素、它们的位置,并最终计算出一个 SliverGeometry (输出)。
- RenderSliver 将计算好的 SliverGeometry 赋值给自己的 geometry 属性。
- RenderViewport 读取 geometry,从而知道这个 Sliver 占了多少空间、下一个 Sliver 应该从哪里开始布局等信息,然后继续布局下一个 Sliver。
RenderSliver子类.performLayout() 中进行 布局计算 的示例代码:
dart
void performLayout() {
// 1. 接收约束
final SliverConstraints constraints = this.constraints;
// 2. 分析滚动状态
final double scrollOffset = constraints.scrollOffset;
final double remainingExtent = constraints.remainingPaintExtent;
// 3. 计算内容布局
// ... 具体的布局逻辑 ...
// 4. 生成几何信息
geometry = SliverGeometry(
scrollExtent: totalContentHeight, // 总内容高度
paintExtent: visibleContentHeight, // 可见内容高度
layoutExtent: layoutContentHeight, // 布局影响高度
maxPaintExtent: maxContentHeight, // 最大绘制高度
hasVisualOverflow: hasOverflow, // 是否溢出
);
}
梳理下 RenderSliver 的子类们:

💡 Tips :Sliver 组件们:ListView → SliverList 、GridView → SliverGrid 、SliverToBoxAdapter (用于将一个普通Box组件进行Sliver适配)、SliverAppBar 、SliverPersistentHeader (吸顶头部)、SliverFillRemaining (填充视口剩余空间)、SliverPadding (为Sliver添加内边距)、SliverLayoutBuilder (可以根据 Sliver 的几何信息来构建其子组件) 等。
2.3. Viewport - 视口管理器
继承 MultiChildRenderObjectWidget (多子Widget),实现 "懒加载/按需渲染" 滚动视图的基石,主要职责:
- 管理可见区域显示 :根据自身尺寸和给定的偏移量显示子组件的子集,只渲染在视口范围内可见的 Sliver子组件,而不是全部内容。
- 协调滚动偏移 :接收并处理 ViewportOffset 传递的滚动偏移量信息,随着偏移量的变化,动态调整哪些子组件在视口中可见。
- 实现高效的布局算法 :采用视口感知的布局协议,向 Sliver子组件 传递约束信息,包含可见空间剩余量等视口相关信息,使子组件能够智能地决定渲染内容。
- 支持无限滚动机制:通过按需构建机制,只创建当前可见的 Widget,在布局阶段与构建阶段交错进行,实现高性能的无限列表。
- 处理不同类型的 Sliver 组合 :统一管理线性列表、网格、可折叠头部等不同类型的 Sliver,通过 Sliver布局协议 协调各种滚动效果,如视差滚动、折叠头部等。
- 维护渲染边界和裁剪:定义内容的可视边界,对超出视口的内容进行裁剪,管理重绘边界,优化渲染性能。
构造函数:
dart
Viewport({
super.key,
this.axisDirection = AxisDirection.down, // 主轴方向,决定了滚动的方向和布局的起点。
this.crossAxisDirection, // 交叉轴方向,它会影响子项在交叉轴上的布局顺序。
this.anchor = 0.0, // 锚点
required this.offset, // 滚动偏移控制器,通常由ScrollPosition实现。
this.center, // 中心子项的Key
this.cacheExtent, // 缓存区域大小
this.cacheExtentStyle = CacheExtentStyle.pixel, // 缓存区域计算方式
this.clipBehavior = Clip.hardEdge, // 超出Viewport边界内容的裁剪行为
List<Widget> slivers = const <Widget>[], // 子组件列表
})
属性详解:
- anchor : double,表示视口中的"零点" (scrollOffset为0.0的点) 在视口自身中的位置比例。0.0-视口的顶部(或左侧) 是滚动偏移的零点,当 scrollOffset 为 0 时,内容的开头对齐视口的开头。1.0-视口的底部 (或右侧) 是滚动偏移的零点。0.5-视口的中心是滚动偏移的零点。anchor 的改变会影响内容如何从 center key 开始向两侧填充。对于反向列表(reverse:true),ListView会将其设置为1.0。
- center :Key?, 中心子项的Key ,这是一个优化参数。当 Viewport 首次布局时,它会尝试找到这个 Key 对应的 Sliver,并假定它位于 scrollOffset 为 0.0 的位置。这主要有两个用途:快速定位 -在拥有大量数据时,可以快速定位到初始显示位置,而无需从头开始构建。布局稳定性-当 Viewport 的尺寸变化时 (如屏幕旋转) 通过 center key 可以保持同一个子项在视口中的相对位置,防止列表"跳动"。
- cacheExtent:doube?,指定在视口可见区域之外,上下(或左右) 应该预先构建和布局的区域大小。如:如果视口高度为 600px,cacheExtent 为 200px,那么系统会渲染从 -200px 到 800px 这个范围内的列表项。
- cacheExtentStyle :CacheExtentStyle ,缓存单位,pixel -默认,逻辑像素,viewport-视口大小的倍数,如:cacheExtent 为 1.0 意味着在视口上方和下方各缓存一个视口高度的区域。
- slivers :List,只能放 Sliver 类型的 Widget, 如:SliverList、SliverGrid、SliverAppBar, SliverToBoxAdapter 等。
核心方法:
dart
// Widget 层和 RenderObject 层的连接点。Viewport Widget
// 调用此方法来创建一个 RenderViewport 实例,并将构造函数中的所有参数传递给它。
createRenderObject(BuildContext context)
// 当 Viewport Widget 的配置发生变化时(例如 axisDirection 改变),此方法会被调用,
// 用新的配置去更新已存在的 RenderViewport 对象。
updateRenderObject(BuildContext context, RenderViewport renderObject)
// 创建 MultiChildRenderObjectElement,这是 Widget 在元素树中的表示。
createElement()
Viewport 在 渲染树 中有两个主要实现:
- RenderViewport :标准视口 RenderObject,会扩展填充整个主轴空间。
- RenderShrinkWrappingViewport :收缩包装视口 RenderObject,会根据其子组件在主轴上的大小来调整自身大小。
dart
// 主轴上占据所有空间
RenderViewport extends RenderViewportBase<SliverPhysicalContainerParentData>{}
abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMixin<RenderSliver>>
extends RenderBox with ContainerRenderObjectMixin<RenderSliver, ParentDataClass>
implements RenderAbstractViewport { ... }
// 主轴上所有以包裹其内容 (shrinkWrap: true)
class RenderShrinkWrappingViewport extends RenderViewportBase<SliverLogicalContainerParentData> { ... }
父类 RenderViewportBase 继承了 RenderBox ,这表明 RenderViewport 自身遵循 盒子模型 (父节点会给他一个 BoxConstraints ,它必须在此约束下确定自己的size),而管理的子节点列表则必须是 RenderSliver 类型,😄 可以把 RenderViewport 看作是 盒子协议 & Sliver协议 间的桥梁。具体的方法调用链路:
dart
// ================ 🚀 初始化 ================
RenderViewport 构造函数
↓
调用父类 RenderViewportBase 构造
├─ 设置 axisDirection 和 crossAxisDirection // 设置主轴和交叉轴方向
├─ 绑定 ViewportOffset // 绑定滚动偏移控制器
└─ 初始化 cacheExtent 和 clipBehavior // 设置缓存范围和裁剪行为
↓
设置 RenderViewport 特有属性
├─ _anchor = anchor // 设置锚点位置(0.0-1.0)
└─ _center = center // 设置中心sliver
↓
addAll(children) // 批量添加子元素
└─ adoptChild() // 为每个子元素建立父子关系
├─ setupParentData() // 设置 SliverPhysicalContainerParentData
├─ attach() // 将子元素附加到渲染管道
└─ markNeedsLayout() // 标记需要重新布局
↓
设置默认center
├─ center == null && firstChild != null → _center = firstChild
└─ 否则 → 保持原有center设置
attach() // 将视口附加到渲染管道
├─ super.attach(owner) // 调用父类attach方法
├─ _offset.addListener(markNeedsLayout) // 监听滚动偏移变化
└─ 递归调用子元素attach() // 确保所有子元素都正确附加
// ================ 📦 布局 ================
performLayout() // 布局入口
↓
应用视口尺寸到偏移控制器
├─ axis == Axis.vertical → offset.applyViewportDimension(size.height)
└─ axis == Axis.horizontal → offset.applyViewportDimension(size.width)
↓
检查是否有子元素
├─ center == null → 设置滚动范围为0并返回
│ ├─ _minScrollExtent = 0.0
│ ├─ _maxScrollExtent = 0.0
│ ├─ _hasVisualOverflow = false
│ └─ offset.applyContentDimensions(0.0, 0.0)
└─ 有子元素 → 继续布局流程
↓
计算布局参数
├─ (mainAxisExtent, crossAxisExtent) = switch (axis) // 计算主轴和交叉轴尺寸
├─ centerOffsetAdjustment = center!.centerOffsetAdjustment // 获取中心偏移调整
└─ maxLayoutCycles = _maxLayoutCyclesPerChild * childCount // 设置最大布局循环次数
↓
执行布局循环
├─ _attemptLayout() // 尝试布局所有子元素
│ ├─ 重置布局数据
│ │ ├─ _minScrollExtent = 0.0
│ │ ├─ _maxScrollExtent = 0.0
│ │ └─ _hasVisualOverflow = false
│ ├─ 计算中心偏移和绘制范围
│ │ ├─ centerOffset = mainAxisExtent * anchor - correctedOffset
│ │ ├─ reverseDirectionRemainingPaintExtent = clampDouble(centerOffset, 0.0, mainAxisExtent)
│ │ └─ forwardDirectionRemainingPaintExtent = clampDouble(mainAxisExtent - centerOffset, 0.0, mainAxisExtent)
│ ├─ 计算缓存范围
│ │ ├─ _calculatedCacheExtent = switch (cacheExtentStyle) // 根据缓存样式计算实际缓存大小
│ │ └─ 计算各方向的缓存范围
│ ├─ layoutChildSequence(反向子元素) // 布局center之前的子元素
│ │ ├─ child.layout(SliverConstraints) // 为每个子sliver执行布局
│ │ ├─ updateChildLayoutOffset() // 更新子元素的布局偏移
│ │ │ └─ childParentData.paintOffset = computeAbsolutePaintOffset()
│ │ └─ updateOutOfBandData() // 更新滚动范围等额外数据
│ │ ├─ GrowthDirection.reverse → _minScrollExtent -= scrollExtent
│ │ └─ hasVisualOverflow → _hasVisualOverflow = true
│ └─ layoutChildSequence (正向子元素) // 布局center及之后的子元素
│ ├─ child.layout(SliverConstraints) // ✨ 给每个子 Sliver下发约束,执行布局
│ ├─ SliverGeometry childLayoutGeometry = child.geometry! // ✨ 获得Sliver 返回的几何信息
│ ├─ updateChildLayoutOffset() // 记录每个 Sliver 的绘制位置
│ └─ updateOutOfBandData() // 基于每个 Sliver 的反馈更新全局信息
│ ├─ GrowthDirection.forward → _maxScrollExtent += scrollExtent // 累计最大滚动范围
│ └─ hasVisualOverflow → _hasVisualOverflow = true // 判断是否有溢出
├─ correction != 0.0 → offset.correctBy(correction) // 修正滚动偏移量
└─ correction == 0.0 → offset.applyContentDimensions() // 应用最终的内容尺寸范围
// ================ 🎨 绘制 ================
paint() // 绘制入口
↓
检查是否有子元素
├─ firstChild == null → 直接返回
└─ 有子元素 → 继续绘制流程
↓
检查是否需要内容裁剪
├─ hasVisualOverflow && clipBehavior != Clip.none → 创建裁剪区域
│ └─ _clipRectLayer.layer = context.pushClipRect() // 创建裁剪图层
│ ├─ needsCompositing // 检查是否需要合成
│ ├─ Offset.zero & size // 设置裁剪矩形
│ ├─ _paintContents // 绘制内容回调
│ └─ oldLayer: _clipRectLayer.layer // 复用旧图层
└─ 无溢出或不裁剪 → 直接绘制内容
├─ _clipRectLayer.layer = null // 清理裁剪图层
└─ _paintContents(context, offset) // 直接绘制内容
↓
_paintContents() // 绘制所有可见内容
└─ 遍历 childrenInPaintOrder // 按绘制顺序处理子元素
├─ child.geometry!.visible → 检查子元素是否可见
└─ context.paintChild(child, offset + paintOffsetOf(child)) // 绘制每个可见的子元素
├─ paintOffsetOf(child) // 获取子元素绘制偏移
│ └─ return childParentData.paintOffset
└─ 应用变换矩阵并绘制
😶 老规矩画个图帮助理解:

😏 原理学完,动手缝合下三个构件,实现一个 最简单 的滑动效果【--->c35/simple_scroll_demo.dart<---】:

运行效果:

😄 是的,就是这么简单,关于Flutter滑动机制 "三个核心构件" 就了解到这,接着开始学习具体的滑动组件。
3. 常用滑动组件
3.1. SingleChildScrollView
简介:
一个用于解决 "内容溢出 " 问题的简单 滚动容器Widget ,可以让 单个子Widget 在空间不足时进行滚动。
3.1.1. API 详解
继承 StatelessWidget ,大部分属性在 Scrollable 那里已经详细讲了,不再赘述,挑几个没讲到的:
- padding :EdgeInsetsGeometry? ,在滚动区域内部添加内边距 (child外边),边距会随着内容一起滚动。
- keyboardDismissBehavior :ScrollViewKeyboardDismissBehavior ,用户与滚动区域交互时,如何以及何时自动收起弹出的键盘。【manual -默认】滚动视图本身不会做任何事情来收起键盘,键盘的收起完全依赖于其他方式,如:回退键、FocusScope.of(context).unfocus() 等。【onDrag 】当键盘弹出时,用户在滚动视图上开始拖动 (滚动) 的那一刻,键盘就会自动收起 ✨。
- primary :bool? ,是否使用主滚动控制器,默认null,由系统自动根据上下文自动选择最合适的控制器。为 true 时,使用 主滚动控制器-PrimaryScrollController (不能同时设置自定义controller),🤔 用于页面级别的滚动,需要与其它组件共享滚动状态,在移动平台上它会自动处理一些系统级的交互。如:Android 从屏幕边缘拖动可以触发返回操作,主滚动视图会优先响应滚动。在 iOS 上,点击状态栏可以快速滚动到顶部。在Scaffold中,如果body是一个可滚动组件,当键盘弹出时,会调整滚动区域以保证焦点输入框可见。为 false 时,当一个页面有多个滚动视图时,只能有一个可以是primary,其它都应该显示设置为false。一般用于独立的滚动区域:如对话框、侧边栏。
简单使用示例【--->c35/single_child_scroll_view_demo.dart<---】运行效果:

😄 非常简单,就切滚动物理效果、设置键盘随列表滚动消失、以及快速滑动到底部、中部和顶部。接着提下使用 SingleChildScrollView 的 两个注意事项:
① 与Column 配合使用的冲突
Column 试图占用尽可能多的空间,而 SingleChildScrollView 提供无限空间,这会导致冲突,需要对Column 进行高度约束。可以使用 LayoutBuilder + ConstrainedBox 设置最小高度,或者使用IntrinsicHeight 强制 Column 适应内容大小。
② 加载机制
SingleChildScrollView 会一次性将它的 child 全部渲染到内存中 ,而不管这个 child 有多大,它只是在 "视口 " 中移动显示。它更适合处理 内容相对固定且不太多 的场景,对于 大量动态内容 ,还是得选择具有 "懒加载 " 特性的滚动组件 (如 Listview ),混合布局可以考虑用 CustomScrollView。
3.1.2. 源码剖析
关于第一个注意事项 "SingleChildScrollView 提供无限空间",在源码中的体现 (移除了滚动方向的尺寸约束):



接着是第二个 "一次性将child全部渲染到内存中 ",跟下代码调用:SingleChildScrollView → build()

SingleChildScrollView 的 Viewport 具体实现 Widget 是 _SingleChildViewport ,对应的 RenderObject → _RenderSingleChildViewport

绘制方法:

上面通过 pushClipRect() 来显示显示区域 (视觉裁剪) 实现 "窗口效果":

🤔 那 "滚动效果 " 呢?通过改变 paintOffset 来移动 子组件的绘制位置:

😁 视觉效果 (滑动) 与实际移动反向是 "相反" 的 ❗️ 向下滚动时,子组件是向上移动的,Y轴负值表示向上移动。
dart
初始状态 (position = 0):
┌─────────────────┐ ← 视口顶部
│ 子组件内容A │
│ 子组件内容B │
│ 子组件内容C │
└─────────────────┘ ← 视口底部
│ 子组件内容D │ ← 不可见
│ 子组件内容E │ ← 不可见
向下滚动 (position = 100):
← 子组件内容A (不可见)
┌─────────────────┐ ← 视口顶部
│ 子组件内容B │
│ 子组件内容C │
│ 子组件内容D │
└─────────────────┘ ← 视口底部
│ 子组件内容E │ ← 不可见
😊 可以将 clipBehavior 属性 Clip.none 来验证是否 child 是否真的是 全部渲染 【--->c35/single_child_scroll_none_clip_demo.dart<---】运行效果:

3.2. ScrollView
抽象类 ,Flutter中绝大部分 "可滚动视图 " 的 顶层父类 ,核心思想是 "滚动机制 & 内容布局 " 的解耦,定义了一个可滚动区域的通用配置框架,具体如何排列子元素 (列表、网格或是其它形式) 则交由其子类实现。它的使命:
将用户的 滚动意图 (由Scrollable捕获) 转化为 视口内内容的平移 (由Viewport 和 Slivers 实现)。
核心方法:
dart
// ❗️ 子类都必须实现,用于返回 Widget列表 (必须是Sliver类型,
// 如:SliverList, SliverGrid, SliverToBoxAdapter),
// ScrollView 会把这个 Sliver 列表交给 Viewport 去渲染.
@protected
List<Widget> buildSlivers(BuildContext context);
// 构建Viewport,根据 shrinkWrap 决定使用哪种 Viewport
@protected
Widget buildViewport(
BuildContext context,
ViewportOffset offset,
AxisDirection axisDirection,
List<Widget> slivers,
){
if (shrinkWrap) {
return ShrinkWrappingViewport(...);
}
return Viewport();
}
// 结合 scrollDirection 和 reverse,用于获取滚动方向。
@protected
AxisDirection getDirection(BuildContext context) {
return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse);
}
// 实现了 StatelessWidget.build()
@override
Widget build(BuildContext context) {
// 第1步:构建 slivers 列表
final List<Widget> slivers = buildSlivers(context);
// 第2步:确定滚动方向
final AxisDirection axisDirection = getDirection(context);
// 第3步:确定有效的 primary 属性
final bool effectivePrimary = primary
?? controller == null && PrimaryScrollController.shouldInherit(context, scrollDirection);
// 第4步:获取滚动控制器
final ScrollController? scrollController = effectivePrimary
? PrimaryScrollController.maybeOf(context)
: controller;
// 第5步:创建 Scrollable 组件
final Scrollable scrollable = Scrollable(
dragStartBehavior: dragStartBehavior,
axisDirection: axisDirection,
controller: scrollController,
physics: physics,
scrollBehavior: scrollBehavior,
semanticChildCount: semanticChildCount,
restorationId: restorationId,
// 💡 将 Slivers 喂给 Viewport
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return buildViewport(context, offset, axisDirection, slivers);
},
clipBehavior: clipBehavior,
);
// 第6步:处理 PrimaryScrollController 继承
final Widget scrollableResult = effectivePrimary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
// 第7步:处理键盘消失行为
if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
return NotificationListener<ScrollUpdateNotification>(
child: scrollableResult,
onNotification: (ScrollUpdateNotification notification) {
final FocusScopeNode focusScope = FocusScope.of(context);
if (notification.dragDetails != null && focusScope.hasFocus) {
focusScope.unfocus();
}
return false;
},
);
} else {
return scrollableResult;
}
}
😄 将通用的滚动逻辑 (视口、控制器、物理效果) 都进行了封装,子类只需实现 buildSlivers() 塞 Slivers。
3.3. ListView
简介:
用于创建 可滚动的线性列表布局 的高级滑动组件。
3.3.1. API 详解
继承 BoxScrollView,同样挑几个属性讲讲:
- shrinkWrap :bool,默认false,尽可能占据父组件在滚动方向上提供的所有空间,如果父组件没限制 (如Column),就会导致无限高度/宽度错误。为true时,尺寸会收缩以包裹其内容的总高度/宽度,但这样会牺牲性能,因为它需要计算所有子项的尺寸 (即便不可见) 来确定自身的总尺寸。故仅在必要时使用,如:在Column 中嵌套一个 ListView 时。
- itemExtent :double? ,子项固定固定高度,如果所有子项都有相同的高度/宽度,设置此属性能极大地提高性能。因为 Listview不再需要动态计算每个子项的尺寸,可以直接算出滚动偏移,从而简化布局过程。
- itemExtentBuilder :ItemExtentBuilder? ,子项高度构建器 ,其实就一方法回调,有两个参数:index -当前子项索引 和 dimensions-当前滚动视口的尺寸信息,返回值double-子项高度。适用于:当你的列表项目高度是可预知的、有规律的,但又不完全相同的场景,如:奇数index,高度50,偶数index,高度100。
- prototypeItem :Widget? ,列表项高度/宽度基本一致但又不想写死 itemExtent,可以提供一个 原型Widget,ListView 会测量这个原型一次,然后假设所有其他项都具有相同的尺寸。
- cacheExtent :double? ,缓存范围,Viewport 的预加载区域大小 (默认250.0) 增加此值可以减少快速滚动时的空白,但因为会提前构建更多项,所以会增加内存消耗。
- addAutomaticKeepAlives :bool ,默认true,当列表项滚动出视口时,是否自动使用 AutomaticKeepAlive 来保存它们的状态。列表项 的 State 也需要混入 AutomaticKeepAliveClientMixin 重写 wantKeepAlive 返回 true 才会有效,对列表项包含复杂状态 (如输入框内容、动画状态) 时很有用。更复杂的和状态,应使用外部状态管理方案 (如Provider、BLoC、Riverpod等),将状态与 UI 分离。
- addRepaintBoundaries :bool,默认true,是否为每个列表项自动包裹一个 RepaintBoundary,用于隔离每个列表项的重绘,防止一个项的动画或变化导致整个列表重绘,从而优化性能。
提供了 四种构造方式:
dart
// ✨ 默认构造函数,接收一个 List<Widget> 作为 children。
// 内部使用 SliverChildListDelegate,它会一次性构建所有的子 Widget,
// 所以仅适用于【少量、固定的子项】的场景
ListView(children: <Widget>[ Container(), ...])
// ✨ ListView.builder(),最常用、最高性能
// 内部使用 SliverChildBuilderDelegate,它不会立即创建所有列表项,而是通过
// itemBuilder 回调函数,在列表项即将进入视口时才进行构建。适用于【大量或无限子项】的场景
final List<String> entries = List<String>.generate(1000, (i) => 'Item $i');
ListView.builder(
itemCount: entries.length, // 列表项总数,如果为 null,则表示一个无限列表。
itemBuilder: (BuildContext context, int index) {
// 根据索引 index 返回对应的 Widget
return ListTile(
title: Text(entries[index]),
);
},
)
// ✨ ListView.separated(),builder() 变种,可以方便地在每个列表项之间插入一个分割线 Widget
ListView.separated(
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text('Item $index'));
},
// 根据索引 index 构建位于 item[index] 和 item[index + 1] 间的分割线。
separatorBuilder: (BuildContext context, int index) {
return const Divider(color: Colors.grey);
},
)
// ✨ ListView.custom() 完全自定义,允许你提供一个自定义的 SliverChildDelegate
// 通过「childrenDelegate」参数传入,前三种构造方法其实都是这个构造方法的语法糖。
// 这种构造方式很少直接使用,适用场景:需对子项的创建、销毁、保活等行为进行更精细控制
3.3.2. 源码剖析
默认构造函数,内部使用 SliverChildListDelegate:
kotlin
SliverChildListDelegate extends SliverChildDelegate {
SliverChildListDelegate(
this.children, // 这里是 List<Widget>
{
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.addSemanticIndexes = true,
this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
this.semanticIndexOffset = 0,
}) : _keyToIndex = <Key?, int>{null: 0};
}
看下 build() 方法:

"盐值(Salt) " 这个概念来自密码学,指的是在 加密过程中添加的额外随机数据 。在这里是给原有Key 添加额外信息 ,如:原始Key("item_1") 包装成 _SaltedValueKey(Key('item_1')),目的是让这个Key在delegate内部是唯一的,以避免Flutter混乱这些Key,导致状态错乱。
"KeyedSubtree " 是一个特殊的 Widget ,它的作用是:为整个子树提供一个稳定的身份标识,帮助Flutter的渲染系统正确追踪Widget,确保当Widget位置变化时,状态能正确保持。"Element复用机制 ":当Widget第一次显示时,Flutter创建对应的 Element 和 RenderObject ,当Widget重新构建时,Flutter会尝试 复用已有的Element ,复用条件是:Widget的runtimeType和key都相同。
😶 用的 children ,传入时就是已经创建的Widget,所以是一次性构建所有的子 Widget ,接着看下 builder() 构建的方式,用的 SliverChildBuilderDelegate:
dart
class SliverChildBuilderDelegate extends SliverChildDelegate {
const SliverChildBuilderDelegate(
this.builder, // 这里是 NullableIndexedWidgetBuilder
{
this.findChildIndexCallback,
this.childCount,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.addSemanticIndexes = true,
this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
this.semanticIndexOffset = 0,
});
}
typedef NullableIndexedWidgetBuilder = Widget? Function(BuildContext context, int index);
看下 build() 方法:

💁♂️ separated() 也是用的 SliverChildBuilderDelegate ,custom() 则需要通过 childrenDelegate 参数传入自定义的 SliverChildDelegate 。继承关系:ListView → BoxScrollView → ScrollView ,BoxScrollView 在 ScrollView 的基础上增加了 "自动填充padding " (避免状态栏遮挡-垂直滚动自动添加顶部填充、避免底部导航栏-自动添加底部填充、刘海屏适配-自动处理异形屏幕的安全区域)。另外,还对布局构建进行了抽象,子类只需实现 buildChildLayout(BuildContext context) 方法。构建调用链路:
dart
ScrollView.build()
↓
BoxScrollView.buildSlivers()
↓
ListView.buildChildLayout() // 生成 SliverMultiBoxAdaptorWidget 对象
↓
BoxScrollView.buildSlivers() // 包装ListView生成的Sliver (如SliverPadding),返回[sliver]
↓
ScrollView.build() // 总装配
↓
@override
Widget build(BuildContext context) {
final List<Widget> slivers = buildSlivers(context); // ② 获取slivers
final Scrollable scrollable = Scrollable( // ① 创建Scrollable
viewportBuilder: (context, offset) => buildViewport(context, offset, axisDirection, slivers),
);
}
清楚明了,看下 ListView.buildChildLayout() ,根据不同情况,创建了四种类型的 Sliver Widget:

👀 跟下这四个 Sliver 到类的具体实现~
3.3.3. SliverList - 动态列表
每个子项都要调用 layout() 方法,需要缓存已测量子项的信息,核心源码:
dart
class SliverList extends SliverMultiBoxAdaptorWidget {
const SliverList({
super.key,
required super.delegate,
});
@override
RenderSliverList createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
return RenderSliverList(childManager: element);
}
}
// 底层 RenderObject 核心逻辑
class RenderSliverList extends RenderSliverMultiBoxAdaptor {
// 🔥 关键:每个子项都需要单独进行布局计算
@override
void performLayout() {
// 1. 遍历每个可见的子项
// 2. 调用 child.layout() 计算每个子项的实际尺寸
// 3. 累积计算总的滚动范围
// 4. 确定每个子项的位置
double scrollOffset = constraints.scrollOffset;
double remainingExtent = constraints.remainingPaintExtent;
// 💡 关键性能瓶颈:需要逐个测量每个子项的高度
while (remainingExtent > 0) {
RenderBox child = getChildAtIndex(index);
child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
// 📏 每次都要获取子项的实际高度
double childExtent = getMainAxisExtent(child);
// ... 位置计算和缓存逻辑
}
}
}
3.3.4. SliverFixedExtentList - 固定高度高性能列表
直接通过数学计算确定位置,跳过子项的layout过程,可以精确预测滚动范围,核心源码:
dart
class SliverFixedExtentList extends SliverMultiBoxAdaptorWidget {
const SliverFixedExtentList({
super.key,
required super.delegate,
required this.itemExtent, // 👈 关键:固定高度
});
final double itemExtent; // 🔥 核心:所有子项的固定高度
@override
RenderSliverFixedExtentList createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
return RenderSliverFixedExtentList(
childManager: element,
itemExtent: itemExtent, // 传递固定高度
);
}
}
// 底层 RenderObject 核心逻辑
class RenderSliverFixedExtentList extends RenderSliverMultiBoxAdaptor {
final double itemExtent;
@override
void performLayout() {
// 🚀 性能优势:可以直接计算而不需要测量
// 1. 🔥 直接计算可见区域内的项目数量
double scrollOffset = constraints.scrollOffset;
int firstVisibleIndex = (scrollOffset / itemExtent).floor();
int lastVisibleIndex = ((scrollOffset + constraints.viewportMainAxisExtent) / itemExtent).ceil();
// 2. 🚀 直接计算每个子项的位置(无需layout测量)
for (int index = firstVisibleIndex; index <= lastVisibleIndex; index++) {
RenderBox child = getChildAtIndex(index);
// 💎 关键优化:强制设置子项高度,跳过子项自己的layout计算
child.layout(
constraints.asBoxConstraints(
minHeight: itemExtent,
maxHeight: itemExtent, // 强制固定高度
),
parentUsesSize: false, // 💡 不需要获取子项尺寸
);
// 🎯 直接计算位置:索引 * 固定高度
double childMainAxisPosition = index * itemExtent - scrollOffset;
// 设置子项位置...
}
// 3. 🚀 直接计算总的滚动范围
geometry = SliverGeometry(
scrollExtent: childCount * itemExtent, // 直接计算总高度
paintExtent: math.min(constraints.remainingPaintExtent, maxPaintExtent),
maxPaintExtent: childCount * itemExtent,
);
}
}
3.3.5. SliverPrototypeExtentList - 原型高度列表
支持复杂的Widget作为原型,只需测量原型一次,后续使用固定高度算法,核心源码:
dart
class SliverPrototypeExtentList extends SliverMultiBoxAdaptorWidget {
const SliverPrototypeExtentList({
super.key,
required super.delegate,
required this.prototypeItem, // 👈 关键:原型Widget
});
final Widget prototypeItem; // 🔥 核心:用于测量的原型Widget
@override
RenderSliverPrototypeExtentList createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
return RenderSliverPrototypeExtentList(
childManager: element,
prototypeItem: prototypeItem,
);
}
}
// 底层 RenderObject 核心逻辑:
class RenderSliverPrototypeExtentList extends RenderSliverMultiBoxAdaptor {
Widget _prototypeItem;
double? _prototypeExtent; // 缓存原型的高度
@override
void performLayout() {
// 🎨 首次计算:测量原型Widget的高度
if (_prototypeExtent == null) {
_prototypeExtent = _measurePrototype();
}
// 💎 后续逻辑与SliverFixedExtentList类似
double itemExtent = _prototypeExtent!;
// 🚀 使用固定高度的高性能算法
int firstVisibleIndex = (constraints.scrollOffset / itemExtent).floor();
int lastVisibleIndex = ((constraints.scrollOffset + constraints.viewportMainAxisExtent) / itemExtent).ceil();
for (int index = firstVisibleIndex; index <= lastVisibleIndex; index++) {
RenderBox child = getChildAtIndex(index);
// 🔥 强制使用原型高度
child.layout(
constraints.asBoxConstraints(
minHeight: itemExtent,
maxHeight: itemExtent,
),
parentUsesSize: false,
);
}
}
// 🎨 原型测量方法
double _measurePrototype() {
// 1. 创建原型Widget的RenderObject
RenderBox prototypeRenderBox = _createPrototypeRenderBox();
// 2. 对原型进行layout测量
prototypeRenderBox.layout(constraints.asBoxConstraints(), parentUsesSize: true);
// 3. 获取原型的主轴高度
double extent = getMainAxisExtent(prototypeRenderBox);
// 4. 清理原型RenderObject
prototypeRenderBox.dispose();
return extent;
}
}
3.3.6. SliverVariedExtentList - 预定义不同高度列表
适用于高度已知,无需子项自己测量,itemExtentBuilder会被多次调用,计算过的高度会被缓存,核心源码:
dart
class SliverVariedExtentList extends SliverMultiBoxAdaptorWidget {
const SliverVariedExtentList({
super.key,
required super.delegate,
required this.itemExtentBuilder, // 👈 关键:高度构建器
});
// 🔥 核心:根据索引和维度信息返回高度的回调
final ItemExtentBuilder itemExtentBuilder;
@override
RenderSliverVariedExtentList createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
return RenderSliverVariedExtentList(
childManager: element,
itemExtentBuilder: itemExtentBuilder,
);
}
}
// 底层 RenderObject 核心逻辑:
class RenderSliverVariedExtentList extends RenderSliverMultiBoxAdaptor {
final ItemExtentBuilder itemExtentBuilder;
// 🔧 高度缓存机制
final Map<int, double> _cachedExtents = <int, double>{};
@override
void performLayout() {
double scrollOffset = constraints.scrollOffset;
double remainingExtent = constraints.remainingPaintExtent;
// 📊 混合策略:预计算 + 按需计算
int currentIndex = 0;
double currentOffset = 0;
// 1. 🔍 找到第一个可见的子项(通过累积高度)
while (currentOffset < scrollOffset) {
double itemHeight = _getItemExtent(currentIndex);
currentOffset += itemHeight;
currentIndex++;
}
// 2. 🎯 渲染可见区域内的子项
while (remainingExtent > 0 && currentIndex < childCount) {
double itemHeight = _getItemExtent(currentIndex);
RenderBox child = getChildAtIndex(currentIndex);
// 💡 性能优化:使用预定义的高度约束子项
child.layout(
constraints.asBoxConstraints(
minHeight: itemHeight,
maxHeight: itemHeight,
),
parentUsesSize: false,
);
remainingExtent -= itemHeight;
currentIndex++;
}
}
// 🔥 核心方法:获取指定索引的高度
double _getItemExtent(int index) {
// 缓存机制避免重复计算
return _cachedExtents[index] ??= itemExtentBuilder(index, constraints);
}
}
3.4. GridView
简介:
用于创建 二维可滚动网格布局容器 ,能够将一系列子Widget 排列成 多行多列的网格形式,支持横向和纵向滚动。
3.4.1. API 详解
继承 BoxScrollView ,属性和Listview差不多,核心是 "gridDelegate",它负责定义网格的几何结构。
dart
// 控制网格布局的代理
final SliverGridDelegate gridDelegate;
// Flutter 提供了两个实现
SliverGridDelegateWithFixedCrossAxisCount
- crossAxisCount: 固定列数。
- mainAxisSpacing: 主轴间距。
- crossAxisSpacing: 交叉轴间距。
- childAspectRatio: 子项的宽高比。默认为 1.0。非常重要,用于计算子项在主轴上的高度。
SliverGridDelegateWithMaxCrossAxisExtent
- maxCrossAxisExtent: 子项在交叉轴上的最大尺寸
- 其它参数同上
提供了 五种构造方式:
dart
// 模拟一些数据
final List<int> data = List.generate(50, (index) => index);
// ✨ ① GridView() - 默认构造函数
// 需要手动提供 gridDelegate 和 children 列表,适合少量、固定的子项。
GridView(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 交叉轴子项数量
crossAxisSpacing: 10, // 交叉轴方向相邻子项间的间距
mainAxisSpacing: 10, // 主轴方向相邻子项间的间距
childAspectRatio: 1.0, // 子项的宽高比
),
children: data.map((i) => GridItem(index: i)).toList()
);
// ✨ ① GridView.count() - 最常用,用于创建固定列数的网格
GridView.count(
// 核心参数:固定列数
crossAxisCount: 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
children: data.map((i) => GridItem(index: i)).toList(),
);
// ✨ ③ GridView.extent() - 响应式布局,根据子项的最大宽度自动计算列数。
GridView.extent(
// 核心参数:子项在交叉轴上的最大尺寸
maxCrossAxisExtent: 120.0,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
children: data.map((i) => GridItem(index: i)).toList(),
);
// ✨ ④ GridView.builder() - 高性能懒加载,用于大量或无限数据的场景,按需构建子项。
GridView.builder(
// 布局代理,与默认构造函数中的一样
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
// 核心参数:子项总数
itemCount: data.length,
// 核心参数:子项构建器
itemBuilder: (context, index) {
// 只有当 item 将要显示时,此方法才会被调用
print('Building item for builder: $index');
return GridItem(index: data[index]);
},
);
// ✨ ⑤ GridView.custom() - 完全自定义,提供了最大的灵活性,可以自定义子项的构建和管理策略。
GridView.custom(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
mainAxisSpacing: 10, // 主轴间距
crossAxisSpacing: 10, // 交叉轴间距
),
// 核心参数:子项代理
// SliverChildBuilderDelegate 行为与 GridView.builder 相同
childrenDelegate: SliverChildBuilderDelegate(
(context, index) {
return GridItem(index: data[index]);
},
childCount: data.length,
),
);
简单使用示例【--->c35/gridview_demo.dart<---】运行效果:

3.4.2. 源码剖析
有了前面 ListView 的经验,看起源码来可谓是轻车熟路了,先是 构造方法:
dart
// 😄 前三个的 Sliver 都是【SliverChildListDelegate】,全是一次性加载所有子控件
// 区别在于【网格布局代理类】的不同:
//
//「SliverGridDelegateWithFixedCrossAxisCount」
// 固定交叉轴上的子项数量 (列数/行数),无论屏幕多大,始终显示固定的列数
// 子项宽度:childWidth = (totalWidth - spacing) / crossAxisCount
//
//「SliverGridDelegateWithMaxCrossAxisExtent」
// 固定子项在交叉轴上的最大尺寸,根据屏幕大小自动计算合适的列数
// 开发者指定子项最大宽度固定,如:maxCrossAxisExtent = 150.0
// 计算列数:crossAxisCount = ceil(totalWidth / (maxCrossAxisExtent + spacing))
// 计算子项宽度:childWidth = (totalWidth - spacing) / crossAxisCount
GridView() → 需要自定义 SliverGridDelegateWithFixedCrossAxisCount 通过 gridDelegate 传入
GridView.count() → 内部自动创建 SliverGridDelegateWithFixedCrossAxisCount
GridView.extend() → 内部自动创建 SliverGridDelegateWithMaxCrossAxisExtent
// 💡 懒加载,Sliver 是【SliverChildBuilderDelegate】,需传入自定义的 gridDelegate 参数。
GridView.builder()
// 既需要自定义 gridDelegate 参数,也需要自定义 childrenDelegate 参数。
GridView.custom()
GridView 也是继承 BoxScrollView ,直接搜 buildChildLayout() :

3.4.3. SliverGrid - 网格布局
用于在 二维网格中放置多个 box 子组件 的 Sliver 组件,专门为滚动视图设计的 网格布局组件。

RenderSliverGrid 的核心源码:
dart
class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
@override
void performLayout() {
// 🎯【准备阶段】获取滚动约束条件
final SliverConstraints constraints = this.constraints;
childManager.didStartLayout();
childManager.setDidUnderflow(false);
final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
final double remainingExtent = constraints.remainingCacheExtent;
final double targetEndScrollOffset = scrollOffset + remainingExtent;
// 🎯【计算可见范围】获取布局策略、计算第一个/最后一个可见子项索引
final SliverGridLayout layout = _gridDelegate.getLayout(constraints);
final int firstIndex = layout.getMinChildIndexForScrollOffset(scrollOffset);
final int? targetLastIndex = targetEndScrollOffset.isFinite ?
layout.getMaxChildIndexForScrollOffset(targetEndScrollOffset) : null;
// 🎯【垃圾回收】移除不在可见范围内的子组件,释放内存
if (firstChild != null) {
final int leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex);
final int trailingGarbage = targetLastIndex != null ? calculateTrailingGarbage(lastIndex: targetLastIndex) : 0;
collectGarbage(leadingGarbage, trailingGarbage);
} else {
collectGarbage(0, 0);
}
// 🎯【布局子组件】每个子组件都通过 SliverGridGeometry 获得精确的位置和尺寸
// 双向构建-既向前也向后添加子组件,懒加载-只构建可见区域的子组件
// 处理第一个子组件
final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
// 向前添加子组件
for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index);
final RenderBox child = insertAndLayoutLeadingChild(
gridGeometry.getBoxConstraints(constraints),
)!;
final SliverGridParentData childParentData = child.parentData! as SliverGridParentData;
childParentData.layoutOffset = gridGeometry.scrollOffset;
childParentData.crossAxisOffset = gridGeometry.crossAxisOffset;
}
// 向后添加子组件
for (int index = indexOf(trailingChildWithLayout!) + 1; targetLastIndex == null || index <= targetLastIndex; ++index) {
// ... 类似的布局逻辑
}
// 🎯【计算几何信息】估算总滚动范围、计算绘制区域、计算缓存区域
final double estimatedTotalExtent = reachedEnd
? trailingScrollOffset
: childManager.estimateMaxScrollOffset(
constraints,
firstIndex: firstIndex,
lastIndex: lastIndex,
leadingScrollOffset: leadingScrollOffset,
trailingScrollOffset: trailingScrollOffset,
);
final double paintExtent = calculatePaintOffset(
constraints,
from: math.min(constraints.scrollOffset, leadingScrollOffset),
to: trailingScrollOffset,
);
geometry = SliverGeometry(
scrollExtent: estimatedTotalExtent,
paintExtent: paintExtent,
maxPaintExtent: estimatedTotalExtent,
cacheExtent: cacheExtent,
hasVisualOverflow: estimatedTotalExtent > paintExtent || constraints.scrollOffset > 0.0 || constraints.overlap != 0.0,
);
}
}
代码有点多,梳理下执行链条:
dart
RenderSliverGrid.performLayout() // 开始网格布局
↓
childManager.didStartLayout() // 初始化子组件管理器
↓
_gridDelegate.getLayout(constraints) // 获取网格布局策略
↓
layout.getMinChildIndexForScrollOffset(scrollOffset) // 计算第一个可见子项索引
↓
layout.getMaxChildIndexForScrollOffset(targetEndScrollOffset) // 计算最后一个可见子项索引
↓
collectGarbage(leadingGarbage, trailingGarbage) // 回收不可见的子组件
↓
layout.getGeometryForChildIndex(firstIndex) // 获取第一个子项的几何信息
↓
for (index >= firstIndex; --index) // 【循环1】向前布局缺失的子组件
→ insertAndLayoutLeadingChild()
→ 设置 layoutOffset 和 crossAxisOffset
↓
for (index <= targetLastIndex; ++index) // 【循环2】向后布局新的子组件
→ insertAndLayoutChild()
→ 设置 layoutOffset 和 crossAxisOffset
↓
childManager.estimateMaxScrollOffset() // 估算总滚动范围
↓
calculatePaintOffset() / calculateCacheOffset() // 计算绘制和缓存区域
↓
geometry = SliverGeometry(...) // 创建最终几何信息
↓
childManager.didFinishLayout() // 完成布局,清理资源
😄 套下数据写个简单的例子 (垂直方向、一行4列,子元素高度为50,Viewport 为180,有24个子项):
dart
// ① 计算可见范围
scrollOffset = 0 (初始位置)
targetEndScrollOffset = 0 + 180 = 180
layout.getMinChildIndexForScrollOffset(0) = 0 // 第一个可见项:索引0
layout.getMaxChildIndexForScrollOffset(180) = 15 // 最后一个可见项:索引15
// 计算详解
mainAxisCount = (180 / 50).ceil() = 3.6.ceil() = 4 行
maxChildIndex = max(0, 4 * 4 - 1) = 15
// ② 分析子项分布 (24个):
行1 (y=0-50): [0] [1] [2] [3]
行2 (y=50-100): [4] [5] [6] [7]
行3 (y=100-150): [8] [9] [10][11]
行4 (y=150-200): [12][13][14][15]
行5 (y=200-250): [16][17][18][19] ← 不可见
行6 (y=250-300): [20][21][22][23] ← 不可见
实际需要布局的子项: 索引 0-15 (只有前16个)
不会创建的子项: 索引 16-23 (后8个)
// ③ 布局执行
collectGarbage(): 清理超出范围的子项
第一个循环 (向前布局):
无需执行 (从索引0开始)
第二个循环 (向后布局):
for (index = 1; index <= 15; ++index) { // 注意:只到15,不到23
insertAndLayoutChild() // 只创建索引1到15的子项
设置 layoutOffset 和 crossAxisOffset
}
索引16-23的子项: 完全不会被创建!
// ④ 最终几何信息
estimatedTotalExtent = childManager.estimateMaxScrollOffset()
// 会根据已知的16个子项来估算24个子项的总高度
// 估算结果: 6行 × 50px = 300px
paintExtent = 180px (viewport高度)
scrollExtent = 300px (估算的总滚动范围)
// 渲染结果
┌─────────────────────────┐ ← viewport top (y=0)
│ [0] [1] [2] [3] │ ← 行1: 已创建,完全可见
│ [4] [5] [6] [7] │ ← 行2: 已创建,完全可见
│ [8] [9] [10] [11] │ ← 行3: 已创建,完全可见
│ [12] [13] [14] [15] │ ← 行4: 已创建,部分可见
└─────────────────────────┘ ← viewport bottom (y=180)
未显示区域:
│ [16] [17] [18] [19] │ ← 行5: 未创建,不可见
│ [20] [21] [22] [23] │ ← 行6: 未创建,不可见
3.5. PageView
简介
可滚动的列表,特殊之处在于它的每个子组件 (称为"页面 ") 在滚动时都会强制占据整个视口 (Viewport)。常用于引导页、轮播图。TabBarView 也是基于它实现的,用来配置TabBar展示不同标签下的内容。
3.5.1. API 详解
继承 StatefulWidget,同样挑几个属性讲讲:
- pageSnapping :bool,是否启用页面吸附,默认true,滚动停止时会自动吸附到最近的页面边界,设置 false,则可以停在任何位置。
- padEnds :bool ,是否在列表的两端添加填充,默认true,会在第一页和最后一页添加额外的填充空间。使得第一页和最后一页能够居中显示在视口中。这个参数只有 viewportFraction < 1.0 时才生效。
- onPageChanged :ValueChanged? ,当一个新页面完全显示时 (pageSnapping完成后) 调用,可以获取新页面的索引。
- childrenDelegate :PageView 内部使用它来生成子组件,不同构造方法最终会创建不同类型的 SliverChildDelegate。
提供了 三种构造方式:
dart
// ✨ ① PageView() - 默认构造函数,适合页面数量较少且固定的情况
PageView(
children: <Widget>[
_buildPage(1, Colors.pink),
_buildPage(2, Colors.cyan),
_buildPage(3, Colors.deepPurple),
],
)
// ✨ ② PageView.builder() - 构造器构造函数,最常用,懒加载,适合页面数量多或不确定的情况。
// 假设有100个页面
const int pageCount = 100;
PageView.builder(
itemCount: pageCount,
itemBuilder: (BuildContext context, int index) {
// itemBuilder 会在页面即将进入视口时被调用
// 这意味着只有当用户滑动到某个页面时,它才会被构建
}
)
// ✨ ③ PageView.custom() - 自定义构造函数,对子项的构建、布局和回收逻辑进行高度自定义时。
PageView.custom(
// childrenDelegate 是核心,它决定了如何提供子页面
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
// 如:奇数页和偶数页显示不同的内容
if (index.isEven) {
return Container(
color: Colors.green,
child: Center(
child: Text(
'Even Page ${index + 1}\n(From PageView.custom())',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 22, color: Colors.white),
),
),
);
} else {
return Container(
color: Colors.orange,
child: Center(
child: Text(
'Odd Page ${index + 1}\n(From PageView.custom())',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 22, color: Colors.white),
),
),
);
}
},
// 同样可以指定子项数量,也可以为 null 表示无限列表
childCount: 20,
),
);
页面的切换控制,用到的 → PageController ,它继承自 ScrollController ,在原有像素级别的基础上,新增了 "页面" 级别的滚动控制,构造方法 & 类成员:
dart
PageController({
this.initialPage = 0, // 初始页面索引
this.keepPage = true, // 是否保存页面状态
this.viewportFraction = 1.0, // 视口占比,< 1.0 每页只占部分视口,可以看到相邻页面的一部分
// > 1.0: 每页超出视口,会有padding效果
super.onAttach, // 附加回调
super.onDetach, // 分离回调
}) : assert(viewportFraction > 0.0);
double? get page // 当前页面的精确位置,可能包含小数部分,如 1.5 表示在第1页和第2页之间
// 必须在 PageView 构建完成后才能访问
// 核心方法
// 动画地切换到指定页面
animateToPage(int page, {required Duration duration, required Curve curve})
// 无动画地直接跳转到指定页面
jumpToPage(int page)
// 动画地切换到下一页
nextPage({required Duration duration, required Curve curve})
// 动画地切换到上一页
previousPage({required Duration duration, required Curve curve})
简单使用示例【--->c35/pageview_demo.dart<---】运行效果:

💡 Tips :滑动几页后,切换Tab,再切回来,发现会从第一页开始,即重新创建。如果想 保存页面状态 ,子页面需混入 AutomaticKeepAliveClientMixin 并重写 wantKeepAlive 返回 true ❗️
3.5.2. 源码剖析
😄 没啥新意,默认构造是 SliverChildListDelegate ,builder() 则是 SliverChildBuilderDelegate ,前者一次性构建所有的子Widget,后者动态懒加载。PageView 继承 StatefulWidget ,直接看 _PageViewState.build() :

SliverFillViewport 继承 StatelessWidget ,直接看 build() :

- _SliverFractionalPadding:负责根据视口大小动态计算并添加内边距,让SliverFillViewport的首尾子项能够居中显示。
- _SliverFillViewportRenderObjectWidget :Widget 和 RenderObject间的桥梁,负责创建和管理RenderSliverFillViewport 渲染对象。
RenderSliverFillViewport 的父类 RenderSliverFixedExtentBoxAdaptor 实现了 performLayout() ,关键代码:
dart
abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
@override
void performLayout() {
// ① 初始化阶段
// 获取约束信息
final SliverConstraints constraints = this.constraints;
childManager.didStartLayout();
// 计算关键偏移量
final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
final double targetEndScrollOffset = scrollOffset + remainingExtent;
// ② 索引计算阶段
// 获取约束信息
final SliverConstraints constraints = this.constraints;
childManager.didStartLayout();
// 计算关键偏移量
final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
final double targetEndScrollOffset = scrollOffset + remainingExtent;
// ③ 垃圾回收阶段
// 计算需要布局的子组件索引范围
final int firstIndex = getMinChildIndexForScrollOffset(scrollOffset, -1);
final int? targetLastIndex = getMaxChildIndexForScrollOffset(targetEndScrollOffset, -1);
// ④ 子组件布局阶段
// 向前布局:清理不再需要的子组件
final int leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex);
final int trailingGarbage = calculateTrailingGarbage(lastIndex: targetLastIndex);
collectGarbage(leadingGarbage, trailingGarbage);
// 向后布局:从 firstChild 向前插入和布局子组件
for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
final RenderBox? child = insertAndLayoutLeadingChild(_getChildConstraints(index));
childParentData.layoutOffset = indexToLayoutOffset(-1, index);
}
// ⑤ 几何信息结算阶段:从 trailingChildWithLayout 向后插入和布局子组件
for (int index = indexOf(trailingChildWithLayout!) + 1; index <= targetLastIndex; ++index) {
RenderBox? child = insertAndLayoutChild(_getChildConstraints(index), after: trailingChildWithLayout);
childParentData.layoutOffset = indexToLayoutOffset(-1, index);
}
// ⑥ 完成阶段
// 计算各种范围信息
final double paintExtent = calculatePaintOffset(constraints, from: leadingScrollOffset, to: trailingScrollOffset);
final double cacheExtent = calculateCacheOffset(constraints, from: leadingScrollOffset, to: trailingScrollOffset);
// 设置最终的几何信息
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
hasVisualOverflow: ...,
);
}
}
4. 高级组件 & 自定义滚动
Sliver 是 Flutter 为了解决 大量内容滚动时的性能问题 而设计的 视口驱动渲染机制 ,它 只构建和渲染用户当前能看到的部分 ,相比 普通Widget 会一次性构建所有内容导致内存爆炸和性能问题要高效得多。
4.1. CustomScrollView
用于 构建自定义、复杂的滚动视图 ,它本身不直接决定其子项的布局,而是创建了一个可以容纳 Sliver 系列组件的滚动视口 (Viewport)。继承自 ScrollView ,只是重写了 buildSlivers() ,返回构造参数传入的 slivers - Sliver组件列表。
4.2. SliverPersistentHeader
Persistent -"持久化 ",该组件可以 根据滚动位置改变自身大小 ,并且可以选择性地 "钉 " 在视口(Viewport) 顶部 的 Sliver 。当它滚动到屏幕边缘时,它不会像普通列表项那样完全滚出屏幕,而是可以收缩到一个最小高度并"固定在那里",直到被下一个 SliverPersistentHeader 或者滚动会顶部所代替。😄 其实就很常说的"吸顶"组件。
dart
class SliverPersistentHeader extends StatelessWidget {
const SliverPersistentHeader({
Key? key,
required this.delegate, // 头部布局的委托对象,包括:最大/最小高度和构建逻辑
this.pinned = false, // 是否固定在视口顶部 (滚动也不消失)
this.floating = false, // 是否有浮动效果 (用户反向滚动会立即重新出现)
});
}
SliverPersistentHeaderDelegate 是一个抽象类,需继承并实现下述方法:
dart
@override
double get minExtent => 60.0; // 最小高度
@override
double get maxExtent => 200.0; // 最大高度
// 返回头部的 UI 组件,参数:
// 「shrinkOffset」-头部从最大高度 maxExtent 收缩的距离,可能范围[0.0, maxExtent-minExtent]
// 当 shrinkOffset 为 0 时,头部处于完全展开状态,当达到最大值时,头部处于完全收缩状态
// 可以利用这个值来实现各种动画效果,如 (透明度、位移、大小锁房)
//
//「overlapsContent」该 Header 当前是否与滚动视图中的主要内容重写。
// 通常在 pinned 为 true 时,Header 下方的内容开始滚动到其后面时,此值为 true。
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(/* 你的头部内容 */);
}
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
// 返回是否需要重建
return true;
}
写个简单使用示例【--->c35/sliver_persistent_header_demo.dart<---】运行效果:

😶 简单传几个参数就实现了吸顶和浮动效果,跟下源码,看下是怎么做到的,build() 根据不同的值情况返回不同的 渲染对象 ,跟下对应的 performLayout() 的核心代码:

从下往上看,先是 _SliverScrollingPersistentHeader → RenderSliverScrollingPersistentHeader:

接着是 _SliverFloatingPersistentHeader → RenderSliverFloatingPersistentHeader:

再接着 _SliverPinnedPersistentHeader → RenderSliverPinnedPersistentHeader:

最后是 _SliverFloatingPinnedPersistentHeader → RenderSliverFloatingPinnedPersistentHeader ,浮动效果来自于父类 RenderSliverFloatingPersistentHeader ,吸顶效果则是重写 updateGeometry() 实现:

4.3. SliverAppBar
基于 SliverPersistentHeader 实现的具体 应用栏组件 (内置应用的常见功能,如标题、操作按钮、背景等),开箱即用,相比起手写一坨 _SliverHeaderDelegate ,直接配置几个属性即可轻松实现相同的效果:floating 、pinned 就不用说了,还有这些:
- snap:是否启用快速收缩/展开动画。
- expandedHeight:完全展开时的高度,通常和 flexibleSpace 来显示背景内容,null 时使用默认工具栏高度。
- collapsedHeight:收缩时的最小高度,不写默认为 toolbarHeight + bottom?.preferredSize.height。
- stretch :bool,是否启用拉伸效果,默认false, 禁用拉伸效果,应用栏保持固定大小。设为true,当用户向下过度滚动时,应用栏会被拉伸放大。开启后的视觉效果:下拉时应用栏的 flexibleSpace 区域会被拉伸,拉伸后背景内容会按比例放大。
- stretchTriggerOffset :double,设置触发 onStretchTrigger 回调 (拉伸回弹) 需要的过度滚动距离,默认100像素,计算方式: 从应用栏的自然位置开始计算向下的拖拽距离。
- onStretchTrigger :Future ,拉伸触发的异步回调,用户拖拽超过阈值且松手时 (拉伸距离达到stretchTriggerOffset),注意,只在 stretch = true 时有效。
写个简单使用示例【--->c35/sliver_app_bar_demo.dart<---】运行效果:

跟下源码,内部使用 _SliverAppBarDelegate 来实现 SliverPersistentHeaderDelegate:



4.4. NestedScrollView
当你在一个可滚动组件中放入一个"同方向 "的可滚动组件,通常会遇到两大类问题:"手势冲突 " & "布局约束问题 "。比如最经典的例子:两个垂直滚动的ListView嵌套,会怎么样?
先是会报错:xxx has an unbounded height,尝试添加 固定高度 约束后。只有内部的列表在滑动,当它滚动尽头时,滚动事件并不会自动传递给外部的ListView。(🐶一种不太好的解法,内部ListView设置:shrinkWrap:true + physics: NeverScrollableScrollPhysics() ,即收缩+禁止滚动,仅适用于列表项少且固定的情况)。
而 NestedScrollView 就是为了解决这两个问题而设计出来的:
- 通过 滚动协调器 ( _NestedScrollCoordinator) 统一管理和分配滚动事件,消除竞争.
- 为 body 提供了有界约束,使其内部的可滚动组件可以正常布局。
4.4.1. API 详解
构造方法:
dart
class NestedScrollView extends StatefulWidget {
const NestedScrollView({
super.key, // Widget的唯一标识符,用于性能优化和状态保持
this.controller, // 外层滚动控制器,用于程序化控制滚动位置和监听滚动事件
this.scrollDirection = Axis.vertical, // 滚动方向,默认垂直滚动(仅影响外层滚动)
this.reverse = false, // 是否反向滚动,true时从底部开始滚动(仅影响外层滚动)
this.physics, // 滚动物理效果,控制弹性、边界行为等滚动特性(仅影响外层滚动)
required this.headerSliverBuilder, // 头部构建器,返回头部Sliver组件列表(如SliverAppBar)
required this.body, // 主体内容Widget,通常是TabBarView或其他滚动组件
this.dragStartBehavior = DragStartBehavior.start, // 拖拽开始行为,控制手势识别的起始时机
this.floatHeaderSlivers = false, // 是否优先浮动头部Sliver,true时向下滚动优先展开头部
this.clipBehavior = Clip.hardEdge, // 内容裁剪行为,控制超出边界内容的显示方式
this.restorationId, // 状态恢复ID,用于应用重启时恢复滚动位置
this.scrollBehavior, // 滚动行为配置,定义滚动样式、物理效果等平台相关行为
});
核心属性:
- headerSliverBuilder :NestedScrollViewHeaderSliversBuilder → List Function(BuildContext context, bool innerBoxIsScrolled),用于构建 头部 的 Sliver 组件列表。第二个参数的值代表"嵌套的滑动内容是否已经达到顶部,开始滑动"。
- body :Widget ,主体部分,通常是一个包含可滚动内容的组件,最常见的是 TabBarView ,其每个子页面都是一个 ListView 或 CustomScrollView,这部分内容构成了 "内部滚动视图"。
- controller :ScrollController ,控制外层滚动,内层滚动由 PrimaryScrollController 自动管理。
- floatHeaderSlivers:是否优先浮动头部Slivers,默认 false,设为 true,向下滚动会优先展开头部。
- 注:scrollDirection、reverse、physics 只影响外层滚动视图,内层滚动视图需要在body中单独配置。
最简单的使用代码示例 (注意headerSliverBuilder列表的元素必须为Sliver组件,如:SliverList):
dart
NestedScrollView(
// 头部
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverToBoxAdapter(
child: Container(height: 200, color: Colors.orange),
),
];
},
// 主体
body: Container(
color: Colors.orange.shade50,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 30,
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.orange,
child: Text('${index + 1}'),
),
title: const Text('这是主体内容 (body)'),
subtitle: Text('列表项 ${index + 1}'),
),
),
),
),
),
运行效果:

尝试 SliverAppBar:
dart
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
// 🎨 可折叠的 SliverAppBar
SliverAppBar(
title: const Text('📱 SliverAppBar 示例'),
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
floating: true,
pinned: true,
snap: true,
expandedHeight: 200.0,
// 🎭 弹性空间 - 展开时显示的内容
flexibleSpace: FlexibleSpaceBar(
title: const Text('可折叠标题'),
background: Container(
color: Colors.purple,
child: Center(
child: Icon(Icons.star, size: 80, color: Colors.white.withOpacity(0.3)),
),
),
),
// 根据内容滚动状态决定是否显示阴影
forceElevated: innerBoxIsScrolled,
),
];
},
运行效果:

在 NestedScrollView 中,使用 floating、pinned、snap 等属性的 SliverAppBar 时,会产生 "重叠" 问题:
AppBar 在展开/收起过程中可能遮挡内容、内层滚动视图的内容可能显示在 AppBar 下方、滚动对齐不正确。
为了避免 body 的初始内容被 Header 遮挡,你需要使用这两对组合:
- SliverOverlapInjector :将它作为 headerSliverBuilder 返回的 Sliver 列表中的一个父组件,包裹住其他 Sliver。它会捕获其子 Sliver (如 SliverAppBar) 所占据的重叠量。
- SliverOverlapInjector :在 body 内部的可滚动视图,将它作为第一个Sliver,它会将SliverOverlapAbsorber 捕获到的重叠量作为内边距,应用到其内部,从而将 body内容往下挪,避免被遮挡。
用法示例 (注:两者必须使用同一个 SliverOverlapAbsorberHandle):
dart
// 在 headerSliverBuilder 中,套Sliver组件
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(...), // 包装会产生重叠的组件
)
// 在 body 的每个滚动视图中,在 TabBarView 的每个 Tab 中都要使用 SliverOverlapInjector
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
)
另外,你可能发现滑动后,且Tab到别的页面,回来后,第一页的 滑动距离没有保持 ,给 CustomScrollView 的 key 属性设置一个 PageStorageKey 的对象 (唯一标识) 即可解决。原理是 ScrollPosition 类中通过 PageStorage 组件进行滑动进度的读写。
💡 Tips: 在 body 的 TabBarView 中为每个 ListView 都提供了自己的 ScrollController,会导致NestedScrollView 的协调机制 失效!
4.4.2. 源码剖析
核心大脑 _NestedScrollCoordinator (协调器):
dart
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { ... }
它管理两个独立的 ScrollController: _outerController 和 _innerController:

协调内外滚动位置的同步、处理手势分发和滚动事件。滚动分发的核心算法 (用户开始拖拽-drag() → 拖拽更新-applyUserOffset() ):
- 向上拖拽 (delta < 0):优先消除内层 overscroll → 然后滚动外层 → 最后滚动内层。
- 向下拖拽 (delta > 0):根据 floatHeaderSlivers 决定是否优先滚动外层。
然后是 惯性滚动处理,当手指离开屏幕:

然后是关键的 _getMetrics() ,它创建统一的滚动指标,让内外两个独立的滚动视图在物理模拟时表现得像一个整体。最后是为了解决 SliverAppBar 重叠问题的 SliverOverlapAbsorber/Injector 机制:

在 RenderSliverOverlapInjector.performLayout() 中:

画个调用时序图帮助理解:

用户手势 → NestedScrollCoordinator → 智能分析 → 分发给合适的滚动组件
4.5. Notification通知机制
ScrollNotification 是一个抽象类,用于 在滚动相关事件发生时发出通知,它是整个滚动通知系统的核心基类。
scala
abstract class ScrollNotification extends LayoutChangedNotification with ViewportNotificationMixin {
ScrollNotification({
required this.metrics,
required this.context,
});
final ScrollMetrics metrics; // 滚动指标信息
final BuildContext? context; // 触发通知的组件上下文
}
核心属性:
- metrics:ScrollMetrics,描述滚动视图内容的详细信息,包含:pixels(当前位置)、minScrollExtent(最小滚动范围)、maxScrollExtent(最大滚动范围)、viewportDimension(视口尺寸)等。
- context:触发此通知的 Widget 的构建上下文,可用于查找滚动组件的渲染对象,确定视口大小等。
- depth:继承自 ViewportNotificationMixin,表示通知冒泡经过的视口数量,通常监听器只响应 depth 为 0 的通知 (本地通知)。
它的几个字类:
dart
// 开始滚动
class ScrollStartNotification extends ScrollNotification {
final DragStartDetails? dragDetails; // 拖拽开始详情
}
// 滚动更新
class ScrollUpdateNotification extends ScrollNotification {
final DragUpdateDetails? dragDetails; // 拖拽更新详情
final double? scrollDelta; // 滚动距离增量
}
// 过渡滚动
class OverscrollNotification extends ScrollNotification {
final double overscroll; // 过度滚动的像素数
final double velocity; // 滚动速度
}
// 滚动结束
class ScrollEndNotification extends ScrollNotification {
final DragEndDetails? dragDetails; // 拖拽结束详情
}
// 用户滚动方向改变
class UserScrollNotification extends ScrollNotification {
final ScrollDirection direction; // 滚动方向
}
// 用户停止交互
class UserScrollNotification extends ScrollNotification {
final ScrollDirection direction; // 处于 idle
}
使用示例:
dart
// 使用 NotificationListener<ScrollNotification> 包裹滑动视图进行监听
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification is ScrollUpdateNotification) {
print('滚动位置: ${notification.metrics.pixels}');
}
return false; // 不消费通知,继续向上传播
},
child: ListView(...),
)
相比 ScrollController ,ScrollNotification 的优势在于 "解耦 " 和 "事件驱动",具体表现在:
- 任何父组件都可以通过 NotificationListener 监听到其子孙树中任何滚动组件的事件,无需获取该滚动组件的 ScrollController,这使得父组件与子滚动组件间没有强依赖关系,代码更清晰、更易维护。
- NotificationListener 提供了丰富且详细的事件类型,让你能精确地知道滚动的具体阶段。而ScrollController.addListener() 只有一个通用的"滚动变动"通知,你无法直接区分是用户拖动、惯性滑动还是代码驱动的滚动。
- ScrollNotification 是一种标准的、自下而上 的"事件冒泡机制 ",而 ScrollController 则主要用于自上而下的 控制,监听只是其附加功能。
😁 ScrollNotification 的事件来源可以分为两种:
dart
// 用户手势交互
1. 用户拖拽屏幕 -> GestureDetector 识别
2. 手势生成 DragStartDetails, DragUpdateDetails, DragEndDetails
3. 这些手势信息传递给 ScrollActivity
// ② 程序化滚动
// ScrollController 主动调用
controller.animateTo(100.0); // 程序控制滚动
controller.jumpTo(200.0); // 立即跳转
然后 Scrollable 是所有滚动组件的基础,其内部事件生成流程:
- 手势识别 : RawGestureDetector 识别用户手势
- 活动创建 : 根据手势类型创建不同的 ScrollActivity (DragScrollActivity -用户拖拽、DrivenScrollActivity -程序滚动、BallisticScrollActivity-惯性滚动)。
- 位置更新: ScrollPosition 根据活动更新滚动位置。
- 通知分发 : ScrollPosition 创建并分发 ScrollNotification。
ScrollPosition 是事件创建的核心,关键源码:
dart
abstract class ScrollPosition {
/// 开始滚动时调用
void didStartScroll() {
// 实际上是通过 activity 来分发通知的!
activity!.dispatchScrollStartNotification(copyWith(), context.notificationContext);
}
/// 滚动更新时调用
void didUpdateScrollPositionBy(double delta) {
// 通过 activity 分发滚动更新通知
activity!.dispatchScrollUpdateNotification(copyWith(), context.notificationContext!, delta);
}
/// 滚动结束时调用
void didEndScroll() {
// 通过 activity 分发滚动结束通知,并保存偏移量
activity!.dispatchScrollEndNotification(copyWith(), context.notificationContext!);
saveOffset();
if (keepScrollOffset) {
saveScrollOffset();
}
}
/// 过度滚动时调用
void didOverscrollBy(double value) {
assert(activity!.isScrolling);
// 通过 activity 分发过度滚动通知
activity!.dispatchOverscrollNotification(copyWith(), context.notificationContext!, value);
}
/// 用户滚动方向改变时调用
void didUpdateScrollDirection(ScrollDirection direction) {
// 直接创建 UserScrollNotification 并分发
UserScrollNotification(
metrics: copyWith(),
context: context.notificationContext!,
direction: direction
).dispatch(context.notificationContext);
}
/// 滚动指标更新时调用
void didUpdateScrollMetrics() {
assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
assert(_haveScheduledUpdateNotification);
_haveScheduledUpdateNotification = false;
if (context.notificationContext != null) {
// 分发滚动指标变化通知
ScrollMetricsNotification(
metrics: copyWith(),
context: context.notificationContext!
).dispatch(context.notificationContext);
}
}
}
接着是 事件传播的链路:
dart
ScrollPosition.didUpdateScrollPositionBy()
↓
ScrollPosition._dispatch(ScrollUpdateNotification(...))
↓
BuildContext.dispatchNotification(notification)
↓
Element._notificationTree.dispatch(notification)
↓
向上遍历 Element 树
↓
查找 NotificationListener<ScrollNotification>
↓
执行 onNotification 回调
↓
根据返回值决定是否继续传播