Scrollable
是 ListView
、 CustomScrollView
、 SingleChildScrollView
等常用控件的超类。在本文中,我们将尝试了解其背后的原理。
首先,让我们从滚动更新通知开始。
什么是通知?
可以将通知向上发送到 Widget
树。Flutter
会在滚动、大小变化和布局变化等事件上发送通知。
换句话说,每当某个东西滚动或改变其大小时,祖先都会收到通知。
让我们看看滚动时发送的通知里面有什么。
NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) {
return false; // <- putting a debugger breakpoint here
},
child: ListView(
children: [
const SizedBox(height: 1000),
],
),
)
首先,我们来关注一下 scrollDelta
。这是相对于之前状态,滚动位置增加的像素数。如果为正,则表示用户沿着主方向滚动;如果为负,则表示用户向后滚动。
如果我们深入研究 metrics
的内容,会发现很多有用的数据。让我们将这些数据可视化。
可滚动内容被涂成红色,而静态小部件则被涂成蓝色。
因此, extentTotal
是 Scrollable
内容的总高度。maxScrollExtent
是无法容纳在视口中的内容的高度, scrollDelta
是自上次通知以来已滚动的原始像素数 maxScrollExtent
ententBefore
和 extentAfter
对应于 Scrollable
从开始到结束的剩余高度。
viewPortDimension
是包含 Scrollable
的小部件的高度。
如果内容小于视口会发生什么?
让我们将 SizedBox
高度从 1000 改为 200。现在,由于没有滚动发生,通知事件不会发送——滚动内容的大小小于视口。如果我们需要这些值,该如何获取呢?
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
...
)
}
我们可以看到,视口高度等于 extentTotal
, maxScrollExtent
为 0,因为在这种情况下无法滚动。
什么是 DragDetails?
如果我们回到通知对象,我们会注意到它有一个 dragDetails
属性。这个对象类似于 GestureDetector
更新回调中发送的对象。让我们在屏幕上进行一些滚动,并观察发送的数据:
print("$scrollDelta\t$dragDetails");
让我们快速滚动小部件并查看数据:
乍一看, scrollDelta
和 dragDetails.y
值似乎取反了,但很相似,但请检查最后一个值。你能猜出这里发生了什么吗?提示:这是在 Android 上完成的。
让我们在 iPhone 上运行它并看看:
拖动数据类似,但 scrollDelta
有所不同。拖动完成后,通知仍然会发送,但没有拖动更新详细信息。
当然,原因是 Scrollable
默认应用了不同的 ScrollPhysics
默认应用的是 BouncingScrollPhysics
,而 Android
默认应用的是 ClampingScrollPhysics
。
那么 ScrollPhysics 是如何工作的呢?
- 接收拖动细节和滚动增量
- 通过模拟层进行处理
- 如果需要,应用边界
- 输出滚动位置和速度
- Scrollable 将计算值作为通知发送给监听器
在 Flutter 中,你可以选择不同的物理效果,也可以创建自定义的物理效果。此外,还可以同时应用多个滚动物理效果,如下所示:
BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics())
此示例将首先应用 BouncingScrollPhysics
的模拟,然后应用 AlwaysScrollableScrollPhysics
的模拟。
是否始终知道总体范围?
不,有些情况下我们会计算 Scrollable
中每个项目的大小,例如,当项目大小取决于其内容时,使用 ListView.builder
,比如有一个 Text
小部件,其行高可以是 1 行,也可以是 2 行。
在这种情况下,我们无法计算 totalExtent
,因为这意味着我们必须对整个列表进行计算,这违背了延迟加载的初衷 。在这种情况下,Flutter 将仅实例化视口 + cacheExtent
( ListView 的一个参数)中可见的项目。请注意,即使某个项目仅部分适合 cacheExtent ,它仍会被实例化。
当内容小于父窗口小部件时,有两个选项可用于计算 viewPort 大小。
将 shrinkWrap
设置为 true 后,渲染器将被强制根据其子项计算 viewPort
的高度。这样一来,延迟加载将被禁用,这可能会导致性能问题。请仅在确保列表中不会包含太多对象时才使用此方法。
那么为什么我不能将 Spacer 或 flexible 放在 Scrollable 中呢?
SingleChildScrollView(
child: Column(
children: [
Text("Content"),
const Spacer(), // <- don't do that
ElevatedButton(onPressed: () {}, child: Text("Button"))
],
),
)
这里存在一个冲突: Spacer
想要占用尽可能多的空间,而 Scrollable
允许其子元素占用尽可能多的空间。在这个例子中,Spacer
需要知道父元素的大小才能计算其高度,但这是不可能的,因为父元素的大小取决于子元素。
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
中使用 Spacer
或 Flexible
。
还可以使用此包中的 ScrollableColumn
小部件
如何使用 Scrollable 和 Transform?
让我们实现一个滚动监听器,它会在滚动时更新应用栏中的视图。
让我们从创建一个 StatefulWidget
开始。它也可以是无状态的,这取决于你使用的状态管理方法。在这个例子中,我希望它尽可能简单,所以我只使用了 ValueNotifier
作为 StatefulWidget
的属性。
class _ScrollableZoomerState extends State<ScrollableZoomer> {
ValueNotifier<double> scrollPosition = ValueNotifier(0.0);
...
}
它将存储一个标准化的滚动位置,其中 0.0 是开始,1.0 是完全滚动。
@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%。然后对“下一步”按钮应用一些简单的变换。
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"),
),
),
),
)
],
),
您可以尝试不同的值和数学运算,唯一的限制就是想象力。