ScrollPositionAlignmentPolicy
ruby
/// 在应用 [ScrollPosition.ensureVisible] 的 `alignment` 参数时使用的策略。
enum ScrollPositionAlignmentPolicy {
/// 使用 [ScrollPosition.ensureVisible] 的 `alignment` 参数来决定
/// 目标对象在滚动后如何对齐。
explicit,
/// 找到滚动容器的底部边界,如果有需要则滚动容器,以保证对象的底部可见。
///
/// 例如,找到滚动容器的底部边界。如果目标项的底部在容器的底部边界之下,
/// 则滚动使该目标项的底部正好出现在容器的可见区域内。
/// 如果该目标项已经完全可见,则不进行滚动。
keepVisibleAtEnd,
/// 找到滚动容器的顶部边界,如果有需要则滚动容器,以保证对象的顶部可见。
///
/// 例如,找到滚动容器的顶部边界。如果目标项的顶部在容器的顶部边界之上,
/// 则滚动使该目标项的顶部正好出现在容器的可见区域内。
/// 如果该目标项已经完全可见,则不进行滚动。
keepVisibleAtStart,
}
是一个 枚举 ,主要用于控制 ScrollPosition.ensureVisible
在滚动时如何对齐目标 Widget。
1. explicit
- 行为 :完全依赖传入的
alignment
参数(0.0 表示顶部对齐,1.0 表示底部对齐,0.5 表示居中等)。 - 应用场景 :你希望 自定义精确的对齐方式。
2. keepVisibleAtEnd
- 行为 :如果目标 Widget 的 底部超出容器的可见范围 ,就滚动让它的底部正好显示在容器底部;
如果目标 Widget 已经完整可见,则不滚动。 - 应用场景 :适合 聊天窗口 这类场景,新消息进来时保持在底部。
3. keepVisibleAtStart
- 行为 :如果目标 Widget 的 顶部在容器上方不可见 ,就滚动让它的顶部正好显示在容器顶部;
如果目标 Widget 已经完整可见,则不滚动。 - 应用场景 :适合需要保持 列表头部元素可见 的情况,比如目录导航。
ScrollPosition
ScrollPosition
就是 记录和管理滚动视图当前滚到哪、能滚到哪、怎么滚的对象,它保存了偏移量、边界、滚动状态,并能通知监听器变化。
-
存数据 :保存当前滚动位置(
pixels
)、最大/最小能滚多远(maxScrollExtent
/minScrollExtent
)。 -
管滚动 :配合
physics
决定滚动方式(比如弹性、惯性)。 -
控行为 :通过
activity
表示现在是拖拽、惯性还是静止。 -
能监听 :
Listenable
,随时通知你「滚动发生了」。 -
配套用 :常和
ScrollController
、NotificationListener
一起使用。
scala
/// 确定在滚动视图中内容的哪一部分是可见的。
///
/// [pixels] 值决定了滚动视图的滚动偏移量,用于选择显示内容的哪一部分。
/// 当用户滚动视口时,这个值会发生变化,从而改变显示的内容。
///
/// [ScrollPosition] 会对滚动应用 [physics],并保存 [minScrollExtent] 和 [maxScrollExtent]。
///
/// 滚动由当前的 [activity] 控制,它是通过 [beginActivity] 设置的。
/// [ScrollPosition] 本身并不会启动任何 activity。
/// 相反,具体的子类(如 [ScrollPositionWithSingleContext])通常会在响应用户输入
/// 或 [ScrollController] 的指令时启动 activity。
///
/// 该对象是一个 [Listenable],当 [pixels] 发生变化时会通知监听器。
///
/// {@template flutter.widgets.scrollPosition.listening}
/// ### 获取滚动信息
///
/// 有几种方式可以获取有关滚动和可滚动组件的信息,
/// 但它们提供的信息类型各不相同,
/// 包括滚动行为、位置以及 [Viewport] 的尺寸等。
///
/// [ScrollController] 是一个 [Listenable]。
/// 当任意一个附加到它的 [ScrollPosition] 通知其监听器时,
/// 它也会通知自己的监听器(比如在滚动发生时)。
/// 这和使用 [ScrollNotification] 类型的 [NotificationListener]
/// 来监听滚动位置变化非常相似,但两者不同之处在于:
///
/// - [NotificationListener] 会提供有关"滚动活动"的信息;
/// - [ScrollController] 则可以直接访问滚动位置对象本身。
///
/// 另外,监听 [ScrollNotification] 还可以细分为监听其特定子类,
/// 比如 [UserScrollNotification]。
///
/// {@tool dartpad}
/// 这个示例展示了使用 [ScrollController] 与使用 [ScrollNotification] 类型的
/// [NotificationListener] 来监听滚动的区别。
/// 切换 [Radio] 按钮可以在两种方式间切换。
/// 使用 [ScrollNotification] 可以获取滚动活动的详细信息以及 [ScrollPosition] 的指标,
/// 但不能直接获取滚动位置对象本身;
/// 使用 [ScrollController] 则可以直接访问位置对象。
/// 这两种方式都只会在"滚动行为发生时"触发。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_notification.0.dart**
/// {@end-tool}
///
/// 注意:
/// [ScrollController] 不会在附加到它的 [ScrollPosition] 列表发生变化时通知监听器。
/// 如果需要监听滚动位置的附加和分离,可以使用 [ScrollController.onAttach] 和
/// [ScrollController.onDetach] 方法。
/// 这在 [Scrollable] 的 build 方法中创建滚动位置时,
/// 想要为其 [ScrollPosition.isScrollingNotifier] 添加监听器时也很有用。
///
/// 当一个滚动位置刚附加上时,
/// 它的 [ScrollMetrics](例如 [ScrollMetrics.maxScrollExtent])还不可用,
/// 因为这些值要等到 [Scrollable] 完成布局并计算内容范围后才会确定。
/// 可以通过 [ScrollPosition.hasContentDimensions] 来判断指标是否已可用,
/// 或者使用 [ScrollMetricsNotification](后面会详细讨论)。
///
/// {@tool dartpad}
/// 这个示例展示了如何通过 [ScrollController.onAttach]
/// 为 [ScrollPosition.isScrollingNotifier] 添加监听器。
/// 它用于在滚动发生时改变 [AppBar] 的颜色。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart**
/// {@end-tool}
///
/// #### 从不同的上下文中获取
///
/// 当需要在"滚动组件内部的上下文"中获取滚动信息时,
/// 可以使用 [Scrollable.of] 获取 [ScrollableState],
/// 然后通过 [ScrollableState.position] 获取 [ScrollPosition]。
/// 这个位置对象就是附加在 [ScrollController] 上的那个。
///
/// 当需要在"滚动组件外部的上下文"中获取滚动信息时,使用 [ScrollNotificationObserver]。
/// 例如 [AppBar] 的"滚动置底效果"就是这样实现的。
/// 因为 [Scaffold.appBar] 与 [Scaffold.body] 属于不同的子树,
/// 滚动通知不会冒泡到 app bar。
/// 可以通过 [ScrollNotificationObserverState.addListener] 监听在当前上下文之外发生的滚动通知。
///
/// #### 尺寸变化
///
/// 需要注意的是:
/// 监听 [ScrollController] 或 [ScrollPosition] **不会**在滚动指标([ScrollMetrics])发生变化时触发,
/// 比如窗口大小改变导致 [Viewport] 尺寸或可滚动范围发生变化。
/// 如果需要监听滚动指标的变化,应使用 [ScrollMetricsNotification]。
/// 与 [ScrollNotification] 不同,
/// [ScrollMetricsNotification] 并不与"滚动活动"相关,
/// 而是与滚动区域的尺寸变化有关(例如窗口大小)。
///
/// {@tool dartpad}
/// 这个示例展示了当 `windowSize` 改变时,
/// 如何派发一个 [ScrollMetricsNotification]。
/// 点击浮动按钮可以增加可滚动窗口的大小。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart**
/// {@end-tool}
/// {@endtemplate}
///
/// ## 继承 ScrollPosition
///
/// 随着时间推移,一个 [Scrollable] 可能会有多个不同的 [ScrollPosition] 对象。
/// 例如,当 [Scrollable.physics] 的类型发生变化时,
/// [Scrollable] 会使用新的 physics 创建一个新的 [ScrollPosition]。
/// 为了将旧实例的状态转移到新实例,子类需要实现 [absorb] 方法(详见 [absorb])。
///
/// 此外,子类在 [userScrollDirection] 值变化时还需要调用 [didUpdateScrollDirection]。
///
/// 参见:
///
/// * [Scrollable]:使用 [ScrollPosition] 来决定显示内容的哪一部分。
/// * [ScrollController]:可与 [ListView]、[GridView] 等可滚动组件一起使用,用于控制 [ScrollPosition]。
/// * [ScrollPositionWithSingleContext]:最常用的 [ScrollPosition] 具体子类。
/// * [ScrollNotification] 和 [NotificationListener]:在不使用 [ScrollController] 的情况下监听滚动位置。
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {}
ScrollPosition
kotlin
/// 创建一个对象,用来确定滚动视图中哪一部分内容是可见的。
ScrollPosition({
required this.physics, // 滚动物理特性(决定滚动行为,比如惯性、回弹等)
required this.context, // 上下文信息(通常是 Scrollable 的 BuildContext)
this.keepScrollOffset = true, // 是否保持滚动偏移量(通常用于恢复滚动位置)
ScrollPosition? oldPosition, // 旧的滚动位置对象,用来在重建时继承状态
this.debugLabel, // 调试标签
}) {
if (oldPosition != null) {
absorb(oldPosition); // 如果有旧的滚动位置,吸收它的状态
}
if (keepScrollOffset) {
restoreScrollOffset(); // 如果需要保持滚动偏移,就尝试恢复滚动位置
}
}
}
当创建一个 ScrollPosition
时,它会做三件事:
- 设置基础属性 :保存滚动物理特性(
physics
)、上下文(context
)、是否保持偏移(keepScrollOffset
)等。 - 继承旧状态 :如果传入了
oldPosition
,会调用absorb(oldPosition)
,把旧的滚动状态(如pixels
、方向等)转移过来。 - 恢复滚动偏移 :如果
keepScrollOffset = true
,会尝试调用restoreScrollOffset()
恢复之前的滚动位置(比如应用重建后保持列表滚动到原来的位置)。
假设有一个 ListView
,滑到了中间,然后因为主题切换或屏幕旋转导致 Widget 重建:
- Flutter 会创建一个新的
ScrollPosition
。 - 它会把旧的
ScrollPosition
的状态吸收过来(所以列表不会跳到顶部)。 - 如果设置了
keepScrollOffset = true
,它还会自动恢复到之前的滚动位置。
physics、context、keepScrollOffset、minScrollExtent、maxScrollExtent
dart
/// 滚动位置应该如何响应用户输入。
///
/// 例如:决定当用户停止拖动滚动视图后,
/// 该组件应该如何继续执行动画。
final ScrollPhysics physics;
/// 滚动发生的上下文。
///
/// 通常由 [ScrollableState] 实现。
final ScrollContext context;
/// 是否使用 [PageStorage] 保存当前滚动偏移,并在该滚动位置的
/// 可滚动组件被重新创建时恢复它。
///
/// 另见:
///
/// * [ScrollController.keepScrollOffset] 和 [PageController.keepPage],
/// 它们会创建滚动位置并初始化这个属性。
// TODO(goderbauer): 当状态恢复支持 PageStorage 的所有功能时,弃用这个属性。
final bool keepScrollOffset;
/// 一个用于 [toString] 输出的标签。
///
/// 主要用于在调试输出中帮助区分不同的动画控制器实例。
final String? debugLabel;
@override
double get minScrollExtent => _minScrollExtent!;
double? _minScrollExtent; // 最小可滚动范围
@override
double get maxScrollExtent => _maxScrollExtent!;
double? _maxScrollExtent; // 最大可滚动范围
@override
bool get hasContentDimensions => _minScrollExtent != null && _maxScrollExtent != null;
// 是否已经有内容的滚动范围(即 min/max 都已确定)
/// 在单帧内 [forcePixels] 改变时,额外添加的速度。
///
/// 这个值和当前 [activity] 的 [ScrollActivity.velocity] 一起
/// 用于 [recommendDeferredLoading],来询问 [physics] 是否需要延迟加载。
///
/// 这是因为一次 [forcePixels] 调用可能对应的 [ScrollActivity] 速度为 0,
/// 但实际上滚动视图瞬间从当前位置跳到一个很远的位置。
/// 调用方需要考虑这种情况。
///
/// 举例:
/// 如果当前滚动位置在 5000 像素,我们 [jumpTo] 到 0(回到顶部),
/// 则隐含速度为 -5000,而 `activity.velocity` 为 0。
/// 在这个跳转过程中,可能会跨过大量资源消耗大的 Widget,
/// 它们应该避免执行不必要的工作。
double _impliedVelocity = 0;
@override
double get pixels => _pixels!;
double? _pixels; // 当前滚动偏移量
@override
bool get hasPixels => _pixels != null; // 是否有有效的滚动偏移
@override
double get viewportDimension => _viewportDimension!;
double? _viewportDimension; // 视口(可见区域)的尺寸
@override
bool get hasViewportDimension => _viewportDimension != null;
// 是否已经有视口尺寸
/// 是否已经有 [viewportDimension]、[minScrollExtent]、[maxScrollExtent]、
/// [outOfRange] 和 [atEdge] 等值。
///
/// 在第一次调用 [applyNewDimensions] 之前设置为 true。
bool get haveDimensions => _haveDimensions;
bool _haveDimensions = false;
/// 该位置上的可滚动组件是否应该吸收指针事件。
///
/// 该值取决于当前的 [ScrollActivity],
/// 决定滚动视图或其子元素是否还能接收额外的触摸输入。
///
/// 比如:在 [BouncingScrollPhysics] 允许越界滚动时,
/// 当滚动视图从越界状态回弹时,
/// 子组件仍然可以接收指针事件。
bool get shouldIgnorePointer => !outOfRange && (activity?.shouldIgnorePointer ?? true);
-
核心属性
physics
👉 滚动规则(决定怎么滚)。context
👉 滚动发生的环境。keepScrollOffset
👉 要不要保存和恢复滚动位置。debugLabel
👉 调试用的名字。
-
滚动范围
minScrollExtent
/maxScrollExtent
👉 最小、最大可滚动位置。hasContentDimensions
👉 是否已经知道内容的滚动范围。
-
滚动状态
pixels
👉 当前滚动偏移量。viewportDimension
👉 可见窗口的大小。haveDimensions
👉 是否已经知道了完整的滚动尺寸信息。
-
特殊逻辑
_impliedVelocity
👉 处理「瞬间跳跃」时的隐含速度。shouldIgnorePointer
👉 决定滚动时,子组件是否还能响应触摸事件。
absorb
scss
/// 从给定的 [ScrollPosition] 中接管所有当前可用的状态。
///
/// 如果构造函数传入了 `oldPosition`,就会调用此方法。
/// `other` 参数的 [runtimeType] 可能和当前对象不同。
///
/// 这个方法可能会破坏另一个 [ScrollPosition] 的状态。
/// 调用后,另一个对象必须立即被销毁(在相同的调用栈中,
/// 在 microtask 执行前,由调用当前构造函数的代码负责销毁)。
///
/// 如果旧的 [ScrollPosition] 对象和当前对象的 [runtimeType] 不同,
/// 那么会在新接管的 [ScrollActivity] 上调用 [ScrollActivity.resetActivity]。
///
/// ## 重写注意事项
///
/// 如果子类要重写此方法:
/// - 必须在设置任何和滚动指标或活动相关的状态后,调用 `super.absorb`,
/// 因为此方法可能会重启 activity,而滚动活动通常依赖这些指标。
/// - 如果无法吸收另一个对象的 activity,可能需要手动启动一个 [IdleScrollActivity]。
/// - 如果子类本身充当了 [ScrollActivityDelegate],可能需要更新已吸收的
/// scroll activities 的 delegate。
void absorb(ScrollPosition other) {
assert(other.context == context); // 确保两个位置的上下文一致
assert(_pixels == null); // 确保当前还没有设置滚动位置
// 如果旧位置有内容范围,继承过来
if (other.hasContentDimensions) {
_minScrollExtent = other.minScrollExtent;
_maxScrollExtent = other.maxScrollExtent;
}
// 如果旧位置有像素偏移,继承过来
if (other.hasPixels) {
_pixels = other.pixels;
}
// 如果旧位置有视口尺寸,继承过来
if (other.hasViewportDimension) {
_viewportDimension = other.viewportDimension;
}
// 接管 activity
assert(activity == null);
assert(other.activity != null);
_activity = other.activity;
other._activity = null; // 旧的 activity 被"掏空"
// 如果两个对象的类型不同,需要重置 activity
if (other.runtimeType != runtimeType) {
activity!.resetActivity();
}
// 更新 context 和 isScrollingNotifier 的状态
context.setIgnorePointer(activity!.shouldIgnorePointer);
isScrollingNotifier.value = activity!.isScrolling;
}
absorb
就是 把旧的 ScrollPosition
的状态(滚动范围、偏移、视口大小、activity 等)吸收过来,然后让旧的对象"失效",再用新的继续工作。
场景举例:
- 你在
ListView
里滚动到一半。 - 因为某些原因(比如
physics
改变)需要创建一个新的ScrollPosition
。 - 新的对象会调用
absorb(oldPosition)
,把旧的滚动位置和状态都继承过来。 - 这样一来,用户不会看到"跳回到顶部"的情况,滚动是连续的。
setPixels(非常重要)
scss
/// 将滚动位置([pixels])更新为指定的像素值。
///
/// 只能由当前的 [ScrollActivity] 调用,
/// 要么是在临时回调阶段,要么是响应用户输入时调用。
///
/// 返回值表示是否有越界(overscroll)。
/// - 如果返回 0.0,说明 [pixels] 已成功更新为给定的 `value`。
/// - 如果返回值 > 0,说明新的 [pixels] 小于请求值(超出了最大滚动范围)。
/// - 如果返回值 < 0,说明新的 [pixels] 大于请求值(超出了最小滚动范围)。
///
/// 越界量由 [applyBoundaryConditions] 计算。
///
/// 实际应用的偏移变化量通过 [didUpdateScrollPositionBy] 上报。
/// 如果有越界,则通过 [didOverscrollBy] 上报。
double setPixels(double newPixels) {
assert(hasPixels); // 必须已有有效的像素位置
// 不能在 build/layout/paint 阶段修改滚动位置,否则会干扰渲染流程
assert(
SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks,
"不能在 build/layout/paint 阶段修改滚动位置!"
);
// 如果新的像素和当前不同
if (newPixels != pixels) {
// 计算边界条件(检查是否超出 min/max)
final double overscroll = applyBoundaryConditions(newPixels);
// debug 校验:overscroll 不能比实际移动量还大
assert(() {
final double delta = newPixels - pixels;
if (overscroll.abs() > delta.abs()) {
throw FlutterError("applyBoundaryConditions 返回了非法的 overscroll 值!");
}
return true;
}());
// 保存旧值,并更新为「newPixels - overscroll」
final double oldPixels = pixels;
_pixels = newPixels - overscroll;
// 如果确实有变化
if (_pixels != oldPixels) {
if (outOfRange) {
context.setIgnorePointer(false); // 越界时允许子组件接收触摸事件
}
notifyListeners(); // 通知监听器滚动位置改变
didUpdateScrollPositionBy(pixels - oldPixels); // 报告实际偏移变化量
}
// 如果存在越界(大于精度容忍范围)
if (overscroll.abs() > precisionErrorTolerance) {
didOverscrollBy(overscroll); // 报告越界
return overscroll; // 返回越界值
}
}
return 0.0; // 没有变化 / 没有越界
}
setPixels
就是 "把滚动条拉到某个位置" 的底层实现,流程是:
- 检查新位置
newPixels
。 - 看看会不会超出范围(用
applyBoundaryConditions
算出 overscroll)。 - 如果能更新,就更新
_pixels
并通知监听器。 - 如果越界了,调用
didOverscrollBy
上报越界情况。
📌 举个例子
-
列表最大滚动范围 = 1000,当前滚动到
900
。 -
调用
setPixels(1200)
:- 目标是
1200
,但超过最大范围1000
,overscroll =200
。 - 实际
_pixels
设置为1000
,返回200
,并触发didOverscrollBy(200)
。
- 目标是
correctPixels
arduino
/// 将 [pixels] 的值直接改为新的值,但不会通知任何监听者。
///
/// 这个方法主要用于 **布局过程 (layout)** 中调整位置。
/// 特别是在 [applyViewportDimension] 或 [applyContentDimensions] 的响应中调用。
/// (在这两种情况下,如果调用了该方法,那么它们通常应该返回 `false`,
/// 以表明位置已经被调整过了。)
///
/// 在其他情况下调用这个方法几乎都是不正确的。
/// 因为它不会立即触发渲染变化 ------
/// 依赖这个对象的 widget 或 render object 不会立刻被通知,
/// 它们只有在下一次读取该值时才会发现变化(可能会很晚才发生)。
///
/// 因此,通常只有在 **布局阶段修正值** 时才适用,
/// 尤其是 [ScrollPosition] 只有一个 viewport 客户端时。
///
/// 如果你想让位置立即跳转或动画滚动到一个新值,
/// 请考虑使用 [jumpTo] 或 [animateTo],
/// 它们会遵循正常的滚动偏移更新规则。
///
/// 如果你想绕过正常规则,强制把 [pixels] 设为某个值,
/// 可以使用 [forcePixels](不过请注意它的注释,
/// 即便如此,这通常仍然不是一个好主意)。
///
/// 另见:
///
/// * [correctBy] ------ [ViewportOffset] 的一个方法,
/// 用于在布局过程中修正 offset,但不会通知监听者。
/// * [jumpTo] ------ 在不处于布局阶段时,
/// 立即修改位置并应用新的偏移。
/// * [animateTo] ------ 类似 [jumpTo],但会通过动画滚动到目标偏移。
void correctPixels(double value) {
_pixels = value;
}
非常简单:就是直接改 _pixels
,完全 不触发任何通知或回调。
🌟 使用场景
-
什么时候用?
- 当 布局过程中,因为内容尺寸或视口尺寸变化,需要"强制修正"滚动位置。
- 比如:屏幕旋转、父布局大小变化、列表内容突然减少等。
-
为什么不能乱用?
- 因为它不会调用
notifyListeners()
,所以界面不会立刻更新。 - 如果不是在布局阶段用,UI 可能"卡住",直到下一次系统主动读取
pixels
- 因为它不会调用