Flutter 笔记 | Flutter 可滚动组件

news2024/11/17 15:30:13

Sliver布局模型

我们介绍过 Flutter 有两种布局模型:

  1. 基于 RenderBox 的盒模型布局。
  2. 基于 Sliver ( RenderSliver ) 按需加载列表布局。

之前我们主要了解了盒模型布局组件,下面学习基于Sliver的布局组件。

通常可滚动组件的子组件可能会非常多、占用的总高度也会非常大;如果要一次性将子组件全部构建出将会非常昂贵!为此,Flutter中提出一个Sliver(中文为“薄片”的意思)概念,Sliver 可以包含一个或多个子组件。Sliver 的主要作用是配合:加载子组件并确定每一个子组件的布局和绘制信息,如果 Sliver 可以包含多个子组件时,通常会实现按需加载模型。

只有当 Sliver 出现在视口中时才会去构建它,这种模型也称为“基于Sliver的列表按需加载模型”。可滚动组件中有很多都支持基于Sliver的按需加载模型,如ListViewGridView,但是也有不支持该模型的,如SingleChildScrollView

Flutter 中的可滚动组件主要由三个角色组成:ScrollableViewportSliver

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

具体布局过程:

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

比如有一个 ListView,大小撑满屏幕,假设它有 100 个列表项(都是RenderBox)且每个列表项高度相同,结构如图所示:

在这里插入图片描述

图中白色区域为设备屏幕,也是 ScrollableViewportSliver 所占用的空间,三者所占用的空间重合,父子关系为:Sliver 父组件为 ViewportViewport的 父组件为 Scrollable 。注意ListView 中只有一个 Sliver,在 Sliver 中实现了子组件(列表项)的按需加载和布局。

其中顶部和底部灰色的区域为 cacheExtent,它表示预渲染的高度,需要注意这是在可视区域之外,如果 RenderBox 进入这个区域内,即使它还未显示在屏幕上,也是要先进行构建的,预渲染是为了后面进入 Viewport 的时候更丝滑。cacheExtent 的默认值是 250,在构建可滚动列表时我们可以指定这个值,这个值最终会传给 Viewport

Scrollable

用于处理滑动手势,确定滑动偏移,滑动偏移变化时构建 Viewport,我们看一下其关键的属性:

Scrollable({
  ...
  this.axisDirection = AxisDirection.down,
  this.controller,
  this.physics,
  required this.viewportBuilder,  
})
属性说明
axisDirection滚动方向
physics此属性接受一个ScrollPhysics类型的对象,它决定可滚动组件如何响应用户操作,比如用户滑动完抬起手指后,继续执行动画;或者滑动到边界时,如何显示。

默认情况下,Flutter会根据具体平台分别使用不同的ScrollPhysics对象,应用不同的显示效果,如当滑动到边界时,继续拖动的话,在 iOS 上会出现弹性效果,而在 Android 上会出现微光效果。

如果你想在所有平台下使用同一种效果,可以显式指定一个固定的ScrollPhysics,Flutter SDK中包含了两个ScrollPhysics的子类,他们可以直接使用:
1) ClampingScrollPhysics:列表滑动到边界时将不能继续滑动,通常在Android 中 配合 GlowingOverscrollIndicator(实现微光效果的组件) 使用。
2) BouncingScrollPhysics:iOS 下弹性效果。
controller此属性接受一个ScrollController对象。ScrollController的主要作用是控制滚动位置和监听滚动事件。

默认情况下,Widget树中会有一个默认的PrimaryScrollController,如果子树中的可滚动组件没有显式的指定controller,并且primary属性值为true时(默认就为true),可滚动组件会使用这个默认的PrimaryScrollController

这种机制带来的好处是父组件可以控制子树中可滚动组件的滚动行为,例如,Scaffold正是使用这种机制在iOS中实现了点击导航栏回到顶部的功能。
viewportBuilder构建 Viewport 的回调。当用户滑动时,Scrollable 会调用此回调构建新的 Viewport,同时传递一个 ViewportOffset 类型的 offset 参数,该参数描述 Viewport 应该显示那一部分内容。

注意重新构建 Viewport 并不是一个昂贵的操作,因为 Viewport 本身也是 Widget,只是配置信息,Viewport 变化时对应的 RenderViewport 会更新信息,并不会随着 Widget 进行重新构建。

主轴和纵轴

在可滚动组件的坐标描述中,通常将滚动方向称为主轴,非滚动方向称为纵轴。由于可滚动组件的默认方向一般都是沿垂直方向,所以默认情况下主轴就是指垂直方向,水平方向同理。

Viewport

Viewport 比较简单,用于渲染当前视口中需要显示 Sliver

Viewport({
  Key? key,
  this.axisDirection = AxisDirection.down,
  this.crossAxisDirection,
  this.anchor = 0.0,
  required ViewportOffset offset, // 用户的滚动偏移 
  this.center, // 类型为Key,表示从什么地方开始绘制,默认是第一个元素
  this.cacheExtent, // 预渲染区域
  // 该参数用于配合解释cacheExtent的含义,也可以为主轴长度的乘数
  this.cacheExtentStyle = CacheExtentStyle.pixel, 
  this.clipBehavior = Clip.hardEdge,
  List<Widget> slivers = const <Widget>[], // 需要显示的 Sliver 列表
})

需要注意的是:

  • offset:该参数为 Scrollabel 构建 Viewport 时传入,它描述了 Viewport 应该显示那一部分内容。
  • cacheExtentcacheExtentStyleCacheExtentStyle 是一个枚举,有 pixelviewport 两个取值。
    • cacheExtentStyle 值为 pixel 时,cacheExtent 的值为预渲染区域的具体像素长度;
    • cacheExtentStyle 值为 viewport 时,cacheExtent 的值是一个乘数,表示有几个 viewport 的长度,最终的预渲染区域的像素长度为:cacheExtent * viewport 的积, 这在每一个列表项都占满整个 Viewport 时比较实用,这时 cacheExtent 的值就表示前后各缓存几个页面。

Sliver

Sliver 主要作用是对子组件进行构建和布局,比如 ListViewSliver 需要实现子组件(列表项)按需加载功能,只有当列表项进入预渲染区域时才会去对它进行构建和布局、渲染。

Sliver 对应的渲染对象类型是 RenderSliverRenderSliverRenderBox 的相同点是都继承自 RenderObject 类,不同点是在布局的时候约束信息不同。RenderBox 在布局时父组件传递给它的约束信息对应的是 BoxConstraints,只包含最大宽高的约束;而 RenderSliver 在布局时父组件(列表)传递给它的约束是对应的是 SliverConstraints

可滚动组件的通用配置

几乎所有可滚动组件在构造时都能指定的通用属性有:

  • scrollDirection(滑动的主轴)
  • reverse(滑动方向是否反向,指阅读方向的反方向,取决于语言环境)
  • controller
  • physics
  • cacheExtent

这些属性最终会透传给对应的 ScrollableViewport

Scrollbar

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

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

ScrollbarCupertinoScrollbar都是通过监听滚动通知来确定滚动条位置的。CupertinoScrollbar是 iOS 风格的滚动条,如果你使用的是Scrollbar,那么在iOS平台它会自动切换为CupertinoScrollbar

SingleChildScrollView

SingleChildScrollView类似于Android中的ScrollView,它只能接收一个子组件,定义如下:

SingleChildScrollView({
  this.scrollDirection = Axis.vertical, // 滚动方向,默认是垂直方向
  this.reverse = false, 
  this.padding, 
  bool primary, 
  this.physics, 
  this.controller,
  this.child,
})

除了前面介绍过的可滚动组件的通用属性外,这里重点关注primary属性:

  • 它表示是否使用 widget 树中默认的PrimaryScrollControllerMaterialApp 组件树中已经默认包含一个 PrimaryScrollController 了);
  • 当滑动方向为垂直方向(scrollDirection值为Axis.vertical)并且没有指定controller时,primary默认为true

需要注意的是,通常SingleChildScrollView只应在期望的内容不会超过屏幕太多时使用,这是因为SingleChildScrollView不支持基于 Sliver 的延迟加载模型,所以如果预计视口可能包含超出屏幕尺寸太多的内容时,那么使用SingleChildScrollView将会非常昂贵(性能差),此时应该使用一些支持Sliver延迟加载的可滚动组件,如ListView

下面是一个使用SingleChildScrollView将大写字母 A-Z 沿垂直方向显示的例子:

class SingleChildScrollViewTestRoute extends StatelessWidget {
  
  Widget build(BuildContext context) {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    return Scrollbar( // 显示进度条
      child: SingleChildScrollView(
        padding: EdgeInsets.all(16.0),
        child: Center(
          child: Column( 
            //动态创建一个List<Widget>  
            children: str.split("") 
                //每一个字母都用一个Text显示,字体为原来的两倍
                .map((c) => Text(c, textScaleFactor: 2.0,)) 
                .toList(),
          ),
        ),
      ),
    );
  }
}

效果:

在这里插入图片描述

ListView

ListView是最常用的可滚动组件之一,它可以沿一个方向线性排布所有子组件,并且它也支持列表项懒加载(在需要时才会创建)。

ListView默认构造函数

我们看看ListView的默认构造函数定义:

ListView({
  ...  
  // 可滚动 widget 公共参数
  Axis scrollDirection = Axis.vertical,  // 滑动方向,Axis.horizontal水平列表Axis.vertical垂直列表
  bool reverse = false, // 滑动方向是否反向
  ScrollController? controller, // 控制可滚动组件的滚动
  bool? primary, // 是否使用 widget 树中默认的PrimaryScrollController
  ScrollPhysics? physics, // 如滑动到边界时效果
  EdgeInsetsGeometry? padding, // 内边距
  
  // ListView 各个构造函数的共同参数  
  double? itemExtent,
  Widget? prototypeItem, // 列表项原型 
  bool shrinkWrap = false,
  bool addAutomaticKeepAlives = true,
  bool addRepaintBoundaries = true,
  double? cacheExtent, // 预渲染区域长度
    
  // 列表项元素
  List<Widget> children = const <Widget>[],
})

上面参数分为两组:第一组是可滚动组件的公共参数,前面已经介绍过,不再赘述;第二组是ListView各个构造函数(ListView有多个构造函数)的共同参数,我们重点来看看这些参数:

  • itemExtent:该参数如果不为null,则会强制children的“长度”为itemExtent的值;这里的“长度”是指滚动方向上子组件的长度,也就是说如果滚动方向是垂直方向,则itemExtent代表子组件的高度;如果滚动方向为水平方向,则itemExtent就代表子组件的宽度

    ListView中,指定itemExtent比让子组件自己决定自身长度会有更好的性能,这是因为指定itemExtent后,滚动系统可以提前知道列表的长度,而无需每次构建子组件时都去再计算一下,尤其是在滚动位置频繁变化时(滚动系统需要频繁去计算列表高度)。

  • prototypeItem:如果我们知道列表中的所有列表项长度都相同但不知道具体是多少,这时我们可以指定一个列表项,该列表项被称为 prototypeItem(列表项原型)。指定 prototypeItem 后,可滚动组件会在 layout 时计算一次它延主轴方向的长度,这样也就预先知道了所有列表项的延主轴方向的长度,所以和指定 itemExtent 一样,指定 prototypeItem 会有更好的性能。

    注意:itemExtentprototypeItem 互斥,不能同时指定它们。

  • shrinkWrap:该属性表示是否根据子组件的总长度来设置ListView的长度,默认值为false 。默认情况下,ListView会在滚动方向尽可能多的占用空间。

    注意:当ListView在一个无边界(滚动方向上)的容器中时,shrinkWrap必须为true

  • addAutomaticKeepAlives:该属性我们将在介绍 PageView 组件时详细解释。

  • addRepaintBoundaries:该属性表示是否将列表项(子组件)包裹在RepaintBoundary组件中。RepaintBoundary 可以先简单理解为它是一个”绘制边界“,将列表项包裹在RepaintBoundary中可以避免列表项不必要的重绘,但是当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加RepaintBoundary反而会更高效。如果列表项自身来维护是否需要添加绘制边界组件,则此参数应该指定为 false

注意:上面这些参数并非ListView特有,其他可滚动组件也可能会拥有这些参数,它们的含义是相同的。

默认构造函数有一个children参数,它接受一个Widget列表(List<Widget>)。这种方式适合只有少量的子组件数量已知且比较少的情况,反之则应该使用ListView.builder 按需动态构建列表项。

注意:虽然这种方式将所有children一次性传递给 ListView,但子组件仍然是在需要时才会加载(build、布局、绘制),也就是说通过默认构造函数构建的 ListView 也是基于 Sliver 的列表懒加载模型。

下面是一个例子:

ListView(
  shrinkWrap: true, 
  padding: const EdgeInsets.all(20.0),
  children: <Widget>[
    const Text('I\'m dedicating every day to you'),
    const Text('Domestic life was never quite my style'),
    const Text('When you smile, you knock me out, I fall apart'),
    const Text('And I thought I was so smart'),
  ],
);

可以看到,虽然使用默认构造函数创建的列表也是懒加载的,但我们还是需要提前将 Widget 创建好,等到真正需要加载的时候才会对 Widget 进行布局和绘制。

ListView.builder

ListView.builder适合列表项比较多或者列表项不确定的情况,下面看一下ListView.builder的核心参数列表:

ListView.builder({
  // ListView公共参数已省略  
  ...
  required IndexedWidgetBuilder itemBuilder,
  int itemCount,
  ...
})
  • itemBuilder:它是列表项的构建器,类型为IndexedWidgetBuilder,返回值为一个widget。当列表滚动到具体的index位置时,会调用该构建器构建列表项。

  • itemCount:列表项的数量,如果为null,则为无限列表。

下面看一个例子:

ListView.builder(
  itemCount: 100,
  itemExtent: 50.0, // 强制高度为 50.0
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text("$index"));
  }
);

效果:

在这里插入图片描述

ListView.separated

ListView.separated可以在生成的列表项之间添加一个分割组件,它比ListView.builder多了一个separatorBuilder参数,该参数是一个分割组件生成器。

下面我们看一个例子:奇数行添加一条蓝色下划线,偶数行添加一条绿色下划线。

class ListView3 extends StatelessWidget {
  
  Widget build(BuildContext context) {
    // 下划线widget预定义以供复用  
    Widget divider1 = Divider(color: Colors.blue,);
    Widget divider2 = Divider(color: Colors.green);
    return ListView.separated(
      itemCount: 100, 
      itemBuilder: (BuildContext context, int index) {  // 列表项构造器
        return ListTile(title: Text("$index"));
      }, 
      separatorBuilder: (BuildContext context, int index) { // 分割器构造器
        return index%2==0?divider1:divider2;
      },
    );
  }
}

效果:

在这里插入图片描述

固定高度列表

前面说过,给列表指定 itemExtentprototypeItem 会有更高的性能,所以当我们知道列表项的高度都相同时,强烈建议指定 itemExtentprototypeItem

下面看一个示例:

class FixedExtentList extends StatelessWidget {
  const FixedExtentList({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ListView.builder(
   	  prototypeItem: ListTile(title: Text("1")),
      // itemExtent: 56,
      itemBuilder: (context, index) {
        // LayoutLogPrint是一个自定义组件,在布局时可以打印当前上下文中父组件给子组件的约束信息
        return LayoutLogPrint(
          tag: index, 
          child: ListTile(title: Text("$index")),
        );
      },
    );
  }
}

因为列表项都是一个 ListTile,高度相同,但是我们不知道 ListTile 的高度是多少,所以指定了prototypeItem ,运行后,控制台打印:

flutter: 0: BoxConstraints(w=428.0, h=56.0)
flutter: 1: BoxConstraints(w=428.0, h=56.0)
flutter: 2: BoxConstraints(w=428.0, h=56.0)
...

可见 ListTile 的高度是 56 ,所以我们指定 itemExtent56也是可以的。但是还是建议优先指定原型,这样的话在列表项布局修改后,仍然可以正常工作(前提是每个列表项的高度相同)。

如果本例中不指定 itemExtentprototypeItem ,我们看看控制台日志信息:

flutter: 0: BoxConstraints(w=428.0, 0.0<=h<=Infinity)
flutter: 1: BoxConstraints(w=428.0, 0.0<=h<=Infinity)
flutter: 2: BoxConstraints(w=428.0, 0.0<=h<=Infinity)
...

可以发现,列表不知道列表项的具体高度,高度约束变为 0.0Infinity

ListView 原理

ListView 内部组合了 ScrollableViewportSliver,需要注意:

  1. ListView 中的列表项组件都是 RenderBox并不是 Sliver, 这个一定要注意。

  2. 一个 ListView 中只有一个Sliver,对列表项进行按需加载的逻辑是 Sliver 中实现的。

  3. ListViewSliver 默认是 SliverList,如果指定了 itemExtent ,则会使用SliverFixedExtentList;如果 prototypeItem 属性不为空,则会使用 SliverPrototypeExtentList,无论是是哪个,都实现了子组件的按需加载模型。

实例:无限加载列表

假设我们要从数据源异步分批拉取一些数据,然后用ListView展示,当我们滑动到列表末尾时,判断是否需要再去拉取数据,如果是,则去拉取,拉取过程中在表尾显示一个loading,拉取成功后将数据插入列表;如果不需要再去拉取,则在表尾提示"没有更多"。

代码如下:

import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';
import 'package:flutter/rendering.dart';

class InfiniteListView extends StatefulWidget {
  
  _InfiniteListViewState createState() => _InfiniteListViewState();
}

class _InfiniteListViewState extends State<InfiniteListView> {
  static const loadingTag = "##loading##"; //表尾标记
  var _words = <String>[loadingTag];

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

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

  // 模拟网络请求
  void _retrieveData() {
    Future.delayed(Duration(seconds: 2)).then((e) {
      setState(() {
        // 重新构建列表
        _words.insertAll(
          _words.length - 1, // 每次插入到 loadingTag 之前
          generateWordPairs().take(20).map((e) => e.asPascalCase).toList(), // 每次生成20个单词
        );
      });
    });
  }
}

效果:

在这里插入图片描述

添加固定列表头

很多时候我们需要给列表添加一个固定表头,比如我们想实现一个商品列表,需要在列表顶部添加一个“商品列表”标题,期望的效果如图所示:

在这里插入图片描述
我们按照之前经验,写出如下代码:


Widget build(BuildContext context) {
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
    }),
  ]);
}

然后运行,发现并没有出现我们期望的效果,相反触发了一个异常;

Error caught by rendering library, thrown during performResize()。
Vertical viewport was given unbounded height ...

从异常信息中我们可以看到是因为ListView高度边界无法确定引起,所以解决的办法也很明显,我们需要给ListView指定边界,我们通过SizedBox指定一个列表高度看看是否生效:

... //省略无关代码
SizedBox(
  height: 400, // 指定列表高度为400
  child: ListView.builder(
    itemBuilder: (BuildContext context, int index) {
      return ListTile(title: Text("$index"));
    },
  ),
),
...

效果:

在这里插入图片描述

可以看到,现在没有触发异常并且列表已经显示出来了,但是我们的手机屏幕高度要大于 400,所以底部会有一些空白。那如果我们要实现列表铺满除表头以外的屏幕空间应该怎么做?直观的方法是我们去动态计算,用屏幕高度减去状态栏、导航栏、表头的高度即为剩余屏幕高度,代码如下:

... //省略无关代码
SizedBox(
  // Material设计规范中状态栏、导航栏、ListTile高度分别为24、56、56 
  height: MediaQuery.of(context).size.height - 24 - 56 - 56,
  child: ListView.builder(itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text("$index"));
  }),
)
...    

效果:

在这里插入图片描述

可以看到,我们期望的效果实现了,但是这种方法并不优雅,如果页面布局发生变化,比如表头布局调整导致表头高度改变,那么剩余空间的高度就得重新计算。

那么有什么方法可以自动拉伸ListView以填充屏幕剩余空间的方法吗?当然有!答案就是Flex。前面已经介绍过在弹性布局中,可以使用Expanded自动拉伸组件大小,并且我们也说过Column是继承自Flex的,所以我们可以直接使用Column + Expanded来实现,代码如下:


Widget build(BuildContext context) {
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    Expanded(
      child: ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
      }),
    ),
  ]);
}

Expandedflex 参数默认为 1,所以上面代码中,Expanded 会占满 Column 中除 ListTile 以外的剩余空间,运行效果跟开头期望的图一致。

ListView.custom

它需要实现一个SliverChildDelegate 用来给 ListView 生成列表项组件,通常很少使用,下面是 API 文档中的一个示例:

class MyListView extends StatefulWidget {
  const MyListView({super.key});

  
  State<MyListView> createState() => _MyListViewState();
}

class _MyListViewState extends State<MyListView> {
  List<String> items = <String>['1', '2', '3', '4', '5'];

  void _reverse() {
    setState(() {
      items = items.reversed.toList();
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: ListView.custom(
          childrenDelegate: SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                return KeepAlive(
                  data: items[index],
                  key: ValueKey<String>(items[index]),
                );
              },
              childCount: items.length,
              findChildIndexCallback: (Key key) {
                final ValueKey<String> valueKey = key as ValueKey<String>;
                final String data = valueKey.value;
                return items.indexOf(data);
              }),
        ),
      ),
      bottomNavigationBar: BottomAppBar(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            TextButton(
              onPressed: () => _reverse(),
              child: const Text('Reverse items'),
            ),
          ],
        ),
      ),
    );
  }
}

class KeepAlive extends StatefulWidget {
  const KeepAlive({
    required Key key,
    required this.data,
  }) : super(key: key);

  final String data;

  
  State<KeepAlive> createState() => _KeepAliveState();
}

class _KeepAliveState extends State<KeepAlive>
    with AutomaticKeepAliveClientMixin {
  
  bool get wantKeepAlive => true;

  
  Widget build(BuildContext context) {
    super.build(context);
    return Text(widget.data);
  }
}

这个 ListView 使用自定义 SilverChildBuilderDelegate 来支持子元素的重排序。

AnimatedList

AnimatedListListView 的功能大体相似,不同的是, AnimatedList 可以在列表中插入或删除节点时执行一个动画,在需要添加或删除列表项的场景中会提高用户体验。

AnimatedList 是一个 StatefulWidget,它对应的 State 类型为 AnimatedListState,添加和删除元素的方法位于 AnimatedListState 中:

void insertItem(int index, { Duration duration = _kDuration });

void removeItem(int index, AnimatedListRemovedItemBuilder builder, { Duration duration = _kDuration }) ;

下面我们看一个示例:实现下面这样的一个列表,点击底部 + 按钮时向列表追加一个列表项;点击每个列表项后面的删除按钮时,删除该列表项,添加和删除时分别执行指定的动画,运行效果如图所示:

在这里插入图片描述
初始的时候有5个列表项,先点击了 + 号按钮,会添加一个 6,添加过程执行渐显动画。然后点击了 4 后面的删除按钮,删除的时候执行了一个渐隐+收缩的合成动画。

下面是实现代码:

class AnimatedListRoute extends StatefulWidget {
  const AnimatedListRoute({Key? key}) : super(key: key);

  
  State createState() => _AnimatedListRouteState();
}

class _AnimatedListRouteState extends State<AnimatedListRoute> {
  var data = <String>[];
  int counter = 5;

  final globalKey = GlobalKey<AnimatedListState>();

  
  void initState() {
    for (var i = 0; i < counter; i++) {
      data.add('${i + 1}');
    }
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return Stack(
      children: [
        AnimatedList(
          key: globalKey,
          initialItemCount: data.length,
          itemBuilder: (
            BuildContext context,
            int index,
            Animation<double> animation,
          ) {
            //添加列表项时会执行渐显动画
            return FadeTransition(
              opacity: animation,
              child: buildItem(context, index),
            );
          },
        ),
        buildAddBtn(),
      ],
    );
  }

  // 创建一个 “+” 按钮,点击后会向列表中插入一项
   Widget buildAddBtn() {
    return Positioned(
      bottom: 30,
      left: 0,
      right: 0,
      child: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () { 
          data.add('${++counter}'); // 添加一个列表项
          // 告诉列表项有新添加的列表项
          globalKey.currentState!.insertItem(data.length - 1);
          print('添加 $counter');
        },
      ),
    );
  }
 
  // 构建列表项
  Widget buildItem(context, index) {
    String char = data[index];
    return ListTile(
      // 数字不会重复,所以作为Key
      key: ValueKey(char),
      title: Text(char),
      trailing: IconButton(
        icon: Icon(Icons.delete), 
        onPressed: () => onDelete(context, index), // 点击时删除
      ),
    );
  }

  void onDelete(context, index) {
    setState(() {
      globalKey.currentState!.removeItem(index, (context, animation) {
          // 删除过程执行的是反向动画,animation.value 会从1变为0
          var item = buildItem(context, index);
          print('删除 ${data[index]}');
          data.removeAt(index);
          // 删除动画是一个合成动画:渐隐 + 缩小列表项告诉
          return FadeTransition(
            opacity: CurvedAnimation(parent: animation, 
              curve: const Interval(0.5, 1.0), // 让透明度变化的更快一些
            ),
            child: SizeTransition( // 不断缩小列表项的高度
              sizeFactor: animation,
              axisAlignment: 0.0,
              child: item,
            ),
          );
        },
        duration: const Duration(milliseconds: 200), // 动画时间为 200 ms
      );
    });
  }
}

代码很简单,但我们需要注意,我们的数据是单独在 data 中维护的,调用 AnimatedListState 的插入和移除方法知识相当于一个通知:在什么位置执行插入或移除动画,仍然是数据驱动的(响应式并非命令式)。

其他ListView变种

除了上面的ListView组件,Flutter SDK 中还提供了一些其他ListView变种,它们可以支持一些酷炫的效果,例如:

  • ListWheelScrollView:它的渲染效果类似于车轮(或者滚筒),是一个3D滚动的效果。

  • ReorderableListView:它可以通过长按拖动某一项到另一个位置来重新排序的列表组
    件。

ListWheelScrollView

ListWheelScrollView 的用法和 ListView 基本相同,基础用法:

ListWheelScrollView(
	itemExtent: 150, 
	children: <Widget>[ ... ], 
);

children 是子控件,itemExtent 指定每一个 Item 的高度。

当有大量数据的时候可以使用ListWheelScrollView.useDelegate方法,就像 ListView.builder 一样,用法如下:

ListWheelScrollView.useDelegate(
      itemExtent: 150, 
      childDelegate: ListWheelChildBuilderDelegate(
        builder: (context, index) {
          return Container(
            margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 30), 
            color: Colors.primaries[index % 10], 
            alignment: Alignment.center, 
            child: Text('$index'), );
        },
        childCount: 100), 
    );

ListWheelScrollView的一些常用属性:

  • diameterRatio: 调整滚轮的直径,diameterRatio 是圆筒直径和主轴渲染窗口的尺寸的比,默认值是 2,如果是垂直方向,主轴渲染窗口的尺寸是 ListWheelScrollView 的高。diameterRatio 越小表示圆筒越圆。

  • perspective: 表示圆柱投影透视图,类似 OpenGLES 中透视投影,理解为看圆
    柱的距离,为 0 时表示从无限远处看,1 表示从无限近处看,值的范围(0,0.01],注
    意是左开右闭区间,默认值是 0.003,值越大,渲染效果越圆。

  • offAxisFraction : 表示车轮水平偏离中心的程度。

  • useMagnifiermagnification :实现放大镜效果,useMagnifier 是否启用
    放大镜,magnification 属性是放大倍率。

  • squeeze :表示车轮上的子控件数量与在同等大小的平面列表上的子控件数量之
    比,例如,如果高度为 100pxitemExtent20px,那么 5 个项将放在一个等效
    的平面列表中。当 squeeze1 时,RenderListWheelViewport 中也会显示 5 个子
    控件。当 squeeze2 时,RenderListWheelViewport 中将显示 10 个子控件,默
    认值为 1

示例代码:

import 'package:flutter/material.dart';
class CustomListWheelScrollView extends StatefulWidget {
  
  _CustomListWheelScrollViewState createState() => _CustomListWheelScrollViewState();
}

class _CustomListWheelScrollViewState extends State<CustomListWheelScrollView> {
  var data = <Color>[
    Colors.orange[50],
    Colors.orange[100],
    Colors.orange[200],
    Colors.orange[300],
    Colors.orange[400],
    Colors.orange[500],
    Colors.orange[600],
    Colors.orange[700],
    Colors.orange[800],
    Colors.orange[900],
  ];

  Color _color = Colors.blue;

  
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        _buildCircle(),
        Container(
          height: 150,
          width: 300,
          child: ListWheelScrollView(
            perspective: 0.006,
            itemExtent: 50,
            onSelectedItemChanged: (index){
              print('onSelectedItemChanged:$index');
              setState(() => _color=data[index]);
            },
            children: data.map((color) => _buildItem(color)).toList(),
          ),
        ),
      ],
    );
  }

  Widget _buildCircle() => Container(
        margin: EdgeInsets.only(bottom: 5),
        width: 30,
        height: 30,
        decoration: BoxDecoration(
          color: _color,
          shape: BoxShape.circle
        ),
      );

  Widget _buildItem(Color color) {
    return Container(
      key: ValueKey(color)  ,
      alignment: Alignment.center,
      height: 50,
      color: color,
      child: Text(
        colorString(color),
        style: TextStyle(color: Colors.white, shadows: [
          Shadow(color: Colors.black, offset: Offset(.5, .5), blurRadius: 2)
        ]),
      ),
    );
  }

  String colorString(Color color) =>
      "#${color.value.toRadixString(16).padLeft(8, '0').toUpperCase()}";
}

效果:

在这里插入图片描述

ReorderableListView

ReorderableListView 需要设置 childrenonReorder 属性,children 是子控件,onReorder 是拖动完成后的回调。

示例代码:

import 'package:flutter/material.dart';
class CustomReorderableListView extends StatefulWidget {
  
  _CustomReorderableListViewState createState() => _CustomReorderableListViewState();
}

class _CustomReorderableListViewState extends State<CustomReorderableListView> {
  var data = <Color>[
    Colors.yellow[50],
    Colors.yellow[100],
    Colors.yellow[200],
    Colors.yellow[300],
    Colors.yellow[400],
    Colors.yellow[500],
    Colors.yellow[600],
    Colors.yellow[700],
    Colors.yellow[800],
    Colors.yellow[900],
  ];

  
  Widget build(BuildContext context) {
    return Container(
      height: 250,
      child: ReorderableListView(
        padding: EdgeInsets.all(10),
        header: Container(
          color: Colors.blue,
          alignment: Alignment.center,
            height: 50,
            child: Text('长按拖拽进行换位',style: TextStyle(color: Colors.white),)),
        onReorder: _handleReorder,
        children: data.map((color) => _buildItem(color)).toList(),
      ),
    );
  }

  void _handleReorder(int oldIndex, int newIndex) {
    if (oldIndex < newIndex) {
      newIndex -= 1;
    }

    setState(() {
      final element = data.removeAt(oldIndex);
      data.insert(newIndex, element);
    });

  }

  Widget _buildItem(Color color) {
    return Container(
      key: ValueKey(color)  ,
      alignment: Alignment.center,
      height: 50,
      color: color,
      child: Text(
        colorString(color),
        style: TextStyle(color: Colors.white, shadows: [
          Shadow(color: Colors.black, offset: Offset(.5, .5), blurRadius: 2)
        ]),
      ),
    );
  }

  String colorString(Color color) =>
      "#${color.value.toRadixString(16).padLeft(8, '0').toUpperCase()}";
}

效果:

在这里插入图片描述

ReorderableListView 除了可滚动组件的通用配置属性外,还提供了一个 header 参数,可以在列表的顶部显示一个顶部条。

另外,ReorderableListView 的每个子控件必须设置唯一的 keyReorderableListView 没有“懒加载”模式,需要一次构建所有的子组件,所以 ReorderableListView 并不适合加载大量数据的列表,它适用于有限集合且需要排序的情况,比如手机系统里面设置语言的功能,通过拖动对语言排序。

onReorder 是拖动完成的回调,它的第一个参数是旧的数据索引,第二个参数是拖动到
位置的索引,回调里面需要对数据进行排序并通过 setState 刷新数据。

ScrollController 监听和控制滚动

前面提到,可滚动组件的通用配置都有一个 controller 属性,通过该属性我们可以指定一个 ScrollController 来控制可滚动组件的滚动,比如可以通过ScrollController来同步多个组件的滑动联动。 ScrollController 需要结合可滚动组件一起工作。

下面是ScrollController构造函数:

ScrollController({
  double initialScrollOffset = 0.0, // 初始滚动位置
  this.keepScrollOffset = true, // 是否保存滚动位置
  ...
})

ScrollController常用的方法和属性:

  • controller.offset:可以获得可滚动组件当前的滚动位置。
  • jumpTo(double offset):跳转到指定的位置。
  • animateTo(double offset,...):跳转到指定的位置,带动画。

滚动监听

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

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

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

代码如下:

class ScrollControllerTestRoute extends StatefulWidget {
  
  ScrollControllerTestRouteState createState() {
    return ScrollControllerTestRouteState();
  }
}

class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
  ScrollController _controller = ScrollController();
  bool showToTopBtn = false; // 是否显示“返回到顶部”按钮

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

  
  void dispose() { 
    _controller.dispose(); // 为了避免内存泄露,需要调用_controller.dispose
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("滚动控制")),
      body: Scrollbar(
        child: ListView.builder(
          itemCount: 100,
          itemExtent: 50.0, // 列表项高度固定时,显式指定高度是一个好习惯(性能消耗小)
          controller: _controller,
          itemBuilder: (context, index) {
            return ListTile(title: Text("$index"),);
          }
        ),
      ),
      floatingActionButton: !showToTopBtn ? null : FloatingActionButton(
        child: Icon(Icons.arrow_upward),
        onPressed: () {
          // 返回到顶部时执行动画
          _controller.animateTo(
            .0,
            duration: Duration(milliseconds: 200),
            curve: Curves.ease,
          );
        }
      ),
    );
  }
}

效果:

在这里插入图片描述

由于列表项高度为 50 像素,当滑动到第 20 个列表项后,右下角 “返回顶部” 按钮会显示,点击该按钮,ListView 会在返回顶部的过程中执行一个滚动动画。

滚动位置恢复 PageStorage

PageStorage是一个用于保存页面(路由)相关数据的组件,它并不会影响子树的UI外观,其实,PageStorage是一个功能型组件,它拥有一个存储桶(bucket),子树中的Widget可以通过指定不同的PageStorageKey来存储各自的数据或状态。

每次滚动结束,可滚动组件都会将滚动位置offset存储到PageStorage中,当可滚动组件重新创建时再恢复。

  • 如果ScrollController.keepScrollOffsetfalse,则滚动位置将不会被存储,可滚动组件重新创建时会使用ScrollController.initialScrollOffset
  • 如果ScrollController.keepScrollOffsettrue,可滚动组件在第一次创建时,会滚动到initialScrollOffset处,因为这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而initialScrollOffset会被忽略。

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

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

不同的PageStorageKey,需要不同的值,这样才可以为不同可滚动组件保存其滚动位置。

注意:一个路由中包含多个可滚动组件时,如果要分别跟踪它们的滚动位置,并非一定就得给他们分别提供PageStorageKey。这是因为Scrollable本身是一个StatefulWidget,它的状态中也会保存当前滚动位置,所以,只要可滚动组件本身没有被从树上移除(detach),那么其State就不会销毁(dispose),滚动位置就不会丢失。只有当Widget发生结构变化,导致可滚动组件的State销毁或重新构建时才会丢失状态,这种情况就需要显式指定PageStorageKey,通过PageStorage来存储滚动位置,一个典型的场景是在使用TabBarView时,在Tab发生切换时,Tab页中的可滚动组件的State就会销毁,这时如果想恢复滚动位置就需要指定PageStorageKey

ScrollPosition

ScrollPosition是用来保存可滚动组件的滚动位置的。

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

double get offset => position.pixels;

一个ScrollController虽然可以对应多个可滚动组件,但是有一些操作,如读取滚动位置offset,则需要一对一!在一对多的情况下,我们可以通过其他方法读取滚动位置,举个例子,假设一个ScrollController同时被两个可滚动组件使用,那么我们可以通过如下方式分别读取他们的滚动位置:

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

我们可以通过controller.positions.length来确定controller被几个可滚动组件使用。

ScrollPosition有两个常用方法:animateTo()jumpTo(),它们是真正来控制跳转滚动位置的方法,而 ScrollController 的两个同名方法,内部最终都会调用 ScrollPosition 的这两个方法。

ScrollController控制原理

我们来介绍一下ScrollController的另外三个方法:

ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition oldPosition);
void attach(ScrollPosition position) ;
void detach(ScrollPosition position) ;
  • ScrollController和可滚动组件关联时,可滚动组件首先会调用ScrollControllercreateScrollPosition()方法来创建一个ScrollPosition来存储滚动位置信息,

  • 接着,可滚动组件会调用attach()方法,将创建的ScrollPosition添加到ScrollControllerpositions列表属性中,这一步称为“注册位置”,只有注册后animateTo()jumpTo()才可以被调用。

  • 当可滚动组件销毁时,会调用ScrollControllerdetach()方法,将其ScrollPosition对象从ScrollControllerpositions列表中移除,这一步称为“注销位置”,注销后animateTo()jumpTo() 将不能再被调用。

需要注意的是,ScrollControlleranimateTo()jumpTo()内部会调用所有ScrollPositionanimateTo()jumpTo(),以实现所有和该ScrollController关联的可滚动组件都滚动到指定的位置。

滚动通知

Flutter Widget树中子Widget可以通过发送通知(Notification)与父(包括祖先) Widget通信。父级组件可以通过NotificationListener组件来监听自己关注的通知,这种通信方式类似于Web开发中浏览器的事件冒泡。

可滚动组件在滚动时会发送ScrollNotification类型的通知,ScrollBar正是通过监听滚动通知来实现的。通过NotificationListener监听滚动事件和通过ScrollController监听滚动有两个主要的不同:

  1. NotificationListener可以在可滚动组件到widget树之间任意位置监听。而ScrollController只能和具体的可滚动组件关联后才可以。
  2. 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置。

也就是说,NotificationListener监听的位置和信息要更加全面和详细。

实例:下面代码监听ListView的滚动通知,然后显示当前滚动进度百分比

class ScrollNotificationTestRoute extends StatefulWidget {
  const ScrollNotificationTestRoute({Key? key}) : super(key: key);

  
  State createState() => _ScrollNotificationTestRouteState();
}

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

  
  Widget build(BuildContext context) {
    return 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}");
          print("atEdge: ${notification.metrics.atEdge}");
          return false;
          //return true; // 放开此行注释后,进度条将失效
        },
        child: Stack(
          alignment: Alignment.center,
          children: <Widget>[
            ListView.builder(
              itemCount: 100,
              itemExtent: 50.0,
              itemBuilder: (context, index) => ListTile(title: Text("$index")),
            ),
            CircleAvatar( // 显示进度百分比 
              radius: 30.0,
              backgroundColor: Colors.black54,
              child: Text(_progress),
            )
          ],
        ),
      ),
    );
  }
}

效果:

在这里插入图片描述

在接收到滚动事件时,参数类型为ScrollNotification,它包括一个metrics属性,它的类型是ScrollMetrics,该属性包含当前ViewPort及滚动位置等信息:

  • pixels:当前滚动位置。
  • maxScrollExtent:最大可滚动长度。
  • extentBefore:滑出ViewPort顶部的长度;此示例中相当于顶部滑出屏幕上方的列表长度。
  • extentInsideViewPort内部长度;此示例中屏幕显示的列表部分的长度。
  • extentAfter:未滑入ViewPort部分的长度;此示例中列表底部未显示到屏幕范围部分的长度。
  • atEdge:是否滑到了可滚动组件的边界(此示例中相当于列表顶或底部)。

ScrollMetrics还有一些其他属性,可自行查阅API文档。

GridView

GridView 默认构造函数

GridView({
    Key? key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController? controller,
    bool? primary,
    ScrollPhysics? physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry? padding,
    required this.gridDelegate,  //下面解释
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    double? cacheExtent, 
    List<Widget> children = const <Widget>[],
    ...
  })

GridViewListView的大多数参数都是相同的,它们的含义也都相同的,我们唯一需要关注的是gridDelegate参数,类型是SliverGridDelegate,它的作用是控制GridView子组件如何排列(layout)。

SliverGridDelegate是一个抽象类,定义了GridView Layout相关接口,子类需要通过实现它们来实现具体的布局算法。Flutter中提供了两个SliverGridDelegate的子类:SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent,我们可以直接使用,下面我们分别来介绍一下它们。

SliverGridDelegateWithFixedCrossAxisCount

该子类实现了一个横轴为固定数量子元素的layout算法,其构造函数为:

SliverGridDelegateWithFixedCrossAxisCount({
   double crossAxisCount, 
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})
  • crossAxisCount:横轴子元素的数量。此属性值确定后子元素在横轴的长度就确定了,即ViewPort横轴长度除以crossAxisCount的商。
  • mainAxisSpacing:主轴方向的间距。
  • crossAxisSpacing:横轴方向子元素的间距。
  • childAspectRatio:子元素在横轴长度和主轴长度的比例。由于crossAxisCount指定后,子元素横轴长度就确定了,然后通过此参数值就可以确定子元素在主轴的长度。

可以发现,子元素的大小是通过crossAxisCountchildAspectRatio两个参数共同决定的。注意,这里的子元素指的是子组件的最大显示空间,注意确保子组件的实际大小不要超出子元素的空间。

下面看一个例子:

GridView(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 3, // 横轴三个子widget
      childAspectRatio: 1.0 // 宽高比为 1 
  ),
  children:<Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast)
  ]
);

运行效果如图所示:

在这里插入图片描述

SliverGridDelegateWithMaxCrossAxisExtent

该子类实现了一个横轴子元素为固定最大长度的layout算法,其构造函数为:

SliverGridDelegateWithMaxCrossAxisExtent({
  double maxCrossAxisExtent,
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})
  • maxCrossAxisExtent为子元素在横轴上的最大长度,之所以是“最大”长度,是因为横轴方向每个子元素的长度仍然是等分的,举个例子,如果ViewPort的横轴长度是450,那么当maxCrossAxisExtent的值在区间[450/4,450/3)内的话,子元素最终实际长度都为112.5,而childAspectRatio所指的子元素横轴和主轴的长度比为最终的长度比。其他参数和SliverGridDelegateWithFixedCrossAxisCount相同。

下面我们看一个例子:

GridView(
  padding: EdgeInsets.zero,
  gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
      maxCrossAxisExtent: 120.0,
      childAspectRatio: 2.0 // 宽高比为 2
  ),
  children: <Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast),
  ],
);

运行效果如图所示:

在这里插入图片描述

GridView.count

GridView.count构造函数内部使用了SliverGridDelegateWithFixedCrossAxisCount,我们通过它可以快速的创建横轴固定数量子元素GridView,我们可以通过以下代码实现和上面例子相同的效果等:

GridView.count( 
  crossAxisCount: 3,
  childAspectRatio: 1.0,
  children: <Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast),
  ],
);

GridView.extent

GridView.extent构造函数内部使用了SliverGridDelegateWithMaxCrossAxisExtent,我们通过它可以快速的创建横轴子元素为固定最大长度的的GridView,上面的示例代码等价于:

GridView.extent(
   maxCrossAxisExtent: 120.0,
   childAspectRatio: 2.0,
   children: <Widget>[
     Icon(Icons.ac_unit),
     Icon(Icons.airport_shuttle),
     Icon(Icons.all_inclusive),
     Icon(Icons.beach_access),
     Icon(Icons.cake),
     Icon(Icons.free_breakfast),
   ],
 );

GridView.builder

ListView类似,GridView也有一个builder方法,当子widget比较多时,我们可以通过GridView.builder来动态创建子widgetGridView.builder 必须指定的参数有两个:

GridView.builder(
	 ...
	 required SliverGridDelegate gridDelegate, 
	 required IndexedWidgetBuilder itemBuilder,
)

其中itemBuilder为子widget构建器,gridDelegate就是前面提到的两种Delegate。

示例:假设我们需要从服务器获取分页获取一些商品列表,然后用GridView来展示

实现代码:

import 'package:flutter/material.dart';

class GridViewWidget extends StatelessWidget {
  const GridViewWidget({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("GridView"),
      ),
      body: const Wrap(alignment: WrapAlignment.center, children: <Widget>[
        Padding(
          padding: EdgeInsets.all(10),
          child: Text("商品列表"),
        ),
        Divider(height: 1.0),
        InfiniteGridView(),
      ]),
    );
  }
}

class InfiniteGridView extends StatefulWidget {
  const InfiniteGridView({Key? key}) : super(key: key);

  
  State createState() => _InfiniteGridViewState();
}

class _InfiniteGridViewState extends State<InfiniteGridView> {
  final ScrollController _controller = ScrollController();
  final List<String> _dataList = [];
  bool _reachEnd = false;

  
  void initState() {
    super.initState();
    // 初始化刷新数据
    _onRefresh();
    _controller.addListener(() {
      if (_controller.position.pixels == _controller.position.maxScrollExtent) {
        print("_loadMore");
        _retrieveData();
      }
    });
  }

  
  Widget build(BuildContext context) {
    return SizedBox(
      //获取屏幕宽高度:
      // MediaQuery.of(context).size.width
      // MediaQuery.of(context).size.height
      width: MediaQuery.of(context).size.width, //可以充满屏幕并且不会报infinity size的错误
      height: MediaQuery.of(context).size.height - (_reachEnd ? 120 : 0), //可以充满屏幕并且不会报infinity size的错误
      child: Flex(
        direction: Axis.vertical,
        children: <Widget>[
          Expanded(
            flex: 10,
            child: RefreshIndicator(
              onRefresh: _onRefresh,
              displacement: 20, //指示器距离屏幕顶部的距离
              color: Colors.orange,
              backgroundColor: Colors.white,
              child: GridView.builder(
                  controller: _controller,
                  // 不加这行在条目比较小的时候不能下拉,原因是RefreshIndicator子元素必须是可滚动的组件,
                  // 这里设置为永远可以滚动
                  physics: const AlwaysScrollableScrollPhysics(),
                  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                      crossAxisCount: 3, //每行三列
                      childAspectRatio: 1.0 //显示区域宽高相等
                      ),
                  itemCount: _dataList.length,
                  itemBuilder: (context, index) {
                    return DecoratedBox(
                        decoration: const BoxDecoration(color: Colors.blue),
                        child: SizedBox(
                          width: 50,
                          height: 50,
                          child: Center(
                            child: Text(
                              "数据${_dataList[index]}",
                              style: const TextStyle(color: Colors.white),
                            ),
                          ),
                        ) //Icon(_icons[index], color: Colors.white,),
                        );
                  }),
            ),
          ),
          Expanded(child: loadMoreWidget()),
        ],
      ),
    );
  }

  //模拟延时2s请求
  Future<void> _onRefresh() async {
    await Future.delayed(const Duration(seconds: 2));
    setState(() {
      _dataList.clear();
      for (int i = 0; i < 21; i++) {
        _dataList.add(i.toString());
      }
    });
  }

  //模拟异步获取数据
  void _retrieveData() {
    setState(() {
      _reachEnd = true;
    });
    Future.delayed(const Duration(milliseconds: 2000)).then((e) {
      setState(() {
        int start = _dataList.length;
        for (int i = start; i < start + 21; i++) {
          _dataList.add(i.toString());
        }
        _reachEnd = false;
      });
    });
  }

  //底部加载更多组件
  Widget loadMoreWidget() {
    return Offstage(
      offstage: !_reachEnd,
      child: const Center(
        child: SizedBox(
            width: 30.0,
            height: 30.0,
            child: CircularProgressIndicator(strokeWidth: 2.0)),
      ),
    );
  }
}

另外,我们并没有发现GridViewListView那样单独提供一个设置分割线的方法,其实可以通过GridView的两种gridDelegatemainAxisSpacingcrossAxisSpacing分别设置主轴和交叉轴的子元素间距来实现:

在这里插入图片描述
修改GridView的父容器的背景就可以修改分割线的颜色。

StaggeredGridView

在这里插入图片描述

如果要实现类似上面这样的瀑布流效果,使用GridView无法实现,Flutter SDK 中也没有提供自带的类似组件,我们可以使用 pub.dev上著名的package库:flutter_staggered_grid_view 来实现,具体使用方法请参考其官方文档。

PageView

如果要实现页面切换和 Tab 布局,我们可以使用 PageView 组件。需要注意,PageView 是一个非常重要的组件,因为在移动端开发中很常用,比如大多数 App 都包含 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能等等,这些都可以通过 PageView 轻松实现。

PageView({
  Key? key,
  this.scrollDirection = Axis.horizontal, // 滑动方向
  this.reverse = false,
  PageController? controller,
  this.physics,
  List<Widget> children = const <Widget>[],
  this.onPageChanged,
  
  // 每次滑动是否强制切换整个页面,如果为false,则会根据实际的滑动距离显示页面
  this.pageSnapping = true,
  // 主要是配合辅助功能用的,后面解释
  this.allowImplicitScrolling = false,
  // 后面解释
  this.padEnds = true,
})

我们看一个 Tab 切换的实例,为了突出重点,我们让每个 Tab 页都只显示一个数字。

// Tab 页面 
class Page extends StatefulWidget {
  const Page({
    Key? key,
    required this.text
  }) : super(key: key);

  final String text;

  
  _PageState createState() => _PageState();
}

class _PageState extends State<Page> {
  
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
}

然后我们创建一个 PageView


Widget build(BuildContext context) {
  var children = <Widget>[];
  // 生成 6 个 Tab 页
  for (int i = 0; i < 6; ++i) {
    children.add( Page( text: '$i'));
  }

  return PageView(
    // scrollDirection: Axis.vertical, // 滑动方向为垂直方向
    children: children,
  );
}

效果:

在这里插入图片描述
如果将 PageView 的滑动方向指定为垂直方向(上面代码中注释部分),则会变为上下滑动切换页面。

PageView.builder

ListView一样,PageView也有一个 PageView.builder 方法,使用方式都是类似的:

  
  Widget build(BuildContext context) {
    var pageView = PageView.builder(
      // 子项构造器
      itemBuilder: (context, index) => FadeInImage.assetNetwork( 
          placeholder: "images/timg4.jpg",
          image: images[index],
          fit: BoxFit.fitWidth
      ),
      itemCount: images.length, // 数量
      onPageChanged: (index) {  // 监听事件 
        print('onPageChanged: index=====$index');
      },
    ); 
    return Scaffold(body: pageView);
  }

PageView 的页面缓存

我们在运行上面示例时会发现:每当页面切换时都会触发新 Page 页的 build,比如我们从第一页滑到第二页,然后再滑回第一页时,控制台打印如下:

flutter: build 0
flutter: build 1
flutter: build 0

可见 PageView 默认并没有缓存功能,一旦页面滑出屏幕它就会被销毁,这和我们前面讲过的 ListView/GridView 不一样,在创建 ListView/GridView 时我们可以手动指定 ViewPort 之外多大范围内的组件需要预渲染和缓存(通过 cacheExtent 指定),只有当组件滑出屏幕后又滑出预渲染区域,组件才会被销毁,但是不幸的是 PageView 并没有 cacheExtent 参数!但是在真实的业务场景中,对页面进行缓存是很常见的一个需求,比如一个新闻 App,下面有很多频道页,如果不支持页面缓存,则一旦滑到新的频道旧的频道页就会销毁,滑回去时又得重新请求数据和构建页面,这谁扛得住!

按道理 cacheExtentViewport 的一个配置属性,且 PageView 也是要构建 Viewport 的,那么为什么就不能透传一下这个参数呢?我们看一下 PageView 创建 Viewport 的源码:

child: Scrollable(
  ...
  viewportBuilder: (BuildContext context, ViewportOffset position) {
    return Viewport(
      // TODO(dnfield): we should provide a way to set cacheExtent
      // independent of implicit scrolling:
      // https://github.com/flutter/flutter/issues/45632
      cacheExtent: widget.allowImplicitScrolling ? 1.0 : 0.0,
      cacheExtentStyle: CacheExtentStyle.viewport,
      ...
    );
  },
)

我们发现,虽然 PageView 没有透传 cacheExtent,但是却在allowImplicitScrollingtrue 时设置了预渲染区域,注意,此时的缓存类型为 CacheExtentStyle.viewport,则 cacheExtent 表示缓存的长度是几个 Viewport 的宽度,cacheExtent1.0,则代表前后各缓存一个页面宽度,即前后各一页。

既然如此,那我们将 PageViewallowImplicitScrolling 置为 true 则不就可以缓存前后两页了?我们修改代码,然后运行示例,发现在第一页时,控制台打印信息如下:

flutter: build 0
flutter: build 1 // 预渲染第二页

滑到第二页时:

flutter: build 0
flutter: build 1
flutter: build 2 // 预渲染第三页

当再滑回第一页时,控制台信息不变,这也就意味着第一页缓存成功,它没有被重新构建。但是如果我们从第二页滑到第三页,然后再滑回第一页时,控制台又会输出 ”build 0“,这也符合预期,因为我们之前分析的就是设置 allowImplicitScrolling 置为 true 时就只会缓存前后各一页,所以滑到第三页时,第一页就会销毁。其实这个效果就和Android的原生控件ViewPager类似了。

OK,能缓存前后各一页也貌似比不能缓存好一点,但还是不能彻底解决不了我们的问题。为什么明明就是顺手的事, flutter 就不让开发者指定缓存策略呢?然后我们翻译一下源码中的注释:

Todo:我们应该提供一种独立于隐式滚动(implicit scrolling)的设置 cacheExtent 的机制。

放开 cacheExtent 透传是很简单的事情,为什么还要以后再做?是有什么难题么?要理解这个我们就需要看看 allowImplicitScrolling 到底是什么了,根据文档以及注释中 issue 的链接,发现PageView 中设置 cacheExtent 会和 iOS 中辅助功能有冲突,所以暂时还没有什么好的办法。看到这可能国内的很多开发者要说我们的 App 不用考虑辅助功能,既然如此,那问题很好解决,将 PageView 的源码拷贝一份,然后透传 cacheExtent 即可。

拷源码的方式虽然很简单,但毕竟不是正统做法,那有没有更通用的方法吗?有!实际上,可滚动组件提供了一种通用的缓存子项的解决方案,在下面介绍。

可滚动组件子项的缓存

使用 AutomaticKeepAlive 开启可滚动组件的子项缓存

ListView 有一个addAutomaticKeepAlives 属性,如果addAutomaticKeepAlivestrue,则 ListView 会为每一个列表项添加一个 AutomaticKeepAlive 父组件。虽然 PageView 的默认构造函数和 PageView.builder 构造函数中没有该参数,但它们最终都会生成一个 SliverChildDelegate 来负责列表项的按需加载,而在 SliverChildDelegate 中每当列表项构建完成后,SliverChildDelegate 都会为其添加一个 AutomaticKeepAlive 父组件。下面我们就先介绍一下 AutomaticKeepAlive 组件。

AutomaticKeepAlive 的组件的主要作用是将列表项的根 RenderObjectkeepAlive 按需自动标记truefalse。为了方便叙述,我们可以认为根 RenderObject 对应的组件就是列表项的根 Widget,代表整个列表项组件,同时我们将列表组件的 Viewport区域 + cacheExtent(预渲染区域)称为加载区域

  1. keepAlive 标记为 false 时,如果列表项滑出加载区域时,列表组件将会被销毁
  2. keepAlive 标记为 true 时,当列表项滑出加载区域后,Viewport 会将列表组件缓存起来;当列表项进入加载区域时,Viewport 从先从缓存中查找是否已经缓存,如果有则直接复用,如果没有则重新创建列表项。

那么 AutomaticKeepAlive 什么时候会将列表项的 keepAlive 标记为 truefalse 呢?

  • 答案是开发者说了算!Flutter 中实现了一套类似 C/S 的机制,AutomaticKeepAlive 就类似一个 Server,它的子组件可以是 Client,这样子组件想改变是否需要缓存的状态时就向 AutomaticKeepAlive 发一个通知消息(KeepAliveNotification),AutomaticKeepAlive 收到消息后会去更改 keepAlive 的状态,如果有必要同时做一些资源清理的工作(比如 keepAlivetrue 变为 false 时,要释放缓存)。

我们基于前面的 PageView 示例,实现页面缓存,根据上面的描述实现思路就很简单了:让Page 页变成一个 AutomaticKeepAliveClient 即可。为了便于开发者实现,Flutter 提供了一个 AutomaticKeepAliveClientMixin ,我们只需要让 PageState 混入这个 mixin,且同时添加一些必要操作即可:

class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {

  
  Widget build(BuildContext context) {
    super.build(context); // 必须调用
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }

  
  bool get wantKeepAlive => true; // 是否需要缓存
}

代码很简单,我们只需要提供一个 wantKeepAlive,它会表示 AutomaticKeepAlive 是否需要缓存当前列表项;另外我们必须在 build 方法中调用一下 super.build(context),该方法实现在 AutomaticKeepAliveClientMixin 中,功能就是根据当前 wantKeepAlive 的值给 AutomaticKeepAlive 发送消息,AutomaticKeepAlive 收到消息后就会开始工作,如图所示:

在这里插入图片描述
现在我们重新运行一下示例,发现每个 Page 页只会 build 一次,缓存成功了。

需要注意,如果我们采用 PageView.custom 构建页面时没有给列表项包装 AutomaticKeepAlive 父组件,则上述方案不能正常工作,因为此时 Client 发出消息后,找不到 Server,404 了,😀。

自定义 KeepAliveWrapper 缓存

虽然AutomaticKeepAliveClientMixin 可以快速的实现页面缓存功能,但是通过混入的方式实现不是很优雅, 前面例子中必须具有侵入性的修改Page代码,不是很灵活,比如一个Page组件需要同时在列表中和列表外使用,为了在列表中缓存它,则我们必须实现两份。所以我们有必要对AutomaticKeepAliveClientMixin 混入进行封装。

下面是一个简单封装:

import 'package:flutter/widgets.dart';

/// 包括可滚动组件的子组件后,该子组件将会被缓存,意味着即使滑出屏幕也不会被销毁。
/// KeepAliveWrapper can keep the item(s) of scrollview alive, **Not dispose**.
class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper({
    Key? key,
    required this.child,
    this.keepAlive = true,
  }) : super(key: key);
  final bool keepAlive;
  final Widget child;

  
  State<KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}

class _KeepAliveWrapperState extends State<KeepAliveWrapper> with AutomaticKeepAliveClientMixin {
  
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child;
  }

  
  void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
    if (oldWidget.keepAlive != widget.keepAlive) {
      // keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中
      updateKeepAlive();
    }
    super.didUpdateWidget(oldWidget);
  }

  
  bool get wantKeepAlive => widget.keepAlive;

  
  void dispose() {
    // print("KeepAliveWrapper dispose");
    super.dispose();
  }
}

这样如果哪个列表项需要缓存,只需要使用 KeepAliveWrapper 包裹一下它即可。例如:


Widget build(BuildContext context) {
  var children = <Widget>[];
  for (int i = 0; i < 6; ++i) {
    // 只需要用 KeepAliveWrapper 包装一下即可
    children.add(KeepAliveWrapper(child:Page( text: '$i'));
  }
  return PageView(children: children);
}

下面我们再在 ListView 中测一下:

class KeepAliveTest extends StatelessWidget {
  const KeepAliveTest({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ListView.builder(itemBuilder: (_, index) {
      return KeepAliveWrapper(
        // 为 true 后会缓存所有的列表项,列表项将不会销毁。
        // 为 false 时,列表项滑出预加载区域后将会别销毁。
        // 使用时一定要注意是否必要,因为对所有列表项都缓存的会导致更多的内存消耗
        keepAlive: true,
        child: ListItem(index: index),
      );
    });
  }
}

class ListItem extends StatefulWidget {
  const ListItem({Key? key, required this.index}) : super(key: key);
  final int index;

  
  _ListItemState createState() => _ListItemState();
}

class _ListItemState extends State<ListItem> {
  
  Widget build(BuildContext context) {
    return ListTile(title: Text('${widget.index}'));
  }

  
  void dispose() {
    print('dispose ${widget.index}');
    super.dispose();
  }
}

因为每一个列表项都被缓存了,所以运行后滑动列表预期日志面板不会有任何日志。如果我们将 keepAlive 设为 false,则当列表项滑出预渲染区域后则会销毁,日志面板将有输出。

CustomScrollView

前面介绍的 ListView、GridView、PageView 都是一个完整的可滚动组件,所谓完整是指它们都包括ScrollableViewportSliver。假如我们想要在一个页面中,同时包含多个可滚动组件,且使它们的滑动效果能统一起来,比如:我们想将已有的两个沿垂直方向滚动的 ListView 成一个 ListView ,这样在第一个 ListView 滑动到底部时能自动接上第二个 ListView,如果尝试写一个 demo:

Widget buildTwoListView() {
    var listView = ListView.builder(
      itemCount: 20,
      itemBuilder: (_, index) => ListTile(title: Text('$index')),
    );
    return Column(
      children: [
        Expanded(child: listView),
        Divider(color: Colors.grey),
        Expanded(child: listView),
      ],
    );
  }
}

效果:

在这里插入图片描述

页面中有两个 ListView,各占可视区域一半高度,虽然能够显式出来,但每一个 ListView 只会响应自己可视区域中滑动,实现不了我们想要的效果。之所以会这样的原因是两个 ListView 都有自己独立的 ScrollableViewportSliver,既然如此,我们自己创建一个共用的 ScrollableViewport 对象,然后再将两个 ListView 对应的 Sliver 添加到这个共用的 Viewport 对象中就可以实现我们想要的效果了。

如果这个工作让开发者自己来做无疑是比较麻烦的,因此 Flutter 提供了一个 CustomScrollView 组件来帮助我们创建一个公共的 ScrollableViewport ,然后它的 slivers 参数接受一个 Sliver 数组,这样我们就可以使用 CustomScrollView 方面的实现我们期望的功能了:

Widget buildTwoSliverList() {
  // SliverFixedExtentList 是一个 Sliver,它可以生成高度相同的列表项。
  // 再次提醒,如果列表项高度相同,我们应该优先使用 SliverFixedExtentList 
  // 和 SliverPrototypeExtentList,如果不同,使用 SliverList.
  var listView = SliverFixedExtentList(
    itemExtent: 56, //列表项高度固定
    delegate: SliverChildBuilderDelegate(
      (_, index) => ListTile(title: Text('$index')),
      childCount: 10,
    ),
  );
  // 使用
  return CustomScrollView(
    slivers: [
      listView,
      listView,
    ],
  );
}

效果:

在这里插入图片描述

可以看到我们期望的效果实现了。

综上,CustomScrollView 的主要功能是提供一个公共的的 ScrollableViewport,来组合多个 SliverCustomScrollView 的结构如图:

在这里插入图片描述

Flutter 中常用的 Sliver

之前介绍过的可滚动组件都有对应的 Sliver

Sliver名称功能对应的可滚动组件
SliverList列表ListView
SliverFixedExtentList高度固定的列表ListView,指定itemExtent
SliverPrototypeExtentList根据原型生成高度固定的列表ListView,指定prototypeItem
SliverAnimatedList添加/删除列表项可以执行动画AnimatedList
SliverGrid网格GridView
SliverFillViewport包含多个子组件,每个都可以填满屏幕PageView

除了和列表对应的 Sliver 之外还有一些用于对 Sliver 进行布局、装饰的组件,它们的子组件必须是 Sliver,我们列举几个常用的:

Sliver名称对应 RenderBox
SliverPaddingPadding
SliverVisibility、SliverOpacityVisibility、Opacity
SliverFadeTransitionFadeTransition
SliverLayoutBuilderLayoutBuilder

还有一些其他常用的 Sliver

Sliver名称说明
SliverAppBar对应 AppBar,主要是为了在 CustomScrollView 中使用。
SliverToBoxAdapter一个适配器,可以将 RenderBox 适配为 Sliver
SliverPersistentHeader滑动到顶部时可以固定住

Sliver系列 Widget 比较多,只需记住它的特点,需要时再去查看文档即可。上面之所以说“大多数”Sliver都和可滚动组件对应,是由于还有一些如SliverPaddingSliverAppBar 等是和可滚动组件无关的,它们主要是为了结合CustomScrollView一起使用,这是因为 CustomScrollView的子组件必须都是Sliver

示例:

import 'package:flutter/material.dart';

class CustomScrollViewTestRoute extends StatelessWidget {
  const CustomScrollViewTestRoute({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    //本路由没有使用Scaffold,为了让子级Widget(如Text)使用
    //Material Design 默认的样式风格,我们使用Material作为本路由的根。
    return Material(
      child: CustomScrollView(
        slivers: <Widget>[
          //AppBar,包含一个导航栏
          SliverAppBar(
            pinned: true,
            expandedHeight: 250.0,
            flexibleSpace: FlexibleSpaceBar(
              title: const Text('Demo'),
              background: Image.asset("images/timg4.jpg", fit: BoxFit.cover,),
            ),
          ),

          SliverPadding(
            padding: const EdgeInsets.all(8.0),
            sliver: SliverGrid( //Grid
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2, //Grid按两列显示
                mainAxisSpacing: 10.0,
                crossAxisSpacing: 10.0,
                childAspectRatio: 4.0,
              ),
              delegate: SliverChildBuilderDelegate((BuildContext context, int index) {
                  //创建子widget
                  return Container(
                    alignment: Alignment.center,
                    color: Colors.cyan[100 * (index % 9)],
                    child: Text('grid item $index'),
                  );
                },
                childCount: 20,
              ),
            ),
          ),
          //List
          SliverFixedExtentList(
            itemExtent: 50.0,//子元素高度50
            delegate: SliverChildBuilderDelegate((BuildContext context, int index) {
                  //创建列表项
                  return Container(
                    alignment: Alignment.center,
                    color: Colors.lightBlue[100 * (index % 9)],
                    child: Text('list item $index'),
                  );
                },
                childCount: 50, //50个列表项
            ),
          ),
        ],
      ),
    );
  }
}

代码分为三部分:

  • 头部SliverAppBarSliverAppBar对应AppBar,两者不同之处在于SliverAppBar可以集成到CustomScrollViewSliverAppBar可以结合FlexibleSpaceBar实现Material Design中头部伸缩的模型,具体效果可以运行该示例查看。
  • 中间的SliverGrid:它用SliverPadding包裹以给SliverGrid添加补白。SliverGrid是一个两列,宽高比为4的网格,它有20个子组件。
  • 底部SliverFixedExtentList:它是一个所有子元素高度都为50像素的列表。

运行效果:

在这里插入图片描述

SliverToBoxAdapter

在实际布局中,我们通常需要往 CustomScrollView 中添加一些自定义的组件,而这些组件并非都有 Sliver 版本,为此 Flutter 提供了一个 SliverToBoxAdapter 组件,它是一个适配器可以将 RenderBox 适配为 Sliver

比如我们想在列表顶部添加一个可以横向滑动的 PageView,可以使用 SliverToBoxAdapter 来配置:

class CustomScrollViewSliverToBoxAdapter extends StatelessWidget {
  const CustomScrollViewSliverToBoxAdapter({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        SliverToBoxAdapter(
          child: SizedBox(
            height: 300,
            child: PageView(
              children: const [Text("1"), Text("2")],
            ),
          ),
        ),
        buildSliverFixedList(),
      ],
    );
  }

  Widget buildSliverFixedList() {
    return SliverFixedExtentList(
      itemExtent: 50.0,
      delegate: SliverChildBuilderDelegate(
        (BuildContext context, int index) {
          //创建列表项
          return Container(
            alignment: Alignment.center,
            color: Colors.lightBlue[100 * (index % 9)],
            child: Text('list item $index'),
          );
        },
        childCount: 20,
      ),
    );
  }
}

注意,上面的代码是可以正常运行的,但是如果将 PageView 换成一个滑动方向和 CustomScrollView 一致的 ListView 则不会正常工作!

  • 原因是:CustomScrollView 组合 Sliver 的原理是为所有子 Sliver 提供一个共享的 Scrollable,然后统一处理指定滑动方向的滑动事件,如果 Sliver 中引入了其他的 Scrollable,则滑动事件便会冲突。

上例中 PageView 之所以能正常工作,是因为 PageViewScrollable 只处理水平方向的滑动,而 CustomScrollView 是处理垂直方向的,两者并未冲突,所以不会有问题,但是换一个也是垂直方向的 ListView 时则不能正常工作,最终的效果是,在ListView内滑动时只会对ListView 起作用,原因是滑动事件被 ListViewScrollable 优先消费,CustomScrollViewScrollable 便接收不到滑动事件了。

Flutter 中手势的冲突时,默认的策略是子元素生效。

所以我们可以得出一个结论:如果 CustomScrollView 有孩子也是一个完整的可滚动组件且它们的滑动方向一致,则 CustomScrollView 不能正常工作。要解决这个问题,可以使用 NestedScrollView,后面介绍。

SliverPersistentHeader

SliverPersistentHeader 的功能是当滑动到 CustomScrollView 的顶部时,可以将组件固定在顶部。

需要注意, Flutter 中设计 SliverPersistentHeader 组件的初衷是为了实现 SliverAppBar,所以它的一些属性和回调在SliverAppBar 中才会用到。因此,如果我们要直接使用 SliverPersistentHeader,看到它的一些配置和参数会感到疑惑,使用起来会感觉有心智成本,为此,下面我们重点关注哪些是需要的,哪些是可以忽略的。

我们先看看 SliverPersistentHeader 的定义:

const SliverPersistentHeader({
  Key? key, 
  required SliverPersistentHeaderDelegate delegate,  
  this.pinned = false, 
  this.floating = false,  
})
  • pinned:header 滑动到可视区域顶部时是否固定在顶部
  • floatingpinnedfalse 时 ,则 header 可以滑出可视区域(CustomScrollViewViewport)(不会固定到顶部),当用户再次向下滑动时,此时不管 header 已经被滑出了多远,它都会立即出现在可视区域顶部并固定住,直到继续下滑到 header 在列表中原来的位置时,header 才会重新回到原来的位置(不再固定在顶部)。 具体效果,看后面示例。
  • delegate:构造 header 组件的委托,类型为 SliverPersistentHeaderDelegate,它是一个抽象类,需要我们自己实现,定义如下:
abstract class SliverPersistentHeaderDelegate {

  // header 最大高度;pined为 true 时,当 header 刚刚固定到顶部时高度为最大高度。
  double get maxExtent;
  
  // header 的最小高度;pined为true时,当header固定到顶部,用户继续往上滑动时,header
  // 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtent
  double get minExtent;

  // 构建 header。
  // shrinkOffset取值范围[0,maxExtent],当header刚刚到达顶部时,shrinkOffset 值为0,
  // 如果用户继续向上滑动列表,shrinkOffset的值会随着用户滑动的偏移减小,直到减到0时。
  //
  // overlapsContent:一般不建议使用,在使用时一定要小心,后面会解释。
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent);
  
  // header 是否需要重新构建;通常当父级的 StatefulWidget 更新状态时会触发。
  // 一般来说只有当 Delegate 的配置发生变化时,应该返回false,比如新旧的 minExtent、maxExtent
  // 等其他配置不同时需要返回 true,其余情况返回 false 即可。
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);

  // 下面这几个属性是SliverPersistentHeader在SliverAppBar中时实现floating、snap 
  // 效果时会用到,平时开发过程很少使用到,读者可以先不用理会。
  TickerProvider? get vsync => null;
  FloatingHeaderSnapConfiguration? get snapConfiguration => null;
  OverScrollHeaderStretchConfiguration? get stretchConfiguration => null;
  PersistentHeaderShowOnScreenConfiguration? get showOnScreenConfiguration => null;
}

可以看到,我们最需要关注的就是maxExtentminExtentpinedtrue 时,当 header 刚刚固定到顶部,此时会对它应用 maxExtent (最大高度);当用户继续往上滑动时,header 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtent。如果我们想让 header 高度固定,则将 maxExtentminExtent 指定为同样的值即可。

为了构建 header 我们必须要定义一个类,让它继承自 SliverPersistentHeaderDelegate,这无疑会增加使用成本!为此,我们封装一个通用的委托构造器 SliverHeaderDelegate,通过它可以快速构建 SliverPersistentHeaderDelegate,实现如下:

typedef SliverHeaderBuilder = Widget Function(
    BuildContext context, double shrinkOffset, bool overlapsContent);

class SliverHeaderDelegate extends SliverPersistentHeaderDelegate {
  // child 为 header
  SliverHeaderDelegate({
    required this.maxHeight,
    this.minHeight = 0,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        assert(minHeight <= maxHeight && minHeight >= 0);

  //最大和最小高度相同
  SliverHeaderDelegate.fixedHeight({
    required double height,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        maxHeight = height,
        minHeight = height;

  //需要自定义builder时使用
  SliverHeaderDelegate.builder({
    required this.maxHeight,
    this.minHeight = 0,
    required this.builder,
  });

  final double maxHeight;
  final double minHeight;
  final SliverHeaderBuilder builder;

  
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    Widget child = builder(context, shrinkOffset, overlapsContent);
    //测试代码:如果在调试模式,且子组件设置了key,则打印日志
    assert(() {
      if (child.key != null) {
        print('${child.key}: shrink: $shrinkOffset,overlaps:$overlapsContent');
      }
      return true;
    }());
    // 让 header 尽可能充满限制的空间;宽度为 Viewport 宽度,
    // 高度随着用户滑动在[minHeight,maxHeight]之间变化。
    return SizedBox.expand(child: child);
  }

  
  double get maxExtent => maxHeight;

  
  double get minExtent => minHeight;

  
  bool shouldRebuild(SliverHeaderDelegate old) {
    return old.maxExtent != maxExtent || old.minExtent != minExtent;
  }
}

下面我们看看如何使用:

class PersistentHeaderRoute extends StatelessWidget {
  const PersistentHeaderRoute({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        buildSliverList(),
        SliverPersistentHeader(
          pinned: true,
          delegate: SliverHeaderDelegate( //有最大和最小高度
            maxHeight: 80,
            minHeight: 50,
            child: buildHeader(1),
          ),
        ),
        buildSliverList(),
        SliverPersistentHeader(
          pinned: true,
          delegate: SliverHeaderDelegate.fixedHeight( //固定高度
            height: 50,
            child: buildHeader(2),
          ),
        ),
        buildSliverList(20),
      ],
    );
  }

  // 构建固定高度的SliverList,count为列表项数量
  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      itemExtent: 50,
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return ListTile(title: Text('$index'));
        },
        childCount: count,
      ),
    );
  }

  // 构建 header
  Widget buildHeader(int i) {
    return Container(
      color: Colors.lightBlue.shade200,
      alignment: Alignment.centerLeft,
      child: Text("PersistentHeader $i"),
    );
  }
}

运行后效果:

在这里插入图片描述

一些注意点

我们说过 SliverPersistentHeaderbuilder 参数 overlapsContent 一般不建议使用,使用时要当心。因为按照 overlapsContent 变量名的字面意思,只要有内容和 Sliver 重叠时就应该为 true,但是如果我们在上面示例的 builder 中打印一下 overlapsContent 的值就会发现第一个 PersistentHeader 1overlapsContent 值一直都是 false,而 PersistentHeader 2 则是正常的,如果我们再添加几个 SliverPersistentHeader ,发现新添加的也都正常。

总结一下:当有多个 SliverPersistentHeader时,需要注意第一个 SliverPersistentHeaderoverlapsContent 值会一直为 false

这可能是一个 bug,也可能就是这么设计的,因为 SliverPersistentHeader 的设计初衷主要是为了实现 SliverAppBar,可能并没有考虑到通用的场景。为此,我们可以定一条约定:如果我们在使用 SliverPersistentHeader 构建子组件时需要依赖 overlapsContent 参数,则必须保证之前至少还有一个 SliverPersistentHeaderSliverAppBarSliverAppBar 的实现中内部包含了SliverPersistentHeader)。

CustomScrollView 总结

  1. CustomScrollView 组合 Sliver 的原理是为所有子 Sliver 提供一个共享的 Scrollable,然后统一处理指定滑动方向的滑动事件。
  2. CustomScrollViewListView、GridView、PageView 一样,都是完整的可滚动组件(同时拥有 Scrollable、Viewport、Sliver)。
  3. CustomScrollView 只能组合 Sliver,如果有孩子也是一个完整的可滚动组件(通过 SliverToBoxAdapter 嵌入)且它们的滑动方向一致时便不能正常工作。

NestedScrollView

前面我们已经知道 CustomScrollView 只能组合 Sliver,如果有孩子也是一个可滚动组件(通过 SliverToBoxAdapter 嵌入)且它们的滑动方向一致时便不能正常工作。为了解决这个问题,Flutter 中提供了一个NestedScrollView 组件,它的功能是组合(协调)两个可滚动组件,下面我们看看它的定义:

const NestedScrollView({
  ... //省略可滚动组件的通用属性
  // header,sliver构造器
  required this.headerSliverBuilder,
  // 可以接受任意的可滚动组件
  required this.body,
  this.floatHeaderSlivers = false,
}) 

我们先看一个简单的示例,需要实现的页面的最终效果如下:

在这里插入图片描述
页面有三部分组成:

  1. 最上面是一个 AppBar,实现导航,要能固定在顶端
  2. AppBar 下面是一个 SliverList,可以有任意多个列表项,为了演示,我们指定5个列表项即可。
  3. 最下面是一个 ListView

预期的效果是 SliverList 和 下面的 ListView 的滑动能够统一(而不是在下面ListView 上滑动时只有ListView响应滑动),整个页面在垂直方向是一个整体。实现代码如下:

Material(
  child: NestedScrollView(
    headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
      // 返回一个 Sliver 数组给外部可滚动组件。
      return <Widget>[
        SliverAppBar(
          title: const Text('嵌套ListView'),
          pinned: true, // 固定在顶部
          forceElevated: innerBoxIsScrolled,
        ),
        buildSliverList(5), // 构建一个 sliverList
      ];
    },
    body: ListView.builder( // 构建一个 ListView
      padding: const EdgeInsets.all(8),
      physics: const ClampingScrollPhysics(), //重要
      itemCount: 30,
      itemBuilder: (BuildContext context, int index) {
        return SizedBox(
          height: 50,
          child: Center(child: Text('Item $index')),
        );
      },
    ),
  ),
);

NestedScrollView 在逻辑上将可滚动组件分为了 headerbody 两部分,header 部分我们可以认为是外部可滚动组件(outer scroll view),可以认为这个可滚动组件就是 CustomScrollView ,它只能接收 Sliver,因此我们通过headerSliverBuilder 来构建一个 Sliver 列表给外部的可滚动组件;而 body 部分可以接收任意的可滚动组件,该可滚动组件称为内部可滚动组件 (inner scroll view)。

Flutter 的源码注释中和文档中会有 outer 和 inner 两个概念,分别指代外部和内部可滚动组件。

NestedScrollView 原理

NestedScrollView 的结构图如图所示:

在这里插入图片描述

有几点解释:

  1. NestedScrollView 整体就是一个 CustomScrollView (实际上是 CustomScrollView 的一个子类)
  2. headerbody 都是 CustomScrollView 的子 Sliver ,注意,虽然 body 是一个 RenderBox,但是它会被包装为 Sliver
  3. CustomScrollView 将其所有子 Sliver 在逻辑上分为 headerbody 两部分:header 是前面部分、body 是后面部分。
  4. body 是一个可滚动组件时, 它和 CustomScrollView 分别有一个 Scrollable ,由于 bodyCustomScrollView 的内部,所以称其为内部可滚动组件,称 CustomScrollView外部可滚动组件;同时 因为 header 部分是 Sliver,所以没有独立的 Scrollable,滑动时是受 CustomScrollViewScrollable 控制,所以为了区分,可以称 header 为外部可滚动组件(Flutter 文档中是这么约定的)。
  5. NestedScrollView 核心功能就是通过一个协调器来协调外部(outer)可滚动组件和内部(inner)可滚动组件的滚动,以使滑动效果连贯统一,协调器的实现原理就是分别给内外可滚动组件分别设置一个 controller,然后通过这两个controller 来协调控制它们的滚动。

综上,在使用 NestedScrollView 有两点需要注意:

  1. 要确认内部的可滚动组件(body)的 physics 是否需要设置为 ClampingScrollPhysics。比如上面的示例运行在 iOS 中时,ListView 如果没有设置为 ClampingScrollPhysics,则用户快速滑动到顶部时,会执行一个弹性效果,此时 ListView 就会与 header 显得割裂(滑动效果不统一),所以需要设置。但是,如果 header 中只有一个 SliverAppBar 则不应该加,因为 SliverAppBar 是固定在顶部的,ListView 滑动到顶部时上面已经没有要继续往下滑动的元素了,所以此时出现弹性效果是符合预期的。

  2. 内部的可滚动组件(body的)不能设置 controllerprimary,这是因为 NestedScrollView 的协调器中已经指定了它的 controller,如果重新设定则协调器将会失效。

SliverAppBar

前面我们已经使用过 SliverAppBar,但是并没有仔细介绍,因为它最常见的使用场景是在作为 NestedScrollViewheader

SliverAppBarAppBarSliver 版,大多数参数都相同,但 SliverAppBar 会有一些特有的功能,下面是 SliverAppBar 特有的一些配置:

const SliverAppBar({
  this.collapsedHeight, // 收缩起来的高度
  this.expandedHeight,// 展开时的高度
  this.pinned = false, // 是否固定
  this.floating = false, //是否漂浮
  this.snap = false, // 当漂浮时,此参数才有效
  bool forceElevated //导航栏下面是否一直显示阴影
  ...
})
  • SliverAppBarNestedScrollView 中随着用户的滑动是可以收缩和展开的,因此我们需要分别指定收缩和展开时的高度。

  • pinnedtrueSliverAppBar 会固定在 NestedScrollView 的顶部,行为 和 SliverPersistentHeaderpinned功能一致。

  • floatingsnapfloatingtrue 时,SliverAppBar 不会固定到顶部,当用户向上滑动到顶部时,SliverAppBar 也会滑出可视窗口。当用户反向滑动时,SliverAppBarsnaptrue 时,此时无论 SliverAppBar 已经滑出屏幕多远,都会立即回到屏幕顶部;但如果 snapfalse,则 SliverAppBar 只有当向下滑到边界时才会重新回到屏幕顶部。这一点和 SliverPersistentHeaderfloating 相似,但不同的是 SliverPersistentHeader 没有 snap 参数,当它的 floatingtrue 时,效果是等同于 SliverAppBarfloatingsnap 同时为 true 时的效果。

我们可以看到 SliverAppBar 的一些参数和 SliverPersistentHeader 很像,这是因为 SliverAppBar 内部就包含了一个 SliverPersistentHeader 组件,用于实现顶部固定和漂浮效果。

下面我们看一个示例:

class SnapAppBar extends StatelessWidget {
  const SnapAppBar({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            // 实现 snap 效果
            SliverAppBar(
              floating: true,
              snap: true,
              expandedHeight: 200,
              forceElevated: innerBoxIsScrolled,
              flexibleSpace: FlexibleSpaceBar(
                background: Image.asset("./imgs/sea.png",fit: BoxFit.cover,),
              ),
            ),
          ];
        },
        body: Builder(builder: (BuildContext context) {
          return CustomScrollView(
            slivers: <Widget>[
              buildSliverList(100)
            ],
          );
        }),
      ),
    );
  }
}

运行后效果:

在这里插入图片描述

当我们滑动到顶部时,然后反向轻微滑动一点点,这时 SliverAppBar 就会整体回到屏幕顶部,但这时有一个问题,注意图中红色圈出来的部分,我们发现 SliverAppBar 返回到屏幕后将 0 - 4 这几个列表项遮住了!而按照正常的交互逻辑,预期是不能遮住的,因为往下滑时,用户就是为了看上面的内容,SliverAppBar 突然整体回到屏幕后正好遮住了上面的内容,这时,用户不得不继续往下再滑动一些距离,这个体验很不好。

为了解决这个问题,能立马想到的思路就是当 SliverAppBar 在回到屏幕的过程中,底下的列表项也同时往下滑相应的偏移就 OK 了。但是我们要动手时发现了问题,因为无论是想监听 header 的滑动信息和控制 body 的滑动都需要用到内外部可滚动组件的 controller ,而 controller 的持有者是 NestedScrollView 的协调器,我们很难获取取,就算能获取(通过context),那也是 NestedScrollView 的内部逻辑,我们不应在在外部去干涉,这样不符合职责分离模式,是有侵入性的 。 Flutter 的开发者也意识到了这点,于是提供了一个标准的解决方案,我们先看看如何解决,再解释,我们修改上面的代码:

class SnapAppBar extends StatelessWidget {
  const SnapAppBar({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            SliverOverlapAbsorber(
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              sliver: SliverAppBar(
                floating: true,
                snap: true,
                expandedHeight: 200,
                flexibleSpace: FlexibleSpaceBar(
                  background: Image.asset("./imgs/sea.png",fit: BoxFit.cover,),
                ),
                forceElevated: innerBoxIsScrolled,
              ),
            ),
          ];
        },
        body: Builder(builder: (BuildContext context) {
          return CustomScrollView(
            slivers: <Widget>[
              SliverOverlapInjector(
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              ),
              buildSliverList(100)
            ],
          );
        }),
      ),
    );
  }
}

需要注意的是和之前的代码相比有两个部分发生了变化:

  1. SliverAppBarSliverOverlapAbsorber 包裹了起来,它的作用就是获取 SliverAppBar 返回时遮住内部可滚动组件的部分的长度,这个长度就是 overlap(重叠) 的长度。

  2. body 中往 CustomScrollViewSliver列表的最前面插入了一个 SliverOverlapInjector,它会将 SliverOverlapAbsorber 中获取的 overlap 长度应用到内部可滚动组件中。这样在 SliverAppBar 返回时内部可滚动组件也会相应的同步滑动相应的距离。

SliverOverlapAbsorberSliverOverlapInjector 都接收有一个 handle,给它传入的是NestedScrollView.sliverOverlapAbsorberHandleFor(context) 。好家伙,名字一个比一个长!但不要被吓到, handle 就是 SliverOverlapAbsorberSliverOverlapInjector 的通信桥梁,即传递 overlap 长度。

以上便是 NestedScrollView 提供的标准解决方案,可能直观上看起来不是很优雅,不过,幸运的是,这是一个标准方案,有需要直接复制代码即可。

实际上,当 snaptrue 时,只需要给 SliverAppBar 包裹一个 SliverOverlapAbsorber即可,而无需再给 CustomScrollView 添加 SliverOverlapInjector,因为这种情况 SliverOverlapAbsorber 会自动吸收 overlap,以调整自身的布局高度为 SliverAppBar 的实际高度,这样的话 header 的高度变化后就会自动将 body 向下撑(headerbody 属于同一个 CustomScrollView),同时,handle 中的 overlap 长度始终 0。而只有当 SliverAppBarSliverOverlapAbsorber 包裹且为固定模式时(pinnedtrue ),CustomScrollView 中添加SliverOverlapInjector 才有意义, handle 中的 overlap 长度不为 0

注意:以上问题解决方式使用最新版本SDK经过模拟器实验仍然存在!由此猜想,同时设置floating: truesnap: true 的效果可能就是设计如此,在下拉时就是想header盖在列表之上,并不是bug。如果想要下拉时,header不挡住列表,最好是直接设置 pinned: true,而不是使用 floatingsnap。例如:

class PinnedAppBar extends StatelessWidget {
  const PinnedAppBar({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              pinned: true,
              expandedHeight: 200,
              flexibleSpace: FlexibleSpaceBar(
                background: Image.asset("./images/timg4.jpg", fit: BoxFit.cover,),
              ),
              forceElevated: innerBoxIsScrolled,
            ),
          ];
        },
        body: Builder(builder: (BuildContext context) {
          return CustomScrollView(
            slivers: <Widget>[buildSliverList(100)],
          );
        }),
      ),
    );
  }

  // 构建固定高度的SliverList,count为列表项属相
  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      itemExtent: 50,
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return ListTile(title: Text('$index'));
        },
        childCount: count,
      ),
    );
  }
}

效果:

在这里插入图片描述

嵌套 TabBarView

我们实现一个商城主页,它有三个Tab,为了获得更大的商品显示空间,我们希望用户向上滑动时 导航栏能够滑出屏幕,当用户向下滑动时,导航栏能迅速回到屏幕,因为向下滑动时可能是用户想看之前的商品,也可能是用户向找到导航栏返回。

代码如下:

class NestedTabBarView extends StatelessWidget {
  const NestedTabBarView({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    final _tabs = <String>['猜你喜欢', '今日特价', '发现更多'];
    // 构建 tabBar
    return DefaultTabController(
      length: _tabs.length, // This is the number of tabs.
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return <Widget>[
              SliverOverlapAbsorber(
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                sliver: SliverAppBar(
                  title: const Text('商城'),
                  floating: true,
                  snap: true,
                  forceElevated: innerBoxIsScrolled,
                  bottom: TabBar(
                    tabs: _tabs.map((String name) => Tab(text: name)).toList(),
                  ),
                ),
              ),
            ];
          },
          body: TabBarView(
            children: _tabs.map((String name) {
              return Builder(
                builder: (BuildContext context) {
                  return CustomScrollView(
                    key: PageStorageKey<String>(name),
                    slivers: <Widget>[
                      SliverOverlapInjector(
                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                      ),
                      SliverPadding(
                        padding: const EdgeInsets.all(8.0),
                        sliver: buildSliverList(50),
                      ),
                    ],
                  );
                },
              );
            }).toList(),
          ),
        ),
      ),
    );
  }

  // 构建固定高度的SliverList,count为列表项属相
  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      itemExtent: 50,
      delegate: SliverChildBuilderDelegate((context, index) {
        return ListTile(title: Text('$index'));
      },
        childCount: count,
      ),
    );
  }
}

效果:

在这里插入图片描述

ScrollPhysics

ScrollPhysics 并不是一个组件,它定义了可滚动组件的物理滚动特性。例如,当用户达到最大滚动范围时,是停止滚动,还是继续滚动。

滚动组件(CustomScrollView、ScrollView、GridView、ListView 等)的 physics 参数均表示此属性。

用法:

CustomScrollView(
	physics: AlwaysScrollableScrollPhysics()
	... 
)

系统提供的 ScrollPhysics 有:

  • AlwaysScrollableScrollPhysics :总是可以滑动
  • NeverScrollableScrollPhysics :禁止滚动
  • BouncingScrollPhysics :内容超过一屏 上拉有回弹效果,iOS 系统中的效果
  • ClampingScrollPhysics :包裹内容,列表滑动到边界时将不能继续滑动,通常在Android 中 配合 GlowingOverscrollIndicator(实现微光效果的组件) 使用。
  • FixedExtentScrollPhysics:滚动条直接落在某一项上,而不是任何位置,类似于老虎机,只能在确定的内容上停止,而不能停在 2 个内容的中间,用于可滚动组件的FixedExtentScrollController
  • PageScrollPhysics:用于 PageView 的滚动特性,停留在页面的边界

自定义 Sliver

Sliver 布局协议

Sliver 的布局协议如下:

  1. Viewport 将当前布局和配置信息通过 SliverConstraints 传递给 Sliver
  2. Sliver 确定自身的位置、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。
  3. Viewport 读取 geometry 中的信息来对 Sliver 进行布局和绘制。

可以看到,这个过程有两个重要的对象 SliverConstraintsSliverGeometry ,我们先看看 SliverConstraints 的定义:

class SliverConstraints extends Constraints {
    //主轴方向
    AxisDirection? axisDirection;
    //Sliver 沿着主轴从列表的哪个方向插入?枚举类型,正向或反向
    GrowthDirection? growthDirection;
    //用户滑动方向
    ScrollDirection? userScrollDirection;
    //当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移
    double? scrollOffset;
    //当前Sliver之前的Sliver占据的总高度,因为列表是懒加载,如果不能预估时,该值为double.infinity
    double? precedingScrollExtent;
    //上一个 sliver 覆盖当前 sliver 的长度(重叠部分的长度),通常在 sliver 是 pinned/floating
    //或者处于列表头尾时有效,我们在后面的小节中会有相关的例子。
    double? overlap;
    //当前Sliver在Viewport中的最大可以绘制的区域。
    //绘制如果超过该区域会比较低效(因为不会显示)
    double? remainingPaintExtent;
    //纵轴的长度;如果列表滚动方向是垂直方向,则表示列表宽度。
    double? crossAxisExtent;
    //纵轴方向
    AxisDirection? crossAxisDirection;
    //Viewport在主轴方向的长度;如果列表滚动方向是垂直方向,则表示列表高度。
    double? viewportMainAxisExtent;
    //Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]
    double? cacheOrigin;
    //Viewport加载区域的长度,范围:
    //[viewportMainAxisExtent,viewportMainAxisExtent + Viewport.cacheExtent*2]
    double? remainingCacheExtent;
}

可以看见 SliverConstraints 中包含的信息非常多。当列表滑动时,如果某个 Sliver 已经进入了需要构建的区域,则列表会将 SliverConstraints 信息传递给该 SliverSliver 就可以根据这些信息来确定自身的布局和绘制信息了。

Sliver 需要确定的是 SliverGeometry

const SliverGeometry({
  //Sliver在主轴方向预估长度,大多数情况是固定值,用于计算sliverConstraints.scrollOffset
  this.scrollExtent = 0.0, 
  this.paintExtent = 0.0, // 可视区域中的绘制长度
  this.paintOrigin = 0.0, // 绘制的坐标原点,相对于自身布局位置
  //在 Viewport中占用的长度;如果列表滚动方向是垂直方向,则表示列表高度。
  //范围[0,paintExtent]
  double? layoutExtent, 
  this.maxPaintExtent = 0.0,//最大绘制长度
  this.maxScrollObstructionExtent = 0.0,
  double? hitTestExtent, // 点击测试的范围
  bool? visible,// 是否显示
  //是否会溢出Viewport,如果为true,Viewport便会裁剪
  this.hasVisualOverflow = false,
  //scrollExtent的修正值:layoutExtent变化后,为了防止sliver突然跳动(应用新的layoutExtent)
  //可以先进行修正,具体的作用在后面 SliverFlexibleHeader 示例中会介绍。
  this.scrollOffsetCorrection,
  double? cacheExtent, // 在预渲染区域中占据的长度
}) 

Sliver布局模型 VS 盒布局模型

两者布局流程基本相同:父组件告诉子组件约束信息 > 子组件根据父组件的约束确定自生大小 > 父组件获得子组件大小调整其位置。不同是:

  1. 父组件传递给子组件的约束信息不同。盒模型传递的是 BoxConstraints,而 Sliver 传递的是 SliverConstraints
  2. 描述子组件布局信息的对象不同。盒模型的布局信息通过 Sizeoffset描述 ,而 Sliver的是通过 SliverGeometry 描述。
  3. 布局的起点不同。Sliver布局的起点一般是Viewport ,而盒模型布局的起点可以是任意的组件。

SliverConstraintsSliverGeometry 属性比较多,只看它们的含义的话并不好理解,下面我们将通过两个例子,通过实践来理解。

自定义 SliverFlexibleHeader

1. SliverFlexibleHeader

我们实现一个类似旧版本微信朋友圈顶部头图的功能:即默认情况下顶部图片只显示一部分,当用户向下拽时图片的剩余部分会逐渐显示,如图所示。

在这里插入图片描述

我们的思路是实现一个 Sliver,将它作为 CustomScrollView 的第一孩子,然后根据用户的滑动来动态调整 Sliver 的布局和显示。下面我们来实现一个 SliverFlexibleHeader,它会结合 CustomScrollView 实现上述效果。我们先看一下页面的整体实现代码:


Widget build(BuildContext context) {
  return CustomScrollView(
    //为了能使CustomScrollView拉到顶部时还能继续往下拉,必须让 physics 支持弹性效果
    physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
    slivers: [
      //我们需要实现的 SliverFlexibleHeader 组件
      SliverFlexibleHeader(
        visibleExtent: 200,, // 初始状态在列表中占用的布局高度
        // 为了能根据下拉状态变化来定制显示的布局,我们通过一个 builder 来动态构建布局。
        builder: (context, availableHeight, direction) {
          return GestureDetector(
            onTap: () => print('tap'), //测试是否可以响应事件
            child: Image(
              image: AssetImage("imgs/avatar.png"),
              width: 50.0,
              height: availableHeight,
              alignment: Alignment.bottomCenter,
              fit: BoxFit.cover,
            ),
          );
        },
      ),
      // 构建一个list
      buildSliverList(30),
    ],
  );
}

接下来,我们的重点是实现 SliverFlexibleHeader,由于涉及到 Sliver 布局,通过现有组件很难组合实现我们想要的功能,所以我们通过定制 RenderObject 的方式来实现它。为了能根据下拉位置的变化来动态调整,SliverFlexibleHeader 中我们通过一个 builder 来动态构建布局,当下拉位置发生变化时,builder 就会被回调。

为了清晰起见,我们先实现一个接收固定 widget_SliverFlexibleHeader 组件,组件定义代码如下:

class _SliverFlexibleHeader extends SingleChildRenderObjectWidget {
  const _SliverFlexibleHeader({
    Key? key,
    required Widget child,
    this.visibleExtent = 0,
  }) : super(key: key, child: child);
  final double visibleExtent;

  
  RenderObject createRenderObject(BuildContext context) {
   	return _FlexibleHeaderRenderSliver(visibleExtent);
  }

  
  void updateRenderObject(BuildContext context, _FlexibleHeaderRenderSliver renderObject) {
    renderObject..visibleExtent = visibleExtent;
  }
}

这里我们继承的既不是 StatelessWidget,也不是 StatefulWidget,这是因为这两个组件主要的作用是组合 Widget,而我们要自定义 RenderObject,则需要继承 RenderObjectWidget,考虑到_SliverFlexibleHeader 有一个子节点,我们可以直接继承 SingleChildRenderObjectWidget 类,这样我们可以省去一些和布局无关的代码,比如绘制和事件的点击测试,这些功能 SingleChildRenderObjectWidget 中已经帮我们处理了。

下面我们实现 _FlexibleHeaderRenderSliver,核心代码就在 performLayout 中,可参考注释:

class _FlexibleHeaderRenderSliver extends RenderSliverSingleBoxAdapter {
    _FlexibleHeaderRenderSliver(double visibleExtent)
      : _visibleExtent = visibleExtent;
  
  double _lastOverScroll = 0;
  double _lastScrollOffset = 0;
  late double _visibleExtent = 0;


  set visibleExtent(double value) {
    // 可视长度发生变化,更新状态并重新布局
    if (_visibleExtent != value) {
      _lastOverScroll = 0;
      _visibleExtent = value;
      markNeedsLayout();
    }
  }

  
  void performLayout() {
    // 滑动距离大于_visibleExtent时则表示子节点已经在屏幕之外了
    if (child == null || (constraints.scrollOffset > _visibleExtent)) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      return;
    }

    // 测试overlap,下拉过程中overlap会一直变化.
    double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
    var scrollOffset = constraints.scrollOffset;

    // 在Viewport中顶部的可视空间为该 Sliver 可绘制的最大区域。
    // 1. 如果Sliver已经滑出可视区域则 constraints.scrollOffset 会大于 _visibleExtent,
    //    这种情况我们在一开始就判断过了。
    // 2. 如果我们下拉超出了边界,此时 overScroll>0,scrollOffset 值为0,所以最终的绘制区域为
    //    _visibleExtent + overScroll.
    double paintExtent = _visibleExtent + overScroll - constraints.scrollOffset;
    // 绘制高度不超过最大可绘制空间
    paintExtent = min(paintExtent, constraints.remainingPaintExtent);

    //对子组件进行布局,关于 layout 详细过程我们将在本书后面布局原理相关章节详细介绍,现在只需知道
    //子组件通过 LayoutBuilder可以拿到这里我们传递的约束对象(ExtraInfoBoxConstraints)
    child!.layout(
      constraints.asBoxConstraints(maxExtent: paintExtent),
      parentUsesSize: false,
    );

    //最大为_visibleExtent,最小为 0
    double layoutExtent = min(_visibleExtent, paintExtent);

    //设置geometry,Viewport 在布局时会用到
    geometry = SliverGeometry(
      scrollExtent: layoutExtent,
      paintOrigin: -overScroll,
      paintExtent: paintExtent,
      maxPaintExtent: paintExtent,
      layoutExtent: layoutExtent,
    );
  }
}

performLayout 中我们通过 Viewport 传来的 SliverConstraints 结合子组件的高度,最终确定了 _SliverFlexibleHeader 的布局、绘制等相关信息,它们被保存在了 geometry 中,之后,Viewport 就可以读取 geometry 来确定 _SliverFlexibleHeaderViewport 中的位置,然后进行绘制。可以手动修改 SliverGeometry 的各个属性,看看效果,这样可以加深理解。

现在还剩最后一个问题,_SliverFlexibleHeader 接收的是一个固定的 widget,我们如何在下拉位置发生变化时来重新构建 widget 呢?上面代码中,我们在 _SliverFlexibleHeaderperformLayout 方法中,每当下拉位置发生变化,我们都会对其子组件重新进行 layout。那既然如此,我们可以创建一个 LayoutBuilder 用于在子组件重新布局时来动态构建 child。思路有了,那么实现很简单,我们看看最终的 SliverFlexibleHeader 实现:

typedef SliverFlexibleHeaderBuilder = Widget Function(
  BuildContext context,
  double maxExtent,
  //ScrollDirection direction,
);

class SliverFlexibleHeader extends StatelessWidget {
  const SliverFlexibleHeader({
    Key? key,
    this.visibleExtent = 0,
    required this.builder,
  }) : super(key: key);

  final SliverFlexibleHeaderBuilder builder;
  final double visibleExtent;

  
  Widget build(BuildContext context) {
    return _SliverFlexibleHeader(
      visibleExtent: visibleExtent,
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight
          );
        },
      ),
    );
  }
}

_SliverFlexibleHeader 中每次对子组件进行布局时,都会触发 LayoutBuilder 来重新构建子 widgetLayoutBuilder 中收到的 constraints 就是 _SliverFlexibleHeader 中对子组件进行布局时 传入的 constraints,即:

...
child!.layout(
  //对子组件进行布局
  constraints.asBoxConstraints(maxExtent: paintExtent),
  parentUsesSize: true,
);
...

2. 传递额外的布局信息

在实际使用 SliverFlexibleHeader 时,我们有时在构建子 widget 时可能会依赖当前列表的滑动方向,当然我们可以在 SliverFlexibleHeaderbuilder 中记录前后的 availableHeight 的差来确定滑动方向,但是这样比较麻烦,需要使用者来手动处理。我们知道在滑动时,SliverSliverConstraints 中已经包含了 userScrollDirection,如果我们能将它经过统一的处理然后透传给 LayoutBuilder 的话就非常好了,这样就不需要开发者在使用时自己维护滑动方向了!按照这个思路我们来实现一下。

首先我们遇到了第一个问题: LayoutBuilder 接收的参数我们没法指定。为此有两种方案:

  1. 我们知道在上面的场景中,在对子组件进行布局时我们传给子组件的约束只使用了最大长度,最小长度是没有用到的,那么我们可以将滑动方向通过最小长度传递给 LayoutBuilder,然后再 LayoutBuilder 中取出即可。
  2. 定义一个新类,让它继承自 BoxConstraints,然后再添加一个可以保存 scrollDirection 的属性。

这两种方案都能成功,那应该使用哪种方案呢?建议使用方案 2 ,因为方案 1 有一个副作用就是会影响子组件布局。我们知道 LayoutBuilder 是在子组件 build 阶段执行的,当我们设置了最小长度后,我们虽然在 build 阶段没有用到它,但是在子组件在布局阶段仍然会应用此约束,所以最终还会影响子组件的布局。

下面我们按照方案 2 来实现:定义一个 ExtraInfoBoxConstraints 类,它可以携带约束之外的信息,为了尽可能通用,我们使用泛型:

class ExtraInfoBoxConstraints<T> extends BoxConstraints {
  ExtraInfoBoxConstraints(
    this.extra,
    BoxConstraints constraints,
  ) : super(
          minWidth: constraints.minWidth,
          minHeight: constraints.minHeight,
          maxWidth: constraints.maxWidth,
          maxHeight: constraints.maxHeight,
        );

  // 额外的信息
  final T extra;
  
  
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is ExtraInfoBoxConstraints &&
        super == other &&
        other.extra == extra;
  }

  
  int get hashCode {
    return hashValues(super.hashCode, extra);
  }
}

上面代码比较简单,要说明的是我们重载了“==”运算符,这是因为 Flutter 在布局期间在特定的情况下会检测前后两次 constraints 是否相等然后来决定是否需要重新布局,所以我们需要重载“==”运算符,否则可能会在最大/最小宽高不变但 extra 发生变化时不会触发 child 重新布局,这时也就不会触发 LayoutBuilder,这明显不符合预期,因为我们希望 extra 发生变化时,会触发 LayoutBuilder 重新构建 child

首先我们修改 __FlexibleHeaderRenderSliverperformLayout 方法:

...
  //对子组件进行布局,子组件通过 LayoutBuilder可以拿到这里我们传递的约束对象(ExtraInfoBoxConstraints)
  child!.layout(
  ExtraInfoBoxConstraints(
    direction, //传递滑动方向
    constraints.asBoxConstraints(maxExtent: paintExtent),
  ),
  parentUsesSize: false,
);
...

然后修改 SliverFlexibleHeader 实现,在 LayoutBuilder 中就可以获取到滑动方向:

typedef SliverFlexibleHeaderBuilder = Widget Function(
  BuildContext context,
  double maxExtent,
  ScrollDirection direction,
);

class SliverFlexibleHeader extends StatelessWidget {
  const SliverFlexibleHeader({
    Key? key,
    this.visibleExtent = 0,
    required this.builder,
  }) : super(key: key);

  final SliverFlexibleHeaderBuilder builder;
  final double visibleExtent;

  
  Widget build(BuildContext context) {
    return _SliverFlexibleHeader(
      visibleExtent: visibleExtent,
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight,
            // 获取滑动方向
            (constraints as ExtraInfoBoxConstraints<ScrollDirection>).extra,
          );
        },
      ),
    );
  }
}

最后我们看一下 SliverFlexibleHeader 中确定滑动方向的逻辑:

// 下拉过程中overlap会一直变化.
double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
var scrollOffset = constraints.scrollOffset;
_direction = ScrollDirection.idle;

// 根据前后的overScroll值之差确定列表滑动方向。注意,不能直接使用 constraints.userScrollDirection,
// 这是因为该参数只表示用户滑动操作的方向。比如当我们下拉超出边界时,然后松手,此时列表会弹回,即列表滚动
// 方向是向上,而此时用户操作已经结束,ScrollDirection 的方向是上一次的用户滑动方向(向下),这是便有问题。
var distance = overScroll > 0
  ? overScroll - _lastOverScroll
  : _lastScrollOffset - scrollOffset;
_lastOverScroll = overScroll;
_lastScrollOffset = scrollOffset;

if (constraints.userScrollDirection == ScrollDirection.idle) {
  _direction = ScrollDirection.idle;
  _lastOverScroll = 0;
} else if (distance > 0) {
  _direction = ScrollDirection.forward;
} else if (distance < 0) {
  _direction = ScrollDirection.reverse;
}

3. 高度修正 scrollOffsetCorrection

如果 visibleExtent 变化时,我们看看效果:

在这里插入图片描述

可以看到有一个突兀地跳动,这是因为 visibleExtent 变化时会导致 layoutExtent 发生变化,也就是说 SliverFlexibleHeader 在屏幕中所占的布局高度会发生变化,所以列表就出现跳动。但这个跳动效果太突兀了,我们知道每一个 Sliver 的高度是通过 scrollExtent 属性预估出来的,因此我们需要修正一下 scrollExtent,但是我们不能直接修改 scrollExtent 的值,直接修改不会有任何动画效果,仍然会跳动,为此,SliverGeometry 提供了一个 scrollOffsetCorrection 属性,它专门用于修正 scrollExtent ,我们只需要将要修正差值传给scrollOffsetCorrection,然后 Sliver 会自动执行一个动画效果过渡到我们期望的高度。

 // 是否需要修正scrollOffset。当_visibleExtent值更新后,为了防止
  // 视觉上突然地跳动,要先修正 scrollOffset。
  double? _scrollOffsetCorrection;

  set visibleExtent(double value) {
    // 可视长度发生变化,更新状态并重新布局
    if (_visibleExtent != value) {
      _lastOverScroll = 0;
      _reported = false;
      // 计算修正值
      _scrollOffsetCorrection = value - _visibleExtent;
      _visibleExtent = value;
      markNeedsLayout();
    }
  }

  
  void performLayout() {
    // _visibleExtent 值更新后,为了防止突然的跳动,先修正 scrollOffset
    if (_scrollOffsetCorrection != null) {
      geometry = SliverGeometry(
        //修正
        scrollOffsetCorrection: _scrollOffsetCorrection,
      );
      _scrollOffsetCorrection = null;
      return;
    }
    ...
  } 

运行后效果:

在这里插入图片描述

4. 边界

SliverFlexibleHeader 构建子组件时开发者可能会依赖“当前的可用高度是否为0”来做一些特殊处理,比如记录是否子组件已经离开了屏幕。但是根据上面的实现,当用户滑动非常快时,子组件离开屏幕时的最后一次布局时传递的约束的 maxExtent 可能不为 0,而当 constraints.scrollOffset 大于 _visibleExtent 时我们在 performLayout 的一开始就返回了,因此 LayoutBuilderbuilder 中就有可能收不到 maxExtent0 时的回调。为了解决这个问题,我们只需要在每次 Sliver 离开屏幕时调用一次 child.layout 同时 将maxExtent 指定为 0 即可,为此我们修改一下:

void performLayout() {
    if (child == null) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      return;
    }
    //当已经完全滑出屏幕时
    if (constraints.scrollOffset > _visibleExtent) {
      geometry = SliverGeometry(scrollExtent: _visibleExtent);
      // 通知 child 重新布局,注意,通知一次即可,如果不通知,滑出屏幕后,child 在最后
      // 一次构建时拿到的可用高度可能不为 0。因为使用者在构建子节点的时候,可能会依赖
      // "当前的可用高度是否为0" 来做一些特殊处理,比如记录是否子节点已经离开了屏幕,
      // 因此,我们需要在离开屏幕时确保LayoutBuilder的builder会被调用一次(构建子组件)。
      if (!_reported) {
        _reported = true;
        child!.layout(
          ExtraInfoBoxConstraints(
            _direction, //传递滑动方向
            constraints.asBoxConstraints(maxExtent: 0),
          ),
          //我们不会使用自节点的 Size, 关于此参数更详细的内容见本书后面关于layout原理的介绍
          parentUsesSize: false,
        );
      }
      return;
    }

    //子组件回到了屏幕中,重置通知状态
    _reported = false;
  
  ...
}

至此大功告成!完整源码查看这里:sliver_flexible_header

自定义 SliverPersistentHeaderToBox

我们在上面介绍了 SliverPersistentHeader,在使用时需要遵守两个规则 :

  1. 必须显式指定高度。

  2. 如果我们在使用 SliverPersistentHeader 构建子组件时需要依赖 overlapsContent 参数,则必须保证之前至少还有一个 SliverPersistentHeaderSliverAppBar

遵守上面这两条规则对于开发者来说心智负担还是较重的,比如对于规则 1,大多数时候我们是不知道 Header 具体的高度的,我们期望直接传一个 widget ,这个 widget 的实际高度 SliverPersistentHeader 能自动算出来。对于规则 2 就更不用说,不知道这个准是要踩坑的。

综上,本节我们自定义一个 SliverPersistentHeaderToBox,它可以将任意 RenderBox 适配为可以固定到顶部的 Sliver 而不用显式指定高度,同时避免上面的问题 2。

第一步:我们先看一下定义 SliverPersistentHeaderToBox

typedef SliverPersistentHeaderToBoxBuilder = Widget Function(
  BuildContext context,
  double maxExtent, //当前可用最大高度
  bool fixed, // 是否已经固定
);

class SliverPersistentHeaderToBox extends StatelessWidget {
  // 默认构造函数,直接接受一个 widget,不用显式指定高度
  SliverPersistentHeaderToBox({
    Key? key,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        super(key: key);
 // builder 构造函数,需要传一个 builder,同样不需要显式指定高度
  SliverPersistentHeaderToBox.builder({
    Key? key,
    required this.builder,
  }) : super(key: key);

  final SliverPersistentHeaderToBoxBuilder builder;

  
  Widget build(BuildContext context) {
    return _SliverPersistentHeaderToBox(
      // 通过 LayoutBuilder接收 Sliver 传递给子组件的布局约束信息
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          return builder(
            context,
            constraints.maxHeight,
            //约束中需要传递的额外信息是一个bool类型,表示 Sliver 是否已经固定到顶部
            (constraints as ExtraInfoBoxConstraints<bool>).extra,
          );
        },
      ),
    );
  }
}

和上面的 SliverFlexibleHeader 很像,不同的是SliverPersistentHeaderToBox传递给 child 的约束中的额外信息是一个 bool 类型,表示是否已经固定到顶部。

第二步:实现 _SliverPersistentHeaderToBox

class _RenderSliverPersistentHeaderToBox extends RenderSliverSingleBoxAdapter {
  
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    child!.layout(
      ExtraInfoBoxConstraints(
        //只要 constraints.scrollOffset不为0,则表示已经有内容在当前Sliver下面了,即已经固定到顶部了
        constraints.scrollOffset != 0,
        constraints.asBoxConstraints(
          // 我们将剩余的可绘制空间作为 header 的最大高度约束传递给 LayoutBuilder
          maxExtent: constraints.remainingPaintExtent,
        ),
      ),
      //我们要根据child大小来确定Sliver大小,所以后面需要用到child的大小(size)信息
      parentUsesSize: true,
    );

    // 子节点 layout 后就能获取它的大小了
    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }

    geometry = SliverGeometry(
      scrollExtent: childExtent,
      paintOrigin: 0, // 固定,如果不想固定应该传` - constraints.scrollOffset`
      paintExtent: childExtent,
      maxPaintExtent: childExtent,
    );
  }

  // 重要,必须重写,下面介绍。
  
  double childMainAxisPosition(RenderBox child) => 0.0;
}

上面代码有四点需要注意:

  1. constraints.scrollOffset 不为 0 时,则表示已经固定到顶部了。
  2. 我们在布局阶段拿到子组件的 size 信息,然后通过通过子组件的大小来确定 Sliver 大小(设置geometry)。 这样就不再需要我们显式传高度值了。
  3. 我们通过给 paintOrigin 设为 0 来实现顶部固定效果;不固定到顶部时应该传 - constraints.scrollOffset,这个可以通过运行示例修改一下参数值查看效果来理解。
  4. 必须要重写 childMainAxisPosition ,否则事件便会失效,该方法的返回值在“点击测试”中会用到。该函数应该返回 paintOrigin 的位置。

大功告成!下面我们来测试一下!我们创建两个 header

  1. 第一个 header:当没有滑动到顶部时,外观和正常列表项一样;当固定到顶部后,显示一个阴影。为了实现这个效果我们需要通过 SliverPersistentHeaderToBox.builder 来动态创建。
  2. 第二个 header: 一个普通的列表项,它接受一个 widget。
class SliverPersistentHeaderToBoxRoute extends StatelessWidget {
  const SliverPersistentHeaderToBoxRoute({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        buildSliverList(5),
        SliverPersistentHeaderToBox.builder(builder: headerBuilder),
        buildSliverList(5),
        SliverPersistentHeaderToBox(child: wTitle('Title 2')),
        buildSliverList(50),
      ],
    );
  }

  // 当 header 固定后显示阴影
  Widget headerBuilder(context, maxExtent, fixed) {
    // 获取当前应用主题,关于主题相关内容将在后面章节介绍,现在
    // 我们要从主题中获取一些颜色。
    var theme = Theme.of(context);
    return Material(
      child: Container(
        color: fixed ? Colors.white : theme.canvasColor,
        child: wTitle('Title 1'),
      ),
      elevation: fixed ? 4 : 0,
      shadowColor: theme.appBarTheme.shadowColor,
    );
  }

  // 我们约定小写字母 w 开头的函数代表是需要构建一个 Widget,这比 buildXX 会更简洁
  Widget wTitle(String text) =>
      ListTile(title: Text(text), onTap: () => print(text));
}

运行效果:

在这里插入图片描述
我们实现的 SliverPersistentHeaderToBox 不仅不需要显式指定高度,而且它的 builder 函数的第三个参数值也正常了(和SliverPersistentHeaderToBox 数量无关)。

完整源码查看这里:sliver_persistent_header_to_box

注意:

  • 如果我们要使用 SliverAppBar,则建议搭配 SliverPersistentHeader 使用,因为 SliverPersistentHeader 设计的初衷就是为了实现 SliverAppBar,所以它们一起使用时会有更好的协同。如果将 SliverPersistentHeaderToBoxSliverAppBar 一起使用,则可能又会导致其他问题,所以建议就是:在没有使用 SliverAppBar 时,用 SliverPersistentHeaderToBox,如果使用了 SliverAppBar ,就用SliverPersistentHeader

参考:《Flutter实战·第二版》

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/570538.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Protein Cell | 中国农科院基因组所刘永鑫组综述微生物组研究的过去、现在和未来(大众评审截止26号20点)...

微生物组研究展望&#xff1a;过去、现在和未来 Microbiome research outlook: past, present, and future 2023-5-23&#xff0c;Protein & Cell&#xff0c;[IF 15.328] DOI&#xff1a;10.1093/procel/pwad031 原文链接&#xff1a;https://academic.oup.com/proteincel…

adb 命令速查(下)

ADB 关于APP安装、调试和monkey压力测试 作者&#xff1a;炭烤毛蛋 &#xff0c;查看博主了解更多。 提示&#xff1a;承接上篇《adb 命令速查(中)》&#xff0c;本文将 文章目录 ADB 关于APP安装、调试和monkey压力测试7 adb 关于 apk 的相关操作7.1 安装 apk普通安装带有命…

QQGC?揭秘QQ的AI绘画大模型技术

&#x1f449;腾小云导读 2022年来&#xff0c;AIGC概念迅速出圈并快速形成产业生态&#xff0c;成为继PGC、UGC之后新的数字内容创作形式。QQ影像中心提出了自研的AI画画技术方案——QQGC&#xff0c;本文将介绍在QQGC基础大模型训练中的实践和探索&#xff0c;接着往下看吧~ …

我用AI帮我唱了首“基尼太美”,颠覆了我的认知!太牛逼了

目录 前言 AI唱"基尼太美"是什么感觉 使用so-vits-svc打造自己专属歌手 1.声音素材整理 2.训练模型 3.让AI唱歌​编辑 AI歌手背后的技术 AI歌手会成为主流吗 写到最后 大家好&#xff0c;我是大侠&#xff0c;AI领域的专业博主 前言 在5月份&#xff0c;孙…

第五篇:强化学习基础之马尔科夫决策过程

你好&#xff0c;我是zhenguo(郭震) 今天总结强化学习第五篇&#xff1a;马尔科夫决策过程 基础 马尔科夫决策过程&#xff08;MDP&#xff09;是强化学习的基础之一。下面统一称为&#xff1a;MDP MDP提供了描述序贯决策问题的数学框架。 它将决策问题建模为&#xff1a; 状态…

司空见惯 - 使用dBm表示功率的各种现实情况

前面一篇文章介绍过&#xff0c;使用dBm表示功率时&#xff0c;如何转换为mW。 那现实世界的实际情况中&#xff0c;使用dBm来表示电磁波的能量强度&#xff0c;列表如下&#xff1a; Power level Power Notes 526 dBm 3.61049 W 黑洞碰撞后的引力波辐射的功率&#xff0c…

解决缓存与数据库数据不一致的问题,这篇文章告诉你如何做!

缓存是提高应用程序性能和响应速度的关键组件之一。缓存可以帮助减少数据库查询次数&#xff0c;从而减轻服务器负担并加快页面加载速度。然而&#xff0c;缓存与数据库一致性是分布式系统中常见的问题&#xff0c;因为缓存和数据库之间可能存在数据不一致的情况。为了解决这个…

CyberLink的摄像头应用程序YouCam 10.1版本在win10系统的下载与安装配置教程

目录 前言一、YouCam安装二、使用配置总结 前言 YouCam是由CyberLink公司开发的一款实用的摄像头应用程序&#xff0c;它集成了多种实时视频特效、背景虚化、美颜、屏幕录制等功能。 通过使用该软件内置的相机特效&#xff0c;用户可以将视频聊天或自拍照片变得更加精彩和有趣…

oracle表空间、用户、表的关系和创建

目录 一、表空间 二、用户 &#xff08;1&#xff09;Oracle和mysql、sqlserver的区别 &#xff08;2&#xff09;创建用户 &#xff08;3&#xff09;给用户授权 三、表 &#xff08;1&#xff09;创建表 &#xff08;2&#xff09;用图像化软件添加表约束 1.主键约束…

TikTok正测试名为“Tako”的AI聊天机器人;武汉大学宣布推出CheeseChat

&#x1f680; 近日安徽安庆一起利用AI换脸技术的电信诈骗案件 近日安徽安庆一起利用AI换脸技术的电信诈骗案件&#xff0c;3名涉案人员被抓获并返还被骗款132万元。 此前也有多起利用AI换脸技术进行的电信诈骗案件&#xff0c;甚至还出现在明星直播带货中。 专家提示&#…

ChatGPT无限可能性:自然语言生成的奥秘

&#x1f497;wei_shuo的个人主页 &#x1f4ab;wei_shuo的学习社区 &#x1f310;Hello World &#xff01; ChatGPT无限可能性&#xff1a;自然语言生成的奥秘 数字化时代&#xff1a;跨越语言和文化障碍 冰岛是北大西洋中部的一个岛国&#xff0c;拥有充满活力的科技产业和…

网络编程初识

如果这篇有没接触过的知识点&#xff0c;请转到网络编程先导知识_小梁今天敲代码了吗的博客-CSDN博客 目录 IPv4和IPv6的概念&#xff1a; 子网掩码 默认网关 ping命令 端口 OSI网络分层模型 TCP/IP四层模型 字节序转换函数 IP地址转换 上一篇介绍了网络编程的先导知…

chatgpt赋能python:Python动态实时轨迹绘图:让数据可视化更生动

Python 动态实时轨迹绘图&#xff1a;让数据可视化更生动 数据可视化是现代数据分析中不可或缺的一部分。在Python语言中&#xff0c;有许多工具和库可以帮助我们将数据转化为可视化的图表。然而&#xff0c;有些情况下&#xff0c;静态图表难以准确有效地展现数据的变化趋势和…

chatgpt赋能python:Python动态Import:优化你的编程体验

Python 动态 Import&#xff1a;优化你的编程体验 在 Python 中&#xff0c; Import 是一个非常常见的操作。它允许你从其他模块中引入需要的函数或者变量&#xff0c;从而避免在不同模块中重复编写代码。在大型项目中&#xff0c; Import 操作可能会变得很混乱&#xff0c;导…

Java内存管理:垃圾回收算法和内存分配的原理和优化

章节一&#xff1a;引言 在当今的软件开发领域&#xff0c;Java是一门广泛应用的编程语言。Java虚拟机&#xff08;JVM&#xff09;负责管理Java应用程序的内存&#xff0c;并通过垃圾回收算法和内存分配策略来优化内存使用。本文将详细介绍Java内存管理的原理、垃圾回收算法的…

【熬夜送书 | 第一期】Java生日快乐,不负代码不负君,面向对象面向卿

文章目录 前言一、java是什么&#xff1f;二、好书推荐《Java核心技术》《Java编程思想》Effective Java 中文版&#xff08;原书第3版&#xff09;Java语言程序设计基础篇进阶篇&#xff08;原书第12版&#xff09;Java并发编程实战软件架构实践&#xff08;原书第4版&#xf…

ThingsBoard教程(五三):规则节点解析 Kafka Node, MQTT Node

Kafka Node Since TB Version 2.0 Kafka节点将消息发送到Kafka代理。它可以接收任何类型的消息。该节点会通过Kafka生产者将记录发送到Kafka服务器。 配置 主题模式 - 可以是静态字符串,也可以是使用消息元数据属性解析的模式。例如${deviceType}引导服务器 - 用逗号分隔的…

fork/join框架

文章目录 前言一、fork/join是什么&#xff1f;二、使用步骤总结 前言 使用Fork/Join框架首先要考虑到的是如何分割任务,分割之后,根据join 再进行任务结果的合并,也就是类似二分法的,分而治之的理念; 一、fork/join是什么&#xff1f; fork 拆分任务,将大任务拆分成小任务,拆…

Linux学习笔记---编辑器Vim

Vim是Linux中功能十分强大的文本编辑器&#xff0c;熟练地使用Vim可以让你高效地在Shell界面编写代码&#xff0c;接下来我们开始一步一步地学习Vim的使用方法: 一、文本编辑器vim的使用 1. 打开Vim 打开终端直接输入vim&#xff0c;即可打开Vim编辑器主界面 它是原始vi编辑…

简单谈谈BIO,NIO,AIO

目录 IO 概述 阻塞 IO (BIO) 基本理解 非阻塞 IO(NIO) ​核心部分 Channel Buffer Selector Channel Buffer Selector 三者关系 异步非阻塞 IO(AIO) IO 概述 IO 的操作方式通常分为几种&#xff1a;同步阻塞 BIO、同步非阻塞 NIO、异步非阻塞 AIO。 &#xff08;1…