掌握 Flutter 中的 Scrollable

0 阅读5分钟

本文翻译自:Mastering Scrollable in Flutter

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

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

什么是通知?

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

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

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

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。现在,由于没有滚动发生,通知事件不会发送——滚动内容的大小小于视口。如果我们需要这些值,该如何获取呢?

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 更新回调中发送的对象。让我们在屏幕上进行一些滚动,并观察发送的数据:

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

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

转存失败,建议直接上传图片文件

乍一看, scrollDeltadragDetails.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 中使用 SpacerFlexible

还可以使用此包中的 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"),
          ),
        ),
      ),
    )
  ],
),

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