当前位置:   article > 正文

Flutter 笔记 | Flutter 可滚动组件_flutter 滚动条

flutter 滚动条

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,  
})
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
属性 说明
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 列表
})
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

需要注意的是:

  • 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(
    ...
  ),
);
  • 1
  • 2
  • 3
  • 4
  • 5

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,
})
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

除了前面介绍过的可滚动组件的通用属性外,这里重点关注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(),
          ),
        ),
      ),
    );
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

效果:

在这里插入图片描述

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>[],
})
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

上面参数分为两组:第一组是可滚动组件的公共参数,前面已经介绍过,不再赘述;第二组是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'),
  ],
);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

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

ListView.builder

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

ListView.builder({
   
  // ListView公共参数已省略  
  ...
  required IndexedWidgetBuilder itemBuilder,
  int itemCount,
  ...
})
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 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"));
  }
);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

效果:

在这里插入图片描述

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;
      },
    );
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

效果:

在这里插入图片描述

固定高度列表

前面说过,给列表指定 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")),
        );
      },
    );
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

因为列表项都是一个 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)
...
  • 1
  • 2
  • 3
  • 4

可见 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)
...
  • 1
  • 2
  • 3
  • 4

可以发现,列表不知道列表项的具体高度,高度约束变为 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个单词
        );
      });
    });
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72

效果:

在这里插入图片描述

添加固定列表头

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

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


Widget build(BuildContext context) {
   
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    ListView.builder(itemBuilder: (BuildContext context, int index) {
   
        return ListTile(title: Text("$index"));
    }),
  ]);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

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

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

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

... //省略无关代码
SizedBox(
  height: 400, // 指定列表高度为400
  child: ListView.builder(
    itemBuilder: (BuildContext context, int index) {
   
      return ListTile(title: Text("$index"));
    },
  ),
),
...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

效果:

在这里插入图片描述

可以看到,现在没有触发异常并且列表已经显示出来了,但是我们的手机屏幕高度要大于 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"));
  }),
)
...    
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

效果:

在这里插入图片描述

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

那么有什么方法可以自动拉伸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"));
      }),
    ),
  ]);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

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);
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86

这个 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 }) ;
  • 1
  • 2
  • 3
  • 4
  • 5

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

在这里插入图片描述
初始的时候有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 FadeTransi
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/繁依Fanyi0/article/detail/463089
推荐阅读
相关标签
  

闽ICP备14008679号