Flutter学习 可滚动Widget 上

1. 可滚动组件介绍

Flutter 中有两种布局模型:

  • 基于 RenderBox 的盒布局模型
  • 基于 RenderSliver按需加载列表布局

Sliver 的作用是: 加载子组件并确定每一个子组件的布局和绘制信息,如果 Sliver 可以包含多个子组件时,通常会实现按需加载模型。

可滚动组件中有很多支持 基于 Sliver 的按需加载模型,例如 ListViewGridView,也有不支持该模型的组件,例如 SingleChildScrollView

Flutter 中的可滚动主要有下面三个角色组成:

  • Scrollable
    用于处理手势滑动,确定滑动偏移,滑动偏移时构建 Viewport
  • Viewport
    显示的视窗,即列表的可视区域
  • Sliver
    视窗里的元素

具体的布局过程是:

  1. Scrollable 监听到用户行为后,根据最新的滑动偏移创建 Viewport
  2. Viewport 将当前视口信息和配置信息通过 SliverConstraints 传递给 Sliver
  3. Sliver 中对子组件 (RenderBox) 按需进行构建和布局,然后确定自身的位置、绘制等信息,保存在 geometry 里

布局结构如下所示:

在这里插入图片描述
其中 Scrollable、ViewPort、Sliver 占满整个屏幕控件,而 Sliver 的父组件为 ViewPort, ViewPort 的父组件为 Scrollable。
ListView 中只有一个 Sliver ,在 Sliver 中实现了子组件按需加载。

1.1 Scrollable

用于处理滑动手势,确定滑动偏移, 滑动偏移变化时去构建 ViewPort ,它的关键属性如下:

class Scrollable extends StatefulWidget {
    
    
  const Scrollable({
    
    
    ...
    this.axisDirection = AxisDirection.down,
    this.controller,
    this.physics,
    required this.viewportBuilder,
  })
  • axisDirection
    滚动分享
  • physics
    接收一个 ScrollPhysics,用于决定组件如何响应用户操作。 默认情况下, 会根据具体平台分别使用不同的 ScrollPhysics 对象,应用不同的显示效果, 如 Android 在滑到边界时,会有微光效果(ClampingScrollPhysics),而 iOS 会有弹性效果(BouncingScrollPhysics
  • controller
    接收一个 ScrollController 对象, 控制滚动位置和监听滚动事件。 默认的话, Widget树有一个 PrimaryScrollController,如果子树中的可滚动组件没有显示的指定 controller, 并且 primary 属性值为 true,可滚动组件会默认使用这个 controller
  • viewportBuilder
    构建 Viewport 的回调, 当用户滑动时,会回调这个对象, 传递一个 ViewportOffset 类型的 offset 参数,该参数描述 Viewport 应该显示哪一部分的内容, 这里要注意的是:构建 ViewPort 不是一个昂贵的消耗,因为它本身是 Widget,只是用于配置信息, ViewPort 变化时对应的 RenderViewport 会更新信息,并不会随着 Widget 进行重新构建

1.2 Viewport

Viewport 用于渲染当前窗口需要显示的 Sliver

class Viewport extends MultiChildRenderObjectWidget {
    
    
  Viewport({
    
    
    ...
    this.axisDirection = AxisDirection.down,
    this.crossAxisDirection,
    this.anchor = 0.0,
    required this.offset,
    this.center,
    this.cacheExtent,
    this.cacheExtentStyle = CacheExtentStyle.pixel,
    this.clipBehavior = Clip.hardEdge,
    List<Widget> slivers = const <Widget>[],
  })
  • center
    类型为 Key, 表示从上面地方开始进行绘制,默认是第一个元素
  • cacheExtentCacheExtentStyle
    CacheExtentStyle 是一个枚举类, 有 pixel、 viewport 两个取值。
    ①: 为pixel时, cacheExtent 的值为预渲染区域的具体像素长度
    ②:为 viewport 时, cacheExtent 的值是一个乘数,表示有几哥 viewport 的长度,最终的预渲染区域的长度为:cacheExtent * viewport, 这在每一个列表项都占满整个 Viewport 时比较适用,这时 cacheExtent 的值就表示前后个缓存几个页面

1.3 Sliver

对子组件进行构建和布局, 比如 ListView 的Sliver 需要实现子组件按需加载功能, 只有当列表项进入到渲染区域时才会去对它进行构建和布局、渲染。

Sliver 对应的渲染对象是 RenderSliver,它和 RenderBox 的都继承自 RenderObject,不同点是:布局时的约束信息不同。 Render通过传递 BoxConstraints,值包含最大宽高的约束, 而 RenderSliver 传递的则是 SliverConstraints

1.4 可滚动组件的通用配置

几乎所有的可滚动组件都可以在构造时去指定 scrollDirectionreversecontrollerphysicscacheExtent, 这些属性最终都会向上透传给 Scrollable 和 Viewport,这些属性我们可以认为是可滚动组件的通用属性

1.5 ScrollController

可滚动组件都有一个 controller 属性,通过该属性我们可以指定一个 ScrollController 来控制,比如来同步多个组件之间的滑动联动。

1.6 子节点缓存

按需加载在大多数场景中都有正收益,但是也可能在一些场景中产生了副作用,例如:
一个页面由 ListView 组成,它的顶部第一项内容的数据需要在每次页面打开的时进行网络请求获取。 为此我们事先了一个 Header 组件实现,是一个 StatefulWidget ,会在 initState 中请求网络数据。

但这时的问题是, 因为 ListView 是按需加载子节点的,意味 Header 如果不在 Viewport 的预渲染区域内,就会被销毁,重新滑入后有重新构建,这可能会产生大量的网络请求,不符合预期。

为了解决这样的问题, 可滚动组件提供了一种缓存子节点的通用方案, 我们可以对特定的子节点(字界限)进行缓存,后续会介绍到

1.7 Scrollbar

Scrollbar 是一个 Material 风格的滚动指示器,如果要给可滚动组件添加滚动条,只需要将 Scrollbar 作为可滚动组件的任意一个父组件即可,如:

Scrollbar(
        child: SingleChildScrollView(
          ....
        ),
      ),

拓展一个 CupertinoScrollbar, 它是一个 iOS风格的可滚动条,如果使用的是 Scrollbar, 那么在 iOS 会自动切换到这个 Bar。

2. SingleChildScrollView

SingleChildScrollView 类似于 Android 中的 ScrollView,其定义如下:

 class SingleChildScrollView extends StatelessWidget {
    
    
  ...
  const SingleChildScrollView({
    
    
    ...
    this.scrollDirection = Axis.vertical,
    this.reverse = false,
    this.padding,
    bool? primary,
    this.physics,
    this.controller,
    this.child,
    this.dragStartBehavior = DragStartBehavior.start,
    this.clipBehavior = Clip.hardEdge,
    this.restorationId,
    this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
  })

其中大部分属性都是之前已经介绍过的通用组件,这边重点看 primary,这个属性表示是否使用 widget 树中默认的 PrimaryScrollController (MaterialApp 组件树中已经默认包含一个 PrimaryScrollController了),当滑动方向为垂直方向且没有指定 controller 时,primary 默认为 true

通常情况下, SingleChildScrollView 只应用期望的内容不会超过屏幕太多时使用,这是因为其不支持 Sliver按需加载模型,(Android中的 ScrollView 也是不支持按需加载的),所以如果视口可能包含超出屏幕尺寸太多的内容时,使用这个Widget的代价是非常高的,此时就需要考虑使用按需加载的滚动组件

2.1 范例

  @override
  Widget build(BuildContext context) {
    
    
    String str = "ABCDEFGHIJKLMNOPGRSTUVWXYZ";

    return Scrollbar(
        child: SingleChildScrollView(
      padding: const EdgeInsets.all(20.0),
      child: Center(
        child: Column(
          // 动态创建一个 List<Widget>
          children:
              // 每一个字母都用一个 Text 显示,字体为原来的两倍
              str.split("").map((e) => Text(e, textScaleFactor: 2.0)).toList(),
        ),
      ),
    ));

在这里插入图片描述

3 ListView

ListView 是支持懒加载的可滚动组件,来看看 ListView 的定义:

class ListView extends BoxScrollView {
    
    
  ListView({
    
    
    ....
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController? controller,
    bool? primary,
    ScrollPhysics? physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry? padding,
    // ListView 各个构造函数的共同参数
    this.itemExtent,
    // 列表项原型
    this.prototypeItem,
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    // 预渲染区域的长度
    double? cacheExtent,
    List<Widget> children = const <Widget>[],
  })

下面来介绍一下重要的参数:

  • itemExtent
    该参数如果不为null, 就会强制 children 的长度为 itemExtent 的值,这里的长度是指滑动方向的长度。
    例如滑动方向为垂直时,就是 子组件的高度,水平方向则为宽度
    指定 itemExtent 比让子组件自己决定自身长度有更好的性能,因为指定后,滚动系统可以提前知道列表的长度,而无需每次构建子组件的时候再去计算一下, 尤其是在滚动位置频繁变化的时
  • prototypeItem
    如果我们知道列表项长度相同但不知道具体是多少,这时就可以指定一个列表项,该列表项被称为 prototypeItem (列表项原型)。指定后,可滚动组件在 layout 时计算一次它延主轴方向的长度,这样就预先知道了所有列表项的延主轴方向的长度,所以和指定 itemExtent 一样,指定后有更好的性能提升。 注意的是 两者是互斥的,不能同时指定
  • shrinkWrap
    该属性表示是否根据子组件的总长度来设置 ListView 的长度,默认为 false
    默认情况下, ListView 会在滚动方向尽可能占用更多的控件, 当 ListView 在一个无边界的容器中时, shrinkWrap 必须为 true
  • addAutomaticKeepAlives
    在之后介绍 PageView 组件时会详细介绍
  • addRepaintBoundaries
    是否将列表项包裹在 RepaintBoundary 组件中。这个组件可以先简单的理解为一个“绘制边界”,将列表项包裹在其中可以避免列表不必要的重绘。 但如果列表重绘的开销本来就小(例如是文本或者颜色块时),不用这个玩意反而会更加的高效。
    如果列表项自身来维护是否需要添加绘制边界组件,则此参数应该制定为 false

3.1 默认构造函数

必须传入一个 children 参数来接受一个 Widget 列表,这种方式只适合子组件已知且数量比较少的情况,如下:

ListView(
  shrinkWrap: true, 
  padding: const EdgeInsets.all(20.0),
  children: <Widget>[
    const Text('AAA'),
    const Text('BBB'),
    const Text('CCC'),
    const Text('DDD'),
  ],
);

但是我们平时开发时,也遇不到这种情况,按需加载一般用在子组件较多且未知数量、未知内容的情况下,这个时候,我们就需要使用 ListView.builder 或其他构造函数来解决

3.2 ListView.builder

这也是一个构造函数,它的参数和默认的差不多,主要来看下它特有的参数:

  • required IndexedWidgetBuilder itemBuilder
    列表项的构建器,返回值为一个 widget, 当列表滚动到具体的 index 位置时,会调用该构建起构建列表项
  • itemCount
    列表项的数量,如果为null, 则为无线列表

下面来看一个例子:

ListView.builder(
          itemCount: 200,
          itemExtent: 50.0,
          itemBuilder: (BuildContext context, int index) {
    
    
            return ListTile(title: Text("$index"));
          }),

效果为:
在这里插入图片描述

3.3 ListView.separated

ListView.separated 可以在生成的列表项中间添加一个分割组件,它比 ListView.builder 多了一个 separtorBuilder 参数,用于生成分割组件

下面来看下列子,奇数行下面添加一条红色的线, 偶数行下面添加蓝色的线:

   @override
  Widget build(BuildContext context) {
    
    
    Widget divider1 = const Divider(color: Colors.red);
    Widget divider2 = const Divider(color: Colors.blue);

    return Scaffold(
      body: ListView.separated(itemBuilder: (BuildContext context, int index) {
    
    
        return ListTile(title: Text("$index"));
      }, separatorBuilder: (BuildContext context, int index) {
    
    
        return index % 2 != 0 ? divider1 : divider2;
      }, itemCount: 200),
    );
  }

效果为:
在这里插入图片描述

3.4 实现无限加载列表

功能点:

  1. 异步分批拉取数据,用 ListView 展示
  2. 当滑动到列表末尾时,判断是否需要再去拉取数据,如果是,就去拉取,拉取过程中在表尾显示一个loading,拉取成功后将数据插入列表,如果不需要再去拉取,则在表尾提示“没有更多”

代码实现如下:
先导包,我们需要用到一个 english_words 的包,用于生成随机单词

// pubspec.yaml
dependencies:
  english_words: ^4.0.0
class _ScrollWidgetRoteState extends State<ScrollerWidgetRoute> {
    
    
  // 表尾标记
  static const loadingTag = "##loading##";

  // _words 是一个 String 数组,也是 ListView 的数据源
  final _words = <String>[loadingTag];

  @override
  void initState() {
    
    
    super.initState();
    _retrieveData();
  }

  @override
  Widget build(BuildContext context) {
    
    
    return Scaffold(
        body: ListView.separated(
            itemBuilder: (context, index) {
    
    
              // 如果到了表尾
              if (_words[index] == loadingTag) {
    
    
                // 没有100条,继续获取数据
                if (_words.length < 101) {
    
    
                  // 异步拉取数据, 用一个 loading 态展示
                  _retrieveData();
                  return Container(
                    padding: const EdgeInsets.all(18.0),
                    alignment: Alignment.center,
                    child: const SizedBox(
                        width: 24.0,
                        height: 24.0,
                        child: CircularProgressIndicator(strokeWidth: 2.0)),
                  );
                } else {
    
    
                  // 已经加载了 100条数据,不再获取数据
                  return Container(
                    alignment: Alignment.center,
                    padding: const EdgeInsets.all(18.0),
                    child: const Text(
                      "没有更多了",
                      style: TextStyle(color: Colors.grey),
                    ),
                  );
                }
              }
              // 显示单词列表
              return ListTile(title: Text(_words[index]));
            },
            separatorBuilder: (context, index) => const Divider(height: .0),
            itemCount: _words.length));
  }

  void _retrieveData() {
    
    
    Future.delayed(const Duration(seconds: 2)).then((value) {
    
    
      setState(() {
    
    
        // 重新构建列表
        _words.insertAll(
            _words.length - 1,
            // 每次生成20个单词
            generateWordPairs()
                .take(20)
                .map((e) => e.asPascalCase)
                .toList());
      });
    });
  }
}

最后效果如下:
在这里插入图片描述
在这里插入图片描述

3.5 添加固定表头

有时候,我们需要给列表加一个固定表头。 这里抖机灵,实现了下面这样的代码:

Column(
      children: [
        const ListTile(title: Text("我是表头")),
        ListView.builder(itemBuilder: (BuildContext context, int index) {
    
    
          return ListTile(title: Text("$index"));
        })
      ],
    )

运行后,报错: Error: Cannot hit test a render box that has never been laid out.

这是因为 ListView 高度边界无法确定, 我们给其指定一个 SizedBox 试试:

..
        SizedBox(
            height: 400,
            child: ListView.builder(
                itemBuilder: (BuildContext context, int index) {
    
    
              return ListTile(title: Text("$index"));
            }))

在这里插入图片描述
可以看到,我们设置了 400 大小的 ListView, 而屏幕的高度大于400, 会导致底部留白。
假如我们想要列表铺满表头以外的屏幕控件该怎么做?

最直观的就是我们动态去计算,使用 屏幕高度减去状态栏、导航栏、表头的高度就是剩余屏幕的高度,但这种做法不太美,也不够现实,因为布局发生变化,比如表头布局调整导致表头高度改变,那么剩余空间的高度就得重新计算。

这里的答案是使用 Flex 弹性布局, Expanded 组件的效果就是自动拉伸组件大小,因为 Column 是继承自 Flex 的,所以我们使用 Colum + Expanded 来配合实现:

Column(
      children: [
        const ListTile(title: Text("我是表头")),
        Expanded(
            child: ListView.builder(
                itemBuilder: (BuildContext context, int index) {
    
    
              return ListTile(title: Text("$index"));
            }))
      ],
    )

效果:
在这里插入图片描述
是符合预期的~

4 滚动监听及控制

接下来来学习 ScrollController 的用法

4.1 ScrollController

ScrollController 的构造函数如下:

class ScrollController extends ChangeNotifier {
    
    
  ScrollController({
    
    
    double initialScrollOffset = 0.0,
    this.keepScrollOffset = true,
    this.debugLabel,
  })

下面是属性和常用方法介绍

  • initialScrollOffset
    初始滚动位置
  • keepScrollOffset
    是否保存滚动位置
  • offset
    可滚动组件当前的滚动位置
  • jumpTo(double offset)animateTo(double offset, ..)
    这两个方法用于跳转到指定的位置,后者会在跳转时执行一个动画

4.1.1 滚动监听

ScrollController 间接继承自 Listenable,所以可以根据其来监听滚动事件:

controller.addListener(()=>print(controller.offset))

4.1.2 示例

我们创建一个 ListView ,当滚动位置发生变化时,我们先打印出当前的滚动位置,然后判断当前位置是否超过 1000像素,如果超过,则在屏幕右下角显示一个 “返回顶部” 的按钮,点击后可以使 ListView 恢复到初始位置,如果没有超过 1000 像素,则隐藏这个按钮。

代码实现如下:

class _ScrollerControllerRouteState extends State<ScrollControllerRoute> {
    
    
  final ScrollController _controller = ScrollController();

  //是否显示“返回到顶部”的按钮
  bool showToTopBtn = false;

  @override
  void initState() {
    
    
    super.initState();
    // 监听滚动事件, 打印滚动事件
    _controller.addListener(() {
    
    
      print("offset: ${
      
      _controller.offset}");
      if (_controller.offset < 1000 && showToTopBtn) {
    
    
        setState(() {
    
    
          showToTopBtn = false;
        });
      } else if (_controller.offset >= 1000 && !showToTopBtn) {
    
    
        setState(() {
    
    
          showToTopBtn = true;
        });
      }
    });
  }

  @override
  void dispose() {
    
    
    // 避免内存泄漏,需要回收 ScrollerController
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    
    
    return Scaffold(
      appBar: AppBar(title: const Text("滚动控制")),
      body: Scrollbar(
        child: ListView.builder(
            itemCount: 100,
            itemExtent: 45,
            controller: _controller,
            itemBuilder: (context, index) {
    
    
              return ListTile(title: Text("$index"));
            }),
      ),
      floatingActionButton: !showToTopBtn
          ? null
          : FloatingActionButton(
              onPressed: () {
    
    
                // 回到顶部
                _controller.animateTo(.0,
                    duration: const Duration(milliseconds: 200), curve: Curves.ease); // Curves 是动画曲线
              },
              child: const Icon(Icons.arrow_upward)),
    );
  }
}

效果:
在这里插入图片描述
在这里插入图片描述

4.1.3 滚动位置恢复

PageStorage 是一个用于保存页面相关数据的组件,它并不会影响子树的UI外观,它是一个功能型组件,子树的 Widget 可以通过制定不同的 PageStorageKey 来存储各自的数据或状态。

每次滚动结束,可滚动组件都会滚动位置 offset 存储到 PageStorage 中, 当可滚动组件重新创建时再恢复, 如果 ScrollController.keepScrollOffset 为 false 时, 则滚动位置将不会被存储,可滚动组件重新创建的时候会使用 ScrollController.initialScrollOffset ; 为 true 时, 可滚动在 第一次 创建时,会滚动到 initialScrollOffset 处,在接下来的滚动中会存储、恢复滚动位置,而 initialScrollOffset 就会被忽略

当一个路由中包含多个可滚动组件时,如果发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这个时候就可以显示指定 PageStorageKey 来分别跟踪不同的可滚动组件的偏移,如:

ListView(key: PageStorageKey(1), ...);
...
ListView(key: PageStorageKey(2), ...);

不同的 PageStorageKey 需要不同的值,才可以做到分别监听

4.1.3 ScrollPosition

ScrollPosition 是用来保存可滚动组件的滚动位置的。 一个 ScrollController 对象可以同时被多个可滚动组件使用, ScrollController 会为每个滚动组件创建一个 ScrollPosition 对象,这些对象保存在 ScrollController 的 position 属性中。
ScrollPosition 是真正保存滑动位置信息的对象, offset 只是它的便捷属性而已:

double get offset => position.pixels;

假设我们的 ScrollController 被两个 可滚动Widget 使用, 我们又想分别读取两个Widget的偏移量, 这个时候就可以使用 ScrollPostion 来读取了:

controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels

实际上 ScrollController 调用的 animateTo()jumpTo() 其实就是调用 ScrollPostion 的对应方法

4.1.4 ScrollController 控制原理

来看下 ScrollController 的另外三个方法:

ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition oldPosition);
void attach(ScrollPosition position);
void detach(ScrollPosition position);

ScrollController 和可滚动组件关联时:

  1. 可滚动组件首先会调用 ScrollControllercreateScrollPosition() 来创建一个 ScrollPostion 来存储滚动信息
  2. 可滚动组件调用 attach() 方法, 将创建的 ScrollPosition 添加到 ScrollController 的 position 属性
  3. 上一步成为 “注册位置”,在之后 animateTojumpTo() 的调用才会生效

当可滚动组件销毁时:

  1. 调用 ScrollControllerdetach() 方法
  2. ScrollController 的 position 置空
  3. 上一步称为“注销位置”,在之后 animateTojumpTo() 的调用不会生效

4.2 滚动监听

Flutter Widget 树中子 Widget 可以通过发送通知 (Notification) 与 父(包括祖先)Widget来通信。父组件则可以通过 NotificationListener 组件来监听自己关注的通知。

可滚动组件在滚动时会发送 ScrollNotification 类型的通知, ScrollBar 正是通过监听这个通知来实现。

通过 NotificationListener 监听滚动事件和 ScrollController 监听有两个主要的不同点:

  • 前者可以在 可滚动组件到 Widget树根之间任意位置都能监听, 后者只能和具体的可滚动组件关联后才可以
  • 收到滚动事件后获得的信息不同, 前者收到滚动事件的信息,通知中会携带当前滚动位置和 ViewPort的一些信息, 后者只能获取到当前的滚动位置

4.2.1 范例

下面我们通过监听 ListView 的滚动通知,然后显示当前滚动的百分比:

class _ScrollNotificationRouteState extends State<ScrollNotificationRoute> {
    
    
  // 保存进度百分比
  String _progress = "0%";

  @override
  Widget build(BuildContext context) {
    
    
    return Scaffold(
        body: Scrollbar(
      child: NotificationListener<ScrollNotification>(
        onNotification: (ScrollNotification notification) {
    
    
          double progress = notification.metrics.pixels /
              notification.metrics.maxScrollExtent;
          // 重新构建
          setState(() {
    
    
            _progress = "${
      
      (progress * 100).toInt()}%";
          });
          print("BottomEdge:${
      
      notification.metrics.extentAfter == 0}");
          return false;
        },
        child: Stack(
          alignment: Alignment.center,
          children: [
            ListView.builder(
                itemBuilder: (context, index) =>
                    ListTile(title: Text("$index")),
                itemCount: 100,
                itemExtent: 45),
            CircleAvatar(
              // 显示进度百分比
              radius: 30.0,
              child: Text(_progress),
              backgroundColor: Colors.black45,
            )
          ],
        ),
      ),
    ));
  }
}

在这里插入图片描述
在接收到滚动事件时,参数类型为 ScrollNotification,它包括一个 metrics 属性,它的类型是 ScrollMetrics,该属性包含当前 Viewport 以及滚动位置信息,它的信息有:

  • pixels
    当前滚动位置
  • maxScrollExtent
    最大可滚动长度
  • extentBefore
    画出 Viewport 顶部的长度, 此示例中相当于顶部画出屏幕上方的列表长度
  • extentInside
    Viewport 内部长度, 此示例中屏幕显示的列表部分的长度
  • extentAfter
    列表中未滑入 Viewport 部分的长度, 在这个示例中就是列表底部未显示到屏幕范围部分的长度
  • atEdge
    是否滑动到可滚动组件的边界

猜你喜欢

转载自blog.csdn.net/rikkatheworld/article/details/121897521