ScrollableListViewCustomScrollViewSingleChildScrollView 等常用控件的超类。在本文中,我们将尝试了解其背后的原理。

首先,让我们从滚动更新通知开始。

什么是通知?

可以将通知向上发送到 Widget 树。Flutter 会在滚动、大小变化和布局变化等事件上发送通知。

换句话说,每当某个东西滚动或改变其大小时,祖先都会收到通知。

让我们看看滚动时发送的通知里面有什么。


dart

体验AI代码助手

代码解读

复制代码

NotificationListener<ScrollUpdateNotification>( onNotification: (notification) { return false; // <- putting a debugger breakpoint here }, child: ListView( children: [ const SizedBox(height: 1000), ], ), )

首先,我们来关注一下 scrollDelta 。这是相对于之前状态,滚动位置增加的像素数。如果为正,则表示用户沿着主方向滚动;如果为负,则表示用户向后滚动。

如果我们深入研究 metrics 的内容,会发现很多有用的数据。让我们将这些数据可视化。

可滚动内容被涂成红色,而静态小部件则被涂成蓝色。

因此, extentTotalScrollable 内容的总高度。maxScrollExtent 是无法容纳在视口中的内容的高度, scrollDelta 是自上次通知以来已滚动的原始像素数 maxScrollExtent ententBeforeextentAfter 对应于 Scrollable 从开始到结束的剩余高度。

viewPortDimension 是包含 Scrollable 的小部件的高度。

如果内容小于视口会发生什么?

让我们将 SizedBox 高度从 1000 改为 200。现在,由于没有滚动发生,通知事件不会发送——滚动内容的大小小于视口。如果我们需要这些值,该如何获取呢?


dart

体验AI代码助手

代码解读

复制代码

class _ScrollableExampleState extends State<ScrollableExample> { final _controller = ScrollController(); // <-- add this @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _controller.position; // <-- setting a debug breakpoint here }); } ... ListView( controller: _controller, // <-- add this ... ) }

我们可以看到,视口高度等于 extentTotalmaxScrollExtent 为 0,因为在这种情况下无法滚动。

什么是 DragDetails?

如果我们回到通知对象,我们会注意到它有一个 dragDetails 属性。这个对象类似于 GestureDetector 更新回调中发送的对象。让我们在屏幕上进行一些滚动,并观察发送的数据:


dart

体验AI代码助手

代码解读

复制代码

print("$scrollDelta\t$dragDetails");

让我们快速滚动小部件并查看数据:

乍一看, scrollDeltadragDetails.y 值似乎取反了,但很相似,但请检查最后一个值。你能猜出这里发生了什么吗?提示:这是在 Android 上完成的。

让我们在 iPhone 上运行它并看看:

拖动数据类似,但 scrollDelta 有所不同。拖动完成后,通知仍然会发送,但没有拖动更新详细信息。

当然,原因是 Scrollable 默认应用了不同的 ScrollPhysics 默认应用的是 BouncingScrollPhysics ,而 Android 默认应用的是 ClampingScrollPhysics

那么 ScrollPhysics 是如何工作的呢?

  • 接收拖动细节和滚动增量
  • 通过模拟层进行处理
  • 如果需要,应用边界
  • 输出滚动位置和速度
  • Scrollable 将计算值作为通知发送给监听器

在 Flutter 中,你可以选择不同的物理效果,也可以创建自定义的物理效果。此外,还可以同时应用多个滚动物理效果,如下所示:


dart

体验AI代码助手

代码解读

复制代码

BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics())

此示例将首先应用 BouncingScrollPhysics 的模拟,然后应用 AlwaysScrollableScrollPhysics 的模拟。

是否始终知道总体范围?

不,有些情况下我们会计算 Scrollable 中每个项目的大小,例如,当项目大小取决于其内容时,使用 ListView.builder ,比如有一个 Text 小部件,其行高可以是 1 行,也可以是 2 行。

在这种情况下,我们无法计算 totalExtent ,因为这意味着我们必须对整个列表进行计算,这违背了延迟加载的初衷 。在这种情况下,Flutter 将仅实例化视口 + cacheExtent ( ListView 的一个参数)中可见的项目。请注意,即使某个项目仅部分适合 cacheExtent ,它仍会被实例化。

当内容小于父窗口小部件时,有两个选项可用于计算 viewPort 大小。

shrinkWrap 设置为 true 后,渲染器将被强制根据其子项计算 viewPort 的高度。这样一来,延迟加载将被禁用,这可能会导致性能问题。请仅在确保列表中不会包含太多对象时才使用此方法。

那么为什么我不能将 Spacer 或 flexible 放在 Scrollable 中呢?


dart

体验AI代码助手

代码解读

复制代码

SingleChildScrollView( child: Column( children: [ Text("Content"), const Spacer(), // <- don't do that ElevatedButton(onPressed: () {}, child: Text("Button")) ], ), )

这里存在一个冲突: Spacer 想要占用尽可能多的空间,而 Scrollable 允许其子元素占用尽可能多的空间。在这个例子中,Spacer 需要知道父元素的大小才能计算其高度,但这是不可能的,因为父元素的大小取决于子元素。


dart

体验AI代码助手

代码解读

复制代码

LayoutBuilder(builder: (context, constraints) { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, maxHeight: double.infinity, ), child: IntrinsicHeight( child: Column( children: [ Text("Content"), const Spacer(), ElevatedButton(onPressed: () {}, child: Text("Button")) ], ), ), ), ); })

LayoutBuilder:

  • 获取父约束
  • 提供尺寸背景

SingleChildScrollView:

  • 内容溢出时启用滚动

ConstrainedBox:

  • 设置最小高度=父高度
  • 允许无限的最大高度
  • 防止列折叠

IntrinsicHeight:

  • 强制计算列的适当高度
  • 帮助确定 child 尺寸

Column:

  • 垂直排列子项
  • 扩展到最大空间

因此,我们可以在 Scrollable 中使用 SpacerFlexible

还可以使用此包中的 ScrollableColumn 小部件

如何使用 Scrollable 和 Transform?

让我们实现一个滚动监听器,它会在滚动时更新应用栏中的视图。

让我们从创建一个 StatefulWidget 开始。它也可以是无状态的,这取决于你使用的状态管理方法。在这个例子中,我希望它尽可能简单,所以我只使用了 ValueNotifier 作为 StatefulWidget 的属性。


dart

体验AI代码助手

代码解读

复制代码

class _ScrollableZoomerState extends State<ScrollableZoomer> { ValueNotifier<double> scrollPosition = ValueNotifier(0.0); ... }

它将存储一个标准化的滚动位置,其中 0.0 是开始,1.0 是完全滚动。


dart

体验AI代码助手

代码解读

复制代码

@override Widget build(BuildContext context) { return Scaffold( appBar: ..., body: NotificationListener<ScrollUpdateNotification>( onNotification: (notification) { scrollPosition.value = min(1, notification.metrics.pixels / notification.metrics.maxScrollExtent); return true; }, child: widget.child, ), ); }

很简单,只需将滚动像素除以最大滚动范围,并确保其不超过 100%。然后对“下一步”按钮应用一些简单的变换。


dart

体验AI代码助手

代码解读

复制代码

appBar: AppBar( actions: [ ValueListenableBuilder( valueListenable: scrollPosition, builder: (context, value, _) => Opacity( opacity: value > 0.1 ? value : 0, child: Transform.translate( offset: Offset(0, 40 * (1 - value)), child: TextButton( onPressed: ..., child: Text("Next"), ), ), ), ) ], ),

您可以尝试不同的值和数学运算,唯一的限制就是想象力。


链接:https://juejin.cn/post/7503738123689623588

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐