ViewportNotificationMixin
dart
/// 一个用于 [Notification] 的 mixin,
/// 它会追踪该通知已经经过多少个 [RenderAbstractViewport](视口)。
///
/// [ScrollNotification] 和 [OverscrollIndicatorNotification] 会使用这个 mixin。
mixin ViewportNotificationMixin on Notification {
/// 该通知已经冒泡经过的视口(viewport)数量。
///
/// 一般情况下,监听器只会响应 [depth] 为 0 的通知。
///
/// 具体来说,这个值表示通知冒泡过程中经过了多少个
/// 由 [Widget] 表示的 [RenderAbstractViewport] 渲染对象。
int get depth => _depth;
int _depth = 0;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('depth: $depth (${depth == 0 ? "local" : "remote"})');
}
}
🔎 说明:
depth
= 0 → 表示通知来自本地 viewport(也就是离监听器最近的那个滚动视图)。depth
> 0 → 表示通知是从更深层的嵌套 viewport 传出来的(remote)。- 典型场景就是 嵌套滚动 ,比如
NestedScrollView
里外层和内层都有ScrollNotification
,
你可以通过depth
区分当前通知是来自外层还是内层。
ViewportElementMixin
dart
/// 一个 mixin,允许包含 [Viewport] 类组件的 [Element]
/// 正确修改 [ViewportNotificationMixin] 的通知深度(depth)。
///
/// 参见:
/// * [Viewport] ------ 它会创建一个自定义的 [MultiChildRenderObjectElement],并混入这个 mixin。
mixin ViewportElementMixin on NotifiableElementMixin {
@override
bool onNotification(Notification notification) {
if (notification is ViewportNotificationMixin) {
// 每经过一个 Viewport,就让通知的 depth 加 1
notification._depth += 1;
}
// 返回 false,表示继续冒泡
return false;
}
}
🔎 说明
ViewportElementMixin
专门给 包含 Viewport 的 Element 用,比如Viewport
。- 当通知冒泡经过
Viewport
时,这个 mixin 会拦截并把depth
加 1。 - 这样配合
ViewportNotificationMixin
,就能知道某个通知经过了多少层 viewport。
✅ 举个例子:
假设你有一个 NestedScrollView
(外层)里又有一个 ListView
(内层),
当内层 ListView
发出一个 ScrollNotification
:
- 通知先从内层
ListView
往上冒泡 →depth=0
(local)。 - 冒泡经过内层
Viewport
→depth=1
。 - 再冒泡经过外层
Viewport
→depth=2
。 - 最终被外层的
NotificationListener
捕获到。
这样监听器就能通过 depth
来判断通知是来自本地滚动 还是来自嵌套滚动的更深层。
ScrollNotification
scala
/// 一个与滚动相关的 [Notification]。
///
/// [Scrollable] 组件会向它们的祖先通知滚动相关的变化。
/// 滚动通知的生命周期如下:
///
/// * [ScrollStartNotification] ------ 表示开始滚动;
/// * 0 个或多个 [ScrollUpdateNotification] ------ 表示滚动位置发生了变化;
/// * 期间可能会夹杂 0 个或多个 [OverscrollNotification] ------ 表示尝试滚动,但因为超出边界没有改变位置;
/// * 与 [ScrollUpdateNotification] 和 [OverscrollNotification] 交错出现的,可能还有 0 个或多个 [UserScrollNotification] ------
/// 表示用户改变了滚动的方向;
/// * [ScrollEndNotification] ------ 表示滚动结束;
/// * 最后会有一个 [UserScrollNotification],其中 [UserScrollNotification.direction] 为 [ScrollDirection.idle]。
///
/// 滚动通知会沿着 widget 树向上传递(bubble),这意味着一个 [NotificationListener]
/// 会收到其所有子孙 [Scrollable] 组件的通知。
/// 如果只想监听最近的 [Scrollable] 的通知,可以检查通知的 [depth] 是否为 0。
///
/// ⚠️ 注意:当一个 [NotificationListener] 收到滚动通知时,
/// 发送通知的 widget 已经完成了 build 和 layout。
/// 此时再调用 [State.setState] 去修改布局已经太晚了,
/// 会导致界面渲染延迟一帧,产生不良的用户体验。
/// 因此滚动通知主要用于绘制阶段(paint)相关的效果,
/// 因为绘制发生在布局之后。
/// 典型例子: [GlowingOverscrollIndicator] 和 [Scrollbar] 就是利用滚动通知来实现绘制效果的。
///
/// {@tool dartpad}
/// 下面的示例展示了使用 [ScrollController] 和 [NotificationListener<ScrollNotification>] 的区别。
/// 切换 [Radio] 按钮,可以选择两种监听方式:
/// - 使用 [ScrollNotification]:可以获得滚动活动和 [ScrollMetrics] 的信息,但拿不到 [ScrollPosition] 对象;
/// - 使用 [ScrollController]:可以直接访问 [ScrollPosition] 对象。
/// 两者都只会在真正发生滚动时触发。
///
/// ** 示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_notification.0.dart **
/// {@end-tool}
///
/// 如果想根据滚动位置来驱动布局,应当直接监听 [ScrollPosition](或者间接通过 [ScrollController])。
/// 这种方式不会在 [ScrollMetrics] 发生变化时触发(例如窗口尺寸改变导致视口大小变化)。
/// 如果需要监听滚动区域尺寸的变化,应使用 [NotificationListener<ScrollMetricsNotification>]。
/// 它与 [ScrollNotification] 的区别在于:
/// - [ScrollNotification] 关注滚动行为;
/// - [ScrollMetricsNotification] 关注滚动区域(Viewport)的尺寸变化。
///
/// {@tool dartpad}
/// 下面的示例展示了当 `windowSize` 改变时,如何收到一个 [ScrollMetricsNotification]。
/// 点击浮动按钮可以增大可滚动窗口的大小。
///
/// ** 示例代码见:examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart **
/// {@end-tool}
///
abstract class ScrollNotification extends LayoutChangedNotification with ViewportNotificationMixin {
/// 子类的构造函数需要传入以下参数。
ScrollNotification({required this.metrics, required this.context});
/// [Scrollable] 内容的描述信息,用于建模 viewport 的状态。
final ScrollMetrics metrics;
/// 触发该通知的 widget 的 [BuildContext]。
///
/// 例如,可以用它找到 scrollable 的渲染对象,从而获取视口大小。
final BuildContext? context;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$metrics');
}
}
🔎 核心要点总结
-
滚动通知的生命周期:Start → Update/Overscroll/UserScroll → End → Idle。
-
冒泡机制 :通知会逐层上传;想监听最近的 Scrollable,判断
depth == 0
。 -
使用限制:
- 不能在通知里调用 setState 来改布局(会延迟一帧)。
- 推荐只做绘制相关的事,比如显示滚动条、overscroll 效果。
-
区别:
ScrollNotification
→ 滚动事件。ScrollMetricsNotification
→ 尺寸/边界变化。ScrollController
→ 直接拿ScrollPosition
来做逻辑控制。
ScrollStartNotification
scala
/// 一个 [Scrollable] widget 开始滚动时发出的通知。
///
/// 相关:
///
/// * [ScrollEndNotification] ------ 表示滚动停止;
/// * [ScrollNotification] ------ 描述了完整的滚动通知生命周期。
class ScrollStartNotification extends ScrollNotification {
/// 构造函数:创建一个"开始滚动"的通知。
ScrollStartNotification({
required super.metrics, // 滚动的度量信息(当前位置、最大/最小值、视口尺寸等)
required super.context, // 触发滚动的 Scrollable 的 BuildContext
this.dragDetails, // 如果是用户拖动导致的滚动,这里会包含拖动开始的细节
});
/// 如果 [Scrollable] 因为 **用户拖动** 而开始滚动,
/// 那么这个字段包含拖动开始的细节(如触摸位置、指针 ID 等)。
///
/// 如果滚动不是用户拖动触发(例如代码调用 `jumpTo` 或 `animateTo`),这里就是 `null`。
final DragStartDetails? dragDetails;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
if (dragDetails != null) {
description.add('$dragDetails');
}
}
}
🔎 关键点
-
继承关系
ScrollStartNotification
→ 继承自ScrollNotification
。所以它同样拥有:
metrics
(滚动信息:pixels
,maxScrollExtent
,viewportDimension
等)context
(触发的 Scrollable 的BuildContext
)
-
触发时机
- 当一个
Scrollable
(比如ListView
、SingleChildScrollView
)刚刚开始滚动时触发。 - 通常会紧跟着发送若干个
ScrollUpdateNotification
。
- 当一个
-
dragDetails
的意义- 非空 :说明滚动是由用户手指拖动触发的,里面有
globalPosition
、localPosition
等手势信息。 - 为空 :说明滚动是由代码触发的(比如
controller.jumpTo
或者惯性滑动)。
- 非空 :说明滚动是由用户手指拖动触发的,里面有
🧩 使用场景
- 监听用户是否开始拖动:
php
NotificationListener<ScrollStartNotification>(
onNotification: (notification) {
if (notification.dragDetails != null) {
print('用户手指开始拖动');
} else {
print('非用户触发的滚动开始');
}
return true;
},
child: ListView.builder(
itemCount: 100,
itemBuilder: (_, i) => ListTile(title: Text('Item $i')),
),
)
ScrollUpdateNotification
scala
class ScrollUpdateNotification extends ScrollNotification {
ScrollUpdateNotification({
required super.metrics, // 滚动度量信息
required BuildContext super.context, // 触发的 Scrollable 的 BuildContext
this.dragDetails, // 如果是手势触发,包含 drag 更新信息
this.scrollDelta, // 本次滚动的位移量(逻辑像素)
int? depth, // 通知深度(嵌套滚动时有用)
}) {
if (depth != null) {
_depth = depth;
}
}
/// 如果是手势拖动触发的滚动,会包含 DragUpdateDetails
final DragUpdateDetails? dragDetails;
/// 本次滚动的位移量(逻辑像素)
final double? scrollDelta;
}
🔎 关键点
-
继承关系
-
ScrollUpdateNotification
继承自ScrollNotification
,所以它同样有:metrics
→ 当前滚动状态(pixels
,minScrollExtent
,maxScrollExtent
,viewportDimension
等)context
→ 触发滚动的Scrollable
的上下文
-
-
触发时机
-
每次滚动位置发生改变时都会触发。
-
生命周期上,它位于:
cssScrollStartNotification → [若干 ScrollUpdateNotification] → ScrollEndNotification
-
如果滚动过程中超出了边界而无法继续滚动,会触发
OverscrollNotification
而不是ScrollUpdateNotification
。
-
-
dragDetails
的作用- 如果是用户手指拖动引起的滚动:这里会包含
DragUpdateDetails
(触摸点位置、移动量等)。 - 如果是代码触发(
controller.animateTo
/jumpTo
),则为null
。
- 如果是用户手指拖动引起的滚动:这里会包含
-
scrollDelta
的作用- 表示本次滚动的位移,单位是逻辑像素。
- 正值/负值表示滚动方向。
- 注意:它是相对量,而
metrics.pixels
是绝对位置。
-
depth
的作用- 表示滚动通知的「嵌套层级」。
depth = 0
→ 最内层的Scrollable
发出的通知。depth > 0
→ 嵌套在内部的Scrollable
向外层冒泡时,深度会递增。- 常见场景:
NestedScrollView
里有多个ListView
。
dart
NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) {
print('当前位置: ${notification.metrics.pixels}');
print('本次滚动位移: ${notification.scrollDelta}');
if (notification.dragDetails != null) {
print('用户手势在拖动: ${notification.dragDetails!.delta}');
}
return true; // 阻止冒泡可返回 true
},
child: ListView.builder(
itemCount: 50,
itemBuilder: (_, i) => ListTile(title: Text('Item $i')),
),
);
OverscrollNotification
scala
class OverscrollNotification extends ScrollNotification {
OverscrollNotification({
required super.metrics, // 滚动状态信息(pixels、extent等)
required BuildContext super.context, // 触发的 Scrollable 的 BuildContext
this.dragDetails, // 如果由手势触发,包含 DragUpdateDetails
required this.overscroll, // 试图滚动超出的距离
this.velocity = 0.0, // 当时的速度(通常 0.0,除非是 ballistic)
}) : assert(overscroll.isFinite),
assert(overscroll != 0.0);
final DragUpdateDetails? dragDetails; // 手势触发才有
final double overscroll; // 被"拦截"的超出位移量
final double velocity; // 当时速度
}
🔎 关键点
-
触发条件
当
Scrollable
试图滚动超过边界 ,但是因为已经到达minScrollExtent
或maxScrollExtent
,所以位置不变,此时触发OverscrollNotification
。举例:
- ListView 已经滑到顶部,再往下拉 → 触发 overscroll(负值)
- 滑到底部,再往上拉 → 触发 overscroll(正值)
-
overscroll
含义- 表示试图滚动但被拒绝的像素数。
- 正值 → 在末端(maxScrollExtent)方向越界。
- 负值 → 在起始端(minScrollExtent)方向越界。
-
dragDetails
- 如果是手指拖动触发 → 包含拖动事件信息。
- 如果是代码触发(比如
animateTo
超过边界) → 通常为null
。
-
velocity
- 手指拖动 overscroll → 一般是
0.0
。 - 惯性滚动(BallisticScrollActivity)到边界继续冲撞 → velocity 为当前滚动速度。
- Driven(比如
ScrollController.animateTo
)超界也会有正速度。
- 手指拖动 overscroll → 一般是
🧩 使用场景
Overscroll 常用于做 下拉刷新 / 吸顶效果 / 边界反馈。
php
NotificationListener<OverscrollNotification>(
onNotification: (notification) {
print('overscroll: ${notification.overscroll}');
if (notification.overscroll < 0) {
print('到顶端了,还在往下拉');
} else {
print('到底端了,还在往上拉');
}
return true;
},
child: ListView.builder(
itemCount: 50,
itemBuilder: (_, i) => ListTile(title: Text('Item $i')),
),
);
在 Flutter 的官方 RefreshIndicator
实现里,就是依赖 OverscrollNotification 来感知「用户在顶部继续下拉」。
ScrollEndNotification
scala
class ScrollEndNotification extends ScrollNotification {
ScrollEndNotification({
required super.metrics,
required BuildContext super.context,
this.dragDetails,
});
final DragEndDetails? dragDetails;
}
🔎 关键点
-
触发时机
-
当
Scrollable
完全停止滚动 时分发。 -
包括:
- 手指拖动后直接停下。
- 手指松开 → 惯性滚动(Ballistic)完成后停下。
- 程序调用
animateTo
等滚动结束。
-
-
dragDetails
-
如果是手指拖动并直接停止 →
dragDetails
有值。 -
如果手指抬起后有速度,触发了惯性滚动 → 最终的
ScrollEndNotification
dragDetails = null。 -
所以,
dragDetails
能区分:- 直接停下(非惯性)
- 惯性滚动完成(ballistic)
-
-
与
ScrollPosition.isScrollingNotifier
的区别isScrollingNotifier
→ 一个实时布尔状态(true/false)。ScrollEndNotification
→ 一次性事件通知。- 如果你只需要知道 "是否正在滚动" ,用
isScrollingNotifier
更方便。
UserScrollNotification
scala
class UserScrollNotification extends ScrollNotification {
UserScrollNotification({
required super.metrics,
required BuildContext super.context,
required this.direction,
});
final ScrollDirection direction;
}
🔎 关键点解析
-
触发条件
-
当用户滚动的方向 发生改变 时(从
forward
变reverse
,或从reverse
变forward
)。 -
当用户 停止滚动 时(direction =
idle
)。
⚠️ 注意:它和
ScrollUpdateNotification
的区别是------ScrollUpdateNotification
每一帧都发,而UserScrollNotification
只在「方向变了」时才发。 -
-
direction
的取值 (ScrollDirection
枚举):-
ScrollDirection.forward
→ 向正方向滚动- 比如竖直列表里,手指向上推,列表往下滚。
-
ScrollDirection.reverse
→ 向负方向滚动- 竖直列表里,手指往下拉,列表往上滚。
-
ScrollDirection.idle
→ 停止滚动
-
-
和
AxisDirection
、GrowthDirection
的区别AxisDirection
:表示视口内「像素增加的方向」GrowthDirection
:内容布局的增长方向ScrollDirection
(这里的direction
):表示用户当前交互方向(forward / reverse / idle),和布局无关,只关心交互
🧩 使用场景
- 隐藏/显示 UI:比如用户向下滚动时隐藏 AppBar,向上滚动时显示。
- 方向检测:检测用户是否正在上拉加载还是下拉刷新。
- 曝光统计:只在方向改变时处理,避免每一帧更新。
php
NotificationListener<UserScrollNotification>(
onNotification: (notification) {
switch (notification.direction) {
case ScrollDirection.forward:
print("用户往正方向滚动(下滚)");
break;
case ScrollDirection.reverse:
print("用户往反方向滚动(上滚)");
break;
case ScrollDirection.idle:
print("用户停止滚动");
break;
}
return true;
},
child: ListView.builder(
itemCount: 30,
itemBuilder: (_, i) => ListTile(title: Text("Item $i")),
),
);
ScrollNotificationPredicate
ini
typedef ScrollNotificationPredicate = bool Function(ScrollNotification notification);
defaultScrollNotificationPredicate
arduino
bool defaultScrollNotificationPredicate(ScrollNotification notification) {
return notification.depth == 0;
}
🔎 含义解析
notification.depth
表示当前ScrollNotification
从子树冒泡(bubble)到父树时,经过了多少个可滚动的容器 (Scrollable
/Viewport
)。- 当
depth == 0
时,说明这个通知是直接从当前Scrollable
发出的 ,中间没有再经过其它Scrollable
。