ViewportBuilder
// 示例可以假设:
// late BuildContext context;
/// [Scrollable] 用来构建通过其显示可滚动内容的视口的签名。
typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);
Scrollable是 Flutter 中用于处理滚动的控件。- 这个签名定义了如何通过传入的
BuildContext和ViewportOffset来构建视口,该视口用于显示滚动内容。
TwoDimensionalViewportBuilder
/// 用于 [TwoDimensionalScrollable] 构建视口的函数签名,
/// 可滚动内容通过该视口进行显示。
typedef TwoDimensionalViewportBuilder = Widget Function(
BuildContext context,
ViewportOffset verticalPosition, // 垂直方向滚动位置控制
ViewportOffset horizontalPosition, // 水平方向滚动位置控制
);
_EnsureVisibleResults
// _performEnsureVisible 的返回类型。
//
// futures 列表表示每一个尚未完成的 ScrollPosition.ensureVisible 调用。
// 返回的 ScrollableState 的 context 被用来查找下一个可能的祖先 Scrollable。
typedef _EnsureVisibleResults = (List<Future<void>>, ScrollableState);
Scrollable
/// 一个在单一维度上管理滚动并通知 [Viewport] 来展示内容的组件。
///
/// [Scrollable] 实现了滚动组件的交互模型,包括手势识别,
/// 但它并不关心真正展示子组件的视口(viewport)是如何构建的。
///
/// 很少会直接构建一个 [Scrollable]。通常你会使用 [ListView] 或 [GridView],
/// 它们结合了滚动、视口以及布局模型。如果你想组合不同的布局模型
/// (或使用自定义的布局模式),可以考虑使用 [CustomScrollView]。
///
/// [Scrollable.of] 和 [Scrollable.ensureVisible] 这两个静态函数通常用于
/// 在 [ListView] 或 [GridView] 内与 [Scrollable] 小部件交互。
///
/// 想要进一步自定义 [Scrollable] 的滚动行为:
///
/// 1. 你可以提供一个 [viewportBuilder] 来定制子组件的模型。
/// 例如,[SingleChildScrollView] 使用一个只显示单个 box 子组件的视口,
/// 而 [CustomScrollView] 使用 [Viewport] 或 [ShrinkWrappingViewport],
/// 它们都能展示一个 sliver 列表。
///
/// 2. 你可以提供一个自定义的 [ScrollController],并由它创建一个自定义的
/// [ScrollPosition] 子类。例如,[PageView] 使用 [PageController],
/// 它会创建一个面向“页面”的滚动位置子类,
/// 从而保证在 [Scrollable] 调整大小时,仍然保持同一页可见。
///
/// ## 在会话期间保持滚动位置
///
/// Scrollable 会尝试使用 [PageStorage] 来保存滚动位置。
/// 可以通过将 [controller] 的 [ScrollController.keepScrollOffset] 设置为 false 来禁用这一行为。
/// 如果启用,建议为此 widget(或其祖先,如 [ScrollView])提供一个 [PageStorageKey] 作为 [key],
/// 以帮助区分不同的 [Scrollable] 实例。
///
/// 参见:
///
/// * [ListView]:常用的 [ScrollView],显示一个线性滚动列表。
/// * [PageView]:显示一组与视口大小相同的子组件,可分页滚动。
/// * [GridView]:[ScrollView] 的一种,显示二维网格状的子组件。
/// * [CustomScrollView]:[ScrollView] 的一种,可以通过 slivers 实现自定义滚动效果。
/// * [SingleChildScrollView]:只有单个子组件的可滚动组件。
/// * [ScrollNotification] 和 [NotificationListener]:
/// 不依赖 [ScrollController] 也能监听滚动位置。
class Scrollable extends StatefulWidget {
/// Creates a widget that scrolls.
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,
this.scrollBehavior,
this.clipBehavior = Clip.hardEdge,
this.hitTestBehavior = HitTestBehavior.opaque,
}) : assert(semanticChildCount == null || semanticChildCount >= 0);
}
作用
Scrollable 是 Flutter 所有可滚动组件的底层基础类,它本身不直接定义 UI 布局,而是提供:
- 滚动手势处理(比如拖动、惯性滚动)。
- 滚动位置管理(通过
ScrollController和ScrollPosition)。 - 与
Viewport的协作(由viewportBuilder决定具体如何渲染子组件)。
但是,它不直接决定:
- 子组件的布局方式(列表、网格、单子项)。
- 具体的视口实现(
Viewport、ShrinkWrappingViewport等)。
所以大多数时候不会直接用 Scrollable,而是用它的上层封装:
ListView、GridView(内置列表和网格布局)。CustomScrollView(灵活组合 sliver,做复杂的滚动效果)。SingleChildScrollView(单个子组件可滚动)。
controller
/// {@template flutter.widgets.Scrollable.controller}
/// 一个可以用来控制此组件滚动位置的对象。
///
/// [ScrollController] 有多个用途:
/// - 可以用来控制初始滚动位置(参见 [ScrollController.initialScrollOffset])。
/// - 可以用来控制滚动视图是否应该在 [PageStorage] 中自动保存和恢复滚动位置
/// (参见 [ScrollController.keepScrollOffset])。
/// - 可以用来读取当前的滚动位置(参见 [ScrollController.offset]),
/// 或者修改它(参见 [ScrollController.animateTo])。
///
/// 如果为 null,[Scrollable] 会在内部创建一个 [ScrollController],
/// 以便创建和管理 [ScrollPosition]。
///
/// 参见:
/// * [Scrollable.ensureVisible]:它会通过动画滚动到指定的 [BuildContext],
/// 以确保目标 widget 可见。
/// {@endtemplate}
final ScrollController? controller;
viewportBuilder(重要)
/// 构建用于显示可滚动内容的视口(viewport)。
///
/// 一个典型的视口会使用提供的 [ViewportOffset] 来决定
/// 其内容的哪一部分实际处于可见范围之内。
///
/// 参见:
///
/// * [Viewport]:一种视口,用于显示一组 sliver。
/// * [ShrinkWrappingViewport]:一种视口,用于显示一组 sliver,
/// 并且会根据这些 sliver 的大小自动调整自身大小。
final ViewportBuilder viewportBuilder;
📌 解释
viewportBuilder:就是一个函数,用来构建 视口 (Viewport) 。- Viewport 的职责:决定在滚动的某一时刻,哪些内容应该被绘制(可见),哪些在可见范围外则不绘制(提升性能)。
- ViewportOffset:代表滚动位置(比如滚动多少像素),
Viewport会用它来判断“现在内容的哪一段要显示”。
👉 换句话说:
Scrollable负责滚动交互viewportBuilder决定“滚动时具体显示什么内容”
maybeOf
/// 返回包裹给定 [context] 的最近的该类实例对应的状态 [ScrollableState],
/// 如果找不到则返回 null。
///
/// 典型的用法如下:
///
/// ```dart
/// ScrollableState? scrollable = Scrollable.maybeOf(context);
/// ```
///
/// 调用此方法时,如果找到了 [ScrollableState],
/// 将会在返回的 [ScrollableState] 上创建依赖关系。
/// 通常这会是最近的 [Scrollable],
/// 但如果使用了 [axis] 参数来指定目标滚动方向,
/// 那么也可能是一个更远的祖先 [Scrollable]。
///
/// 当滚动视图(Scrollables)是嵌套的时,传入可选的 [Axis] 参数会很有用,
/// 因为目标 [Scrollable] 可能并不是离得最近的实例。
/// 如果提供了 [axis],则会返回该方向上最近的 [ScrollableState],
/// 如果没有则返回 null。
///
/// 注意,这个方法只会查找 `context` 的最近 **祖先** [Scrollable]。
/// 这意味着如果 `context` 本身就是一个 [Scrollable] 的话,
/// 它不会返回该 [Scrollable] 自己。
///
/// 另见:
///
/// * [Scrollable.of]:与此方法类似,但如果找不到任何 [Scrollable] 祖先会抛出异常。
_ScrollableScope
// 使得 Scrollable.of() 能像使用 InheritedWidget 一样工作,
// 通过依赖 _ScrollableScope 来访问 ScrollableState。
// ScrollableState.build() 总是会重新构建它的 _ScrollableScope。
class _ScrollableScope extends InheritedWidget {
const _ScrollableScope({
required this.scrollable,
required this.position,
required super.child,
});
/// 当前的 ScrollableState
final ScrollableState scrollable;
/// 对应的滚动位置 ScrollPosition
final ScrollPosition position;
@override
bool updateShouldNotify(_ScrollableScope old) {
// 当滚动位置改变时,通知依赖此 InheritedWidget 的子 widget 重建
return position != old.position;
}
}
📌 解释
-
作用
_ScrollableScope是一个 InheritedWidget,包装了ScrollableState和ScrollPosition。- 它的主要目的是让
Scrollable.of(context)/maybeOf(context)可以通过context.dependOnInheritedWidgetOfExactType来找到最近的ScrollableState。
-
为什么要用 InheritedWidget
- 通过 InheritedWidget,子 widget 可以 自动依赖滚动状态,当滚动位置变化时自动重建(通过
updateShouldNotify判断)。 - 保证了嵌套滚动场景中,子 widget 可以正确获取滚动信息。
- 通过 InheritedWidget,子 widget 可以 自动依赖滚动状态,当滚动位置变化时自动重建(通过
-
updateShouldNotify
- 当
position(滚动位置)变化时返回 true,通知依赖此 scope 的 widget 重新 build。 - 这样可以保证需要知道滚动位置的子 widget 能够随滚动更新。
- 当
ScrollableState
/// [Scrollable] 小部件的 State 对象。
///
/// 要操作一个 [Scrollable] 小部件的滚动位置,
/// 可以使用从 [position] 属性获取到的对象。
///
/// 如果想要在 [Scrollable] 滚动时收到通知,
/// 可以通过 [NotificationListener] 来监听 [ScrollNotification] 通知。
///
/// 这个类不打算被继承(即不推荐自定义子类)。
/// 如果想要定制 [Scrollable] 的行为,应通过提供一个 [ScrollPhysics] 来实现。
class ScrollableState extends State<Scrollable>
// 混入 [TickerProviderStateMixin],允许该 State 提供 [Ticker],
// 主要用于支持滚动动画(比如 [animateTo] 的动画会依赖 Ticker)。
with TickerProviderStateMixin, RestorationMixin
// 实现 [ScrollContext] 接口,ScrollContext 是一个协议接口,
// 定义了滚动组件所需的上下文能力,例如获取 vsync、配置 ScrollPhysics 等。
implements ScrollContext {}
-
Scrollable与ScrollableState的关系Scrollable是 Flutter 内部滚动体系的“最底层可滚动 widget”。- 它本身是一个 StatefulWidget,所以会有对应的
State→ 也就是这里的ScrollableState。 - 几乎所有 Flutter 的滚动组件(如
ListView、GridView、SingleChildScrollView)都是基于Scrollable封装的。
-
position属性的作用-
ScrollableState内部会管理一个ScrollPosition对象(即position属性)。 -
通过
position,我们可以直接控制滚动条,比如:scrollableState.position.jumpTo(200); scrollableState.position.animateTo(500, duration: Duration(milliseconds: 300), curve: Curves.easeOut); -
这就是为什么文档说:要操作滚动位置,用
position。
-
-
滚动事件的监听 (
NotificationListener)- 滚动时,
Scrollable会分发ScrollNotification。 - 我们可以用
NotificationListener<ScrollNotification>包裹滚动组件,来获取滚动事件(比如监控滚动百分比,触发上拉加载)。
- 滚动时,
-
为什么不推荐继承
ScrollableState?ScrollableState是 Flutter 的核心实现,不希望用户去修改。- 官方推荐的扩展方式是:通过 传入不同的
ScrollPhysics控制滚动物理特性(比如惯性、回弹、边界处理等),而不是继承ScrollableState。
-
TickerProviderStateMixin的作用- 滚动动画(
animateTo、惯性滑动、回弹效果)需要用到Ticker来驱动帧刷新。 TickerProviderStateMixin让ScrollableState可以给动画控制器 (AnimationController) 提供vsync,避免不必要的性能消耗。
- 滚动动画(
-
RestorationMixin的作用- Flutter 的 State Restoration(状态恢复机制) ,用来在应用被杀掉或切后台时保存/恢复滚动位置。
- 有了它,即使 App 重新启动,列表也能自动回到之前的滚动位置。
-
ScrollContext接口-
定义了滚动上下文环境,比如:
axisDirection(滚动方向:上下/左右)notificationContext(发送滚动通知的 BuildContext)vsync(动画驱动器)userScrollDirection(用户滚动方向)
-
ScrollableState实现了它,方便ScrollPosition和Scrollable进行解耦。
-
position
/// 此 [Scrollable] 组件的视口位置(viewport position)的管理器。
///
/// 如果要控制 [Scrollable] 创建哪种类型的 [ScrollPosition],
/// 可以提供一个自定义的 [ScrollController],
/// 并在其 [ScrollController.createScrollPosition] 方法中
/// 创建并返回合适的 [ScrollPosition]。
ScrollPosition get position => _position!;
ScrollPosition? _position;
-
position代表 这个 Scrollable 的滚动位置管理器; -
如果开发者希望改变默认的
ScrollPosition行为(比如分页滚动、特殊的边界处理),就需要自己实现一个ScrollController,并在其中的createScrollPosition方法返回自定义的ScrollPosition。
resolvedPhysics
/// [ScrollableState] 已解析(resolved)的 [ScrollPhysics]。
ScrollPhysics? get resolvedPhysics => _physics;
ScrollPhysics? _physics;
-
resolvedPhysics表示 最终生效的滚动物理特性(ScrollPhysics) 。 -
ScrollPhysics控制滚动的行为,比如:- 是否可以继续拖拽超出边界(overscroll/bounce)
- 滑动的减速方式(摩擦力、惯性)
- 滚动是否允许用户交互
-
Flutter 会把
Scrollable配置的 physics 和系统默认的 physics 进行合并(resolve),得到一个最终版本_physics,也就是resolvedPhysics。
deltaToScrollOrigin
/// 一个 [Offset],表示 [ScrollPosition] 到原点(0)的绝对距离,
/// 并且是根据关联的 [Axis](滚动方向轴)来表达的。
///
/// 该属性被 [EdgeDraggingAutoScroller] 使用,
/// 当拖拽手势到达 [Viewport] 边缘时,
/// 用来推动(推进)滚动位置向前移动。
Offset get deltaToScrollOrigin => switch (axisDirection) {
AxisDirection.up => Offset(0, -position.pixels),
AxisDirection.down => Offset(0, position.pixels),
AxisDirection.left => Offset(-position.pixels, 0),
AxisDirection.right => Offset(position.pixels, 0),
};
_effectiveScrollController
ScrollController get _effectiveScrollController =>
widget.controller ?? _fallbackScrollController!;
devicePixelRatio
@override
double get devicePixelRatio => _devicePixelRatio;
late double _devicePixelRatio;
_fallbackScrollController
ScrollController? _fallbackScrollController;
initState
@protected
@override
void initState() {
if (widget.controller == null) {
_fallbackScrollController = ScrollController();
}
super.initState();
}
-
if (widget.controller == null)
👉 检查开发者是否在Scrollablewidget 外部传入了一个ScrollController。 -
_fallbackScrollController = ScrollController();
👉 如果开发者没有提供controller,那么框架会自己创建一个备用的ScrollController,保证Scrollable总是有一个ScrollController可用。
didChangeDependencies
@protected
@override
void didChangeDependencies() {
// 从 MediaQuery 中获取手势相关的配置(如滚动灵敏度、触控设置等)
// 如果 MediaQuery 不存在,则返回 null。
_mediaQueryGestureSettings = MediaQuery.maybeGestureSettingsOf(context);
// 获取当前设备的像素比(DPR, Device Pixel Ratio)
// 优先从 MediaQuery 获取,如果没有 MediaQuery,就从 View 拿设备像素比。
// 用于保证滚动位置、坐标计算在不同分辨率下保持正确。
_devicePixelRatio =
MediaQuery.maybeDevicePixelRatioOf(context) ?? View.of(context).devicePixelRatio;
// 依赖环境可能发生了变化,需要刷新/更新 ScrollPosition。
// 比如屏幕旋转、分辨率变化时,重新计算滚动位置。
_updatePosition();
// 调用父类 didChangeDependencies,保证框架自身逻辑也能正确执行。
super.didChangeDependencies();
}
_updatePosition(核心方法),创建的是 ScrollPositionWithSingleContext对象(抽象类ScrollPosition的具体实现)
// 只在 **一定会触发重建** 的地方调用该方法。
void _updatePosition() {
// 获取 ScrollConfiguration 配置:
// 1. 优先使用 widget.scrollBehavior(外部传入的配置)
// 2. 否则使用 ScrollConfiguration.of(context) (全局默认配置)
_configuration = widget.scrollBehavior ?? ScrollConfiguration.of(context);
// 从 widget 上获取 ScrollPhysics(滚动物理特性),可能为空
// 物理特性决定了滚动的惯性、边界回弹等行为。
final ScrollPhysics? physicsFromWidget =
widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context);
// 基础 physics 来自 ScrollConfiguration
_physics = _configuration.getScrollPhysics(context);
// 如果 widget 提供了 physics,则将其叠加到基础 physics 上
// (通过 applyTo 形成链式组合)
_physics = physicsFromWidget?.applyTo(_physics) ?? _physics;
// 保存当前旧的 ScrollPosition
final ScrollPosition? oldPosition = _position;
if (oldPosition != null) {
// 先把旧的 position 从 controller 上分离
_effectiveScrollController.detach(oldPosition);
// ⚠️ 注意:不能立即 dispose 旧的 position
// 因为 viewport 可能还没完全解除它的监听,
// 所以延迟到 microtask 队列再释放,避免潜在的监听异常。
scheduleMicrotask(oldPosition.dispose);
}
// 创建新的 ScrollPosition:
// 传入物理特性(_physics)、上下文(this)、旧的 position(可能为 null)
_position = _effectiveScrollController.createScrollPosition(_physics!, this, oldPosition);
// 确保 position 一定不为空
assert(_position != null);
// 将新的 position 重新 attach 到 controller
_effectiveScrollController.attach(position);
}
- 重新解析滚动物理特性 (ScrollPhysics) 。
- 销毁旧的 ScrollPosition,创建并替换为新的 ScrollPosition。
- 保证 controller 和新的 position 建立连接。
👉 这个过程通常会发生在:
- 依赖更新时(如
didChangeDependencies,屏幕旋转 / DPI 变化); - Widget 树重建时(比如
Scrollable被重新构建)。
已经将position从_positions中移除了。那么scheduleMicrotask(oldPosition.dispose);里的oldPosition还存在吗?
_effectiveScrollController.detach(oldPosition) 会把 oldPosition 从 _positions 集合中移除,那么紧接着 scheduleMicrotask(oldPosition.dispose) 时,这个 oldPosition 对象是不是就没了?
答案是:oldPosition 依然存在,而且不会是 null。原因如下:
1. Dart 的对象生命周期
在 Dart 里,一个对象是否还存在,取决于是否还有“强引用”指向它。
- 当你调用
detach时,_positions.remove(position)只是把 controller 对该 position 的引用去掉。 - 但是在
_updatePosition方法里,局部变量oldPosition依然强引用着它。 - 这意味着,在方法执行完之前,
oldPosition肯定不会被 GC(垃圾回收)掉。
所以 scheduleMicrotask(oldPosition.dispose) 里传递的 oldPosition 是一个有效的对象。
2. 为什么用 scheduleMicrotask
这里的关键点是:
- 不能立刻 dispose,否则可能会打断
Viewport等还在访问旧 position 的逻辑。 - 使用
scheduleMicrotask,是把dispose延迟到 当前事件循环结束后,即所有本次布局/绘制相关的操作完成之后再执行。 - 此时旧的
position已经完全“没人用了”,再销毁就安全了。
3. dispose 时对象的状态
- 在
detach后,oldPosition已经不再属于 controller 的_positions集合了。 - 但对象本身依然活着(因为
oldPosition变量持有它)。 - 到 microtask 执行时,
oldPosition.dispose()才真正清理内部资源(listener、controller reference 等)。
结论
所以流程是这样的:
oldPosition从 controller 中移除(不再被_positions持有)。oldPosition依然存在,因为方法里局部变量持有它。scheduleMicrotask延迟到本轮事件循环结束后调用dispose。dispose才最终释放旧 position 的内部状态,GC 之后才能真正清掉对象。
补充微任务(scheduleMicrotask)
scheduleMicrotask 的执行时机
在 Dart(Flutter)中:
- **微任务队列(microtask queue)和事件队列(event queue)**是两个不同的队列。
- 当前同步代码执行完毕后,微任务队列中的所有任务会依次执行。
- **事件队列(比如定时器、UI事件等)**在微任务队列清空后才会执行。
关键点:
scheduleMicrotask并不是“立刻执行”,它是安排在当前同步代码之后、下一次事件循环前执行。- 它的作用就是 延迟执行,确保当前函数
_updatePosition的同步逻辑先走完。
_updatePosition 中为什么要用 scheduleMicrotask?
-
detach(oldPosition):把oldPosition从_effectiveScrollController._positions中移除。 -
此时 viewport 还可能有对
oldPosition的监听器,例如ScrollPositionListener。 -
如果你直接调用
oldPosition.dispose():- 会立即清理所有监听器。
- 可能会导致 viewport 内部还在访问 oldPosition 时发生错误(比如 null pointer 或监听器未清理完毕)。
-
使用
scheduleMicrotask可以等_updatePosition的同步逻辑完成后再 dispose,确保安全。
所以微任务 并不会在“所有任务前”执行,而是在当前同步方法结束后、下一次事件循环之前执行。
执行顺序总结
假设 _updatePosition 被调用:
- 执行同步代码
_configuration = ...、_physics = ...。 detach(oldPosition)移除旧的 ScrollPosition。scheduleMicrotask(oldPosition.dispose)将dispose放入微任务队列。_position = createScrollPosition(...)和attach(position)执行。- 当前
_updatePosition函数返回,同步代码结束。 - 微任务队列执行 →
oldPosition.dispose()被调用。 - 之后才处理事件队列中的其他任务(UI、Timer、I/O 等)。
✅ 所以这是 Flutter 官方安全做法,不会导致 dispose 太早。
didUpdateWidget
@protected
@override
void didUpdateWidget(Scrollable oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
if (oldWidget.controller == null) {
// 旧的 controller 为 null,这意味着回退控制器不能为 null。
// 释放回退控制器。
assert(_fallbackScrollController != null);
assert(widget.controller != null);
_fallbackScrollController!.detach(position);
_fallbackScrollController!.dispose();
_fallbackScrollController = null;
} else {
// 旧的 controller 不为 null,则将其分离(detach)。
oldWidget.controller?.detach(position);
if (widget.controller == null) {
// 如果新的 controller 为 null,则需要创建回退 ScrollController。
_fallbackScrollController = ScrollController();
}
}
// 绑定(attach)更新后的有效 ScrollController。
_effectiveScrollController.attach(position);
}
if (_shouldUpdatePosition(oldWidget)) {
_updatePosition();
}
}
_shouldUpdatePosition
bool _shouldUpdatePosition(Scrollable oldWidget) {
// 如果 scrollBehavior 是否为 null 与旧 widget 不一致,则需要更新位置
if ((widget.scrollBehavior == null) != (oldWidget.scrollBehavior == null)) {
return true;
}
// 如果 scrollBehavior 都不为 null,且新旧 scrollBehavior 的 shouldNotify 返回 true,则需要更新位置
if (widget.scrollBehavior != null &&
oldWidget.scrollBehavior != null &&
widget.scrollBehavior!.shouldNotify(oldWidget.scrollBehavior!)) {
return true;
}
// 获取新旧 ScrollPhysics(优先使用 widget.physics,如果没有则使用 scrollBehavior 提供的 physics)
ScrollPhysics? newPhysics = widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context);
ScrollPhysics? oldPhysics =
oldWidget.physics ?? oldWidget.scrollBehavior?.getScrollPhysics(context);
// 遍历 physics 链,比较 runtimeType 是否不同,有不同则需要更新位置
do {
if (newPhysics?.runtimeType != oldPhysics?.runtimeType) {
return true;
}
newPhysics = newPhysics?.parent;
oldPhysics = oldPhysics?.parent;
} while (newPhysics != null || oldPhysics != null);
// 最后,如果 controller 类型不同,也需要更新位置
return widget.controller?.runtimeType != oldWidget.controller?.runtimeType;
}
build
Widget build(BuildContext context) {
assert(_position != null);
// _ScrollableScope 必须放在 notificationContext 返回的 BuildContext 之上,
// 这样我们才能通过下面的方式获取到这个 ScrollableState:
//
// ScrollNotification notification;
// Scrollable.of(notification.context)
//
// 由于 notificationContext 指向的是 _gestureDetectorKey.context,
// 所以 _ScrollableScope 必须放在使用它的 widget(RawGestureDetector)之上。
Widget result = _ScrollableScope(
scrollable: this,
position: position,
child: Listener(
onPointerSignal: _receivedPointerSignal,
child: RawGestureDetector(
key: _gestureDetectorKey,
gestures: _gestureRecognizers,
behavior: widget.hitTestBehavior,
excludeFromSemantics: widget.excludeFromSemantics,
child: Semantics(
explicitChildNodes: !widget.excludeFromSemantics,
child: IgnorePointer(
key: _ignorePointerKey,
ignoring: _shouldIgnorePointer,
child: widget.viewportBuilder(context, position),
),
),
),
),
);
if (!widget.excludeFromSemantics) {
result = NotificationListener<ScrollMetricsNotification>(
onNotification: _handleScrollMetricsNotification,
child: _ScrollSemantics(
key: _scrollSemanticsKey,
position: position,
allowImplicitScrolling: _physics!.allowImplicitScrolling,
axis: widget.axis,
semanticChildCount: widget.semanticChildCount,
child: result,
),
);
}
result = _buildChrome(context, result);
// 仅当存在父级 registrar 时才启用 selection。
final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context);
if (registrar != null) {
result = _ScrollableSelectionHandler(
state: this,
position: position,
registrar: registrar,
child: result,
);
}
return result;
}
_receivedPointerSignal
void _receivedPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent && _position != null) {
if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) {
// 处理器不会使用这个 `event`,因此允许平台触发任何默认的原生行为。
event.respond(allowPlatformDefault: true);
return;
}
final double delta = _pointerSignalEventDelta(event);
final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
// 只有当事件真正会引起滚动时,才对它表示感兴趣。
if (delta != 0.0 && targetScrollOffset != position.pixels) {
GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll);
return;
}
// 这个 `event` 不会引起滚动,因此允许平台触发任何默认的原生行为。
event.respond(allowPlatformDefault: true);
} else if (event is PointerScrollInertiaCancelEvent) {
position.pointerScroll(0);
// 不使用 pointer signal resolver,所有经过命中测试的 Scrollable 都应该停止。
}
}
_pointerSignalEventDelta
// 返回在考虑了 ScrollBehavior 指定的轴、方向以及任何修饰键之后,
// 应该由 [event] 产生的滚动增量(delta)。
double _pointerSignalEventDelta(PointerScrollEvent event) {
final Set<LogicalKeyboardKey> pressed = HardwareKeyboard.instance.logicalKeysPressed;
final bool flipAxes =
pressed.any(_configuration.pointerAxisModifiers.contains) &&
// 只有“物理鼠标滚轮”的输入才会触发轴翻转。
// 在某些平台(比如 Web)上,触控板(trackpad)的输入也通过 pointer signals 传递,
// 但不应该参与这种轴翻转行为。
// 这是因为触控板天然支持所有方向的滚动,而鼠标滚轮通常只在单一轴上滚动。
event.kind == PointerDeviceKind.mouse;
final Axis axis = flipAxes ? flipAxis(widget.axis) : widget.axis;
final double delta = switch (axis) {
Axis.horizontal => event.scrollDelta.dx,
Axis.vertical => event.scrollDelta.dy,
};
return axisDirectionIsReversed(widget.axisDirection) ? -delta : delta;
}
可以总结为三步,算出 delta 的原理就是:
-
确定取哪个轴的增量
- 默认按
widget.axis取dy(竖直)或dx(水平)。 - 如果用户按下了配置的“翻转修饰键”(如
Shift)并且输入来自 鼠标,则横竖轴互换,改为取另一方向的分量。
- 默认按
-
读取事件中的原始滚动量
- 从
PointerScrollEvent.scrollDelta里取对应方向的dx或dy。 - 这就是平台层提供的滚轮/触控板的原始偏移量。
- 从
-
根据
axisDirection调整正负号- 如果 Scrollable 的滚动方向是反向(
up/left),则取负值。 - 这样保证无论正向还是反向,
delta的符号语义都与 Scrollable 一致。
- 如果 Scrollable 的滚动方向是反向(
👉 归纳一句话:
delta =(按是否翻转确定轴向)取事件的 dx/dy → 再按滚动方向(正/反向)修正符号。
_targetScrollOffsetForPointerScroll
// SCROLL WHEEL
//
// 返回应用 [event] 到当前位置后应产生的偏移量,
// 同时考虑到最小/最大可滚动范围的限制。
double _targetScrollOffsetForPointerScroll(double delta) {
return math.min(
math.max(position.pixels + delta, position.minScrollExtent),
position.maxScrollExtent,
);
}
- SCROLL WHEEL → 滚轮(表示这是处理鼠标滚轮的逻辑)
- Returns the offset that should result from applying [event] to the current position, taking min/max scroll extent into account.
→ 返回将 [event] 应用到当前位置后应该得到的目标偏移量,同时会考虑最小/最大滚动范围。
方法逻辑解释
- 当前位置 →
position.pixels - 加上滚动增量 →
position.pixels + delta - 保证不小于最小滚动值 →
math.max(..., position.minScrollExtent) - 保证不大于最大滚动值 →
math.min(..., position.maxScrollExtent)
最终得到一个 合法的目标滚动位置,不会出现“超出内容边界”的情况。
_handlePointerScroll
void _handlePointerScroll(PointerEvent event) {
assert(event is PointerScrollEvent);
// 计算滚动事件对应的增量(delta),
// 这个值会根据滚动方向、轴、是否反转等因素计算出来。
final double delta = _pointerSignalEventDelta(event as PointerScrollEvent);
// 根据 delta 计算目标滚动位置,
// 并确保结果在 minScrollExtent 和 maxScrollExtent 范围内。
final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
// 只有当 delta 不为 0 且目标位置与当前位置不同的时候,
// 才真正触发滚动。
if (delta != 0.0 && targetScrollOffset != position.pixels) {
position.pointerScroll(delta);
}
}
position.pointerScroll(delta)真正滑动的方法
position.pointerScroll(delta) 是真正滑动到指定位置的方法。
position的对象是类型是ScrollPositionWithSingleContext对象