flutter滚动视图之ProxyWidget、ProxyElement、NotifiableElementMixin源码解析(九)

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

  1. 通知先从内层 ListView 往上冒泡 → depth=0(local)。
  2. 冒泡经过内层 Viewportdepth=1
  3. 再冒泡经过外层 Viewportdepth=2
  4. 最终被外层的 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');
  }
}

🔎 核心要点总结

  1. 滚动通知的生命周期:Start → Update/Overscroll/UserScroll → End → Idle。

  2. 冒泡机制 :通知会逐层上传;想监听最近的 Scrollable,判断 depth == 0

  3. 使用限制

    • 不能在通知里调用 setState 来改布局(会延迟一帧)。
    • 推荐只做绘制相关的事,比如显示滚动条、overscroll 效果。
  4. 区别

    • 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');
    }
  }
}

🔎 关键点

  1. 继承关系
    ScrollStartNotification → 继承自 ScrollNotification

    所以它同样拥有:

    • metrics(滚动信息:pixels, maxScrollExtent, viewportDimension 等)
    • context(触发的 Scrollable 的 BuildContext
  2. 触发时机

    • 当一个 Scrollable(比如 ListViewSingleChildScrollView刚刚开始滚动时触发。
    • 通常会紧跟着发送若干个 ScrollUpdateNotification
  3. dragDetails 的意义

    • 非空 :说明滚动是由用户手指拖动触发的,里面有 globalPositionlocalPosition 等手势信息。
    • 为空 :说明滚动是由代码触发的(比如 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;
}

🔎 关键点

  1. 继承关系

    • ScrollUpdateNotification 继承自 ScrollNotification,所以它同样有:

      • metrics → 当前滚动状态(pixels, minScrollExtent, maxScrollExtent, viewportDimension 等)
      • context → 触发滚动的 Scrollable 的上下文
  2. 触发时机

    • 每次滚动位置发生改变时都会触发。

    • 生命周期上,它位于:

      css 复制代码
      ScrollStartNotification → [若干 ScrollUpdateNotification] → ScrollEndNotification
    • 如果滚动过程中超出了边界而无法继续滚动,会触发 OverscrollNotification 而不是 ScrollUpdateNotification

  3. dragDetails 的作用

    • 如果是用户手指拖动引起的滚动:这里会包含 DragUpdateDetails(触摸点位置、移动量等)。
    • 如果是代码触发(controller.animateTo / jumpTo),则为 null
  4. scrollDelta 的作用

    • 表示本次滚动的位移,单位是逻辑像素。
    • 正值/负值表示滚动方向。
    • 注意:它是相对量,而 metrics.pixels 是绝对位置。
  5. 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;                 // 当时速度
}

🔎 关键点

  1. 触发条件

    Scrollable 试图滚动超过边界 ,但是因为已经到达 minScrollExtentmaxScrollExtent,所以位置不变,此时触发 OverscrollNotification

    举例:

    • ListView 已经滑到顶部,再往下拉 → 触发 overscroll(负值)
    • 滑到底部,再往上拉 → 触发 overscroll(正值)
  2. overscroll 含义

    • 表示试图滚动但被拒绝的像素数
    • 正值 → 在末端(maxScrollExtent)方向越界。
    • 负值 → 在起始端(minScrollExtent)方向越界。
  3. dragDetails

    • 如果是手指拖动触发 → 包含拖动事件信息。
    • 如果是代码触发(比如 animateTo 超过边界) → 通常为 null
  4. velocity

    • 手指拖动 overscroll → 一般是 0.0
    • 惯性滚动(BallisticScrollActivity)到边界继续冲撞 → velocity 为当前滚动速度。
    • Driven(比如 ScrollController.animateTo)超界也会有正速度。

🧩 使用场景

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;
}

🔎 关键点

  1. 触发时机

    • Scrollable 完全停止滚动 时分发。

    • 包括:

      • 手指拖动后直接停下。
      • 手指松开 → 惯性滚动(Ballistic)完成后停下。
      • 程序调用 animateTo 等滚动结束。
  2. dragDetails

    • 如果是手指拖动并直接停止 → dragDetails 有值。

    • 如果手指抬起后有速度,触发了惯性滚动 → 最终的 ScrollEndNotification dragDetails = null

    • 所以,dragDetails 能区分:

      • 直接停下(非惯性)
      • 惯性滚动完成(ballistic)
  3. 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;
}

🔎 关键点解析

  1. 触发条件

    • 当用户滚动的方向 发生改变 时(从 forwardreverse,或从 reverseforward)。

    • 当用户 停止滚动 时(direction = idle)。

    ⚠️ 注意:它和 ScrollUpdateNotification 的区别是------ScrollUpdateNotification 每一帧都发,而 UserScrollNotification 只在「方向变了」时才发。

  2. direction 的取值ScrollDirection 枚举):

    • ScrollDirection.forward → 向正方向滚动

      • 比如竖直列表里,手指向上推,列表往下滚。
    • ScrollDirection.reverse → 向负方向滚动

      • 竖直列表里,手指往下拉,列表往上滚。
    • ScrollDirection.idle → 停止滚动

  3. AxisDirectionGrowthDirection 的区别

    • 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
相关推荐
吃饺子不吃馅19 分钟前
深感一事无成,还是踏踏实实做点东西吧
前端·svg·图形学
90后的晨仔1 小时前
Mac 上配置多个 Gitee 账号的完整教程
前端·后端
少年阿闯~~1 小时前
CSS——实现盒子在页面居中
前端·css·html
开发者小天1 小时前
uniapp中封装底部跳转方法
前端·javascript·uni-app
阿波罗尼亚2 小时前
复杂查询:直接查询/子查询/视图/CTE
java·前端·数据库
正义的大古2 小时前
OpenLayers地图交互 -- 章节九:拖拽框交互详解
前端·vue.js·openlayers
三十_A2 小时前
【实录】使用 Verdaccio 从零搭建私有 npm 仓库(含完整步骤及避坑指南)
前端·npm·node.js
huangql5202 小时前
从零到一打造前端内存监控 SDK,并发布到 npm ——基于 TypeScript + Vite + ECharts的解决方案
前端·typescript·echarts
weixin_456904272 小时前
离线下载npm包
前端·npm·node.js
低代码布道师2 小时前
少儿舞蹈小程序(19)地址列表功能实现及默认地址逻辑
前端·低代码·小程序