05.Flutter可滚动的Widget

可滚动组件介绍

Sliver 布局模型

Flutter 有两种布局模型:

Sliver 可以包含一个或多个子组件,只有出现在视窗口时才会去构建子组件,这种模型称为 基于Sliver的列表按需加载模型,可滚动组件中有很多都支持基于 Sliver 的按需加载模型:ListView、GridView;也有不支持该模型的,如 SingleChildScrollView。
Flutter 中的可滚动组件主要由三个角色组成:Scrollable、Viewport 和 Sliver:

具体布局过程:

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

比如有一个 ListView,大小撑满屏幕,假设它有 100 个列表项(都是 RenderBox)且每个列表项高度相同,结构如图:
gy5g6
图中白色区域为设备屏幕,也是 Scrollable 、 Viewport 和 Sliver 所占用的空间,三者所占用的空间重合,父子关系为:Sliver 父组件为 Viewport,Viewport 的 父组件为 Scrollable 。注意 ListView 中只有一个 Sliver,在 Sliver 中实现了子组件(列表项)的按需加载和布局。
其中顶部和底部灰色的区域为 cacheExtent,它表示预渲染的高度,需要注意这是在可视区域之外,如果 RenderBox 进入这个区域内,即使它还未显示在屏幕上,也是要先进行构建的,预渲染是为了后面进入 Viewport 的时候更丝滑。cacheExtent 的默认值是 250,在构建可滚动列表时我们可以指定这个值,这个值最终会传给 Viewport。

Scrollable

用于处理滑动手势,确定滑动偏移,滑动偏移变化时构建 Viewport。Scrollable 关键属性:

Scrollable({
  // ...
  this.axisDirection = AxisDirection.down,
  this.controller,
  this.physics,
  required this.viewportBuilder,
})

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

Viewport

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

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

Sliver

Sliver 主要作用是对子组件进行构建和布局,比如 ListView 的 Sliver 需要实现子组件(列表项)按需加载功能,只有当列表项进入预渲染区域时才会去对它进行构建和布局、渲染。
Sliver 对应的渲染对象类型是 RenderSliver,RenderSliver 和 RenderBox 的相同点是都继承自 RenderObject 类,不同点是在布局的时候约束信息不同。RenderBox 在布局时父组件传递给它的约束信息对应的是 BoxConstraints,只包含最大宽高的约束;而 RenderSliver 在布局时父组件(列表)传递给它的约束是对应的是 SliverConstraints。

可滚动组件的通用配置

几乎所有的可滚动组件在构造时都能指定 scrollDirection(滑动的主轴)、reverse(滑动方向是否反向)、controllerphysicscacheExtent,这些属性最终会透传给对应的 Scrollable 和 Viewport,这些属性我们可以认为是可滚动组件的通用属性。

Android 与 iOS 效果不同。 Android 会呈现出一个波纹状(对应ClampingScrollPhysics), 而 iOS 上有一个回弹的弹性效果(对应BouncingScrollPhysics)。 如果你想不同的平台上呈现各自的效果可以使用AlwaysScrollableScrollPhysics, 它会根据不同平台自动选用各自的物理效果。如果你想禁用在边缘的拖动效果, 那可以使用NeverScrollableScrollPhysics

设置预加载的区域 cacheExtent 强制设置为了 0.0,从而关闭了 " 预加载 "

子节点缓存

为了方便控制子组件在滑出可视区域后是否缓存,可滚动组件提供了一种缓存子节点的通用解决方案,它允许开发者对特定的子界限进行缓存。

如 ListView 的 Header 一般要进行缓存

Scrollbar

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

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

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

SingleChildScrollView

SingleChildScrollView 类似于 Android 中的 ScrollView,它只能接收一个子组件,不支持懒加载,性能较差。SingleChildScrollView 定义:

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

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

class SingleChildScrollViewTestRoute extends StatelessWidget {
  @override
  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(),
          ),
        ),
      ),
    );
  }
}

zs0b2

ListView

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

默认构造函数

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

上面参数分为两组:第一组是可滚动组件的公共参数;第二组是 ListView 各个构造函数(ListView 有多个构造函数)的共同参数。

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

child 高度会适配 item 填充的内容的高度,我们非常的不希望 child 的高度固定,因为这样的话,如果里面的内容超出就会造成布局的溢出。
shrinkWrap 多用于嵌套 listView 中 内容大小不确定 比如 垂直布局中 先后放入文字 listView (需要 Expend 包裹否则无法显示无穷大高度 但是需要确定 listview 高度 shrinkWrap 使用内容适配不会有这样的影响)

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

ListView.builder()

ListView.builder 适合列表项比较多或者列表项不确定的情况:

ListView.builder({
  // ListView公共参数已省略  
  // ...
  required IndexedWidgetBuilder itemBuilder,
  int itemCount,
  // ...
})
ListView.builder(
  itemCount: 100,
  itemExtent: 50.0, //强制高度为50.0
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text("$index"));
  }
);

trmmf

ListView.separated()

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

static ListView list_divider(BuildContext context) {
	return ListView.separated(
    separatorBuilder: (context, index) {
      //和itemBuilder 同级别的执行
      if (index == 2) {
        return Container(
          height: 40.0,
          color: Colors.red,
          child: const Center(
            child: Text("类型1"),
          ),
        );
      } else if (index == 7) {
        return Container(
          height: 40.0,
          color: Colors.blue,
          child: const Center(
            child: Text("类型2"),
          ),
        );
      } else if (index == 14) {
        return Container(
          height: 40.0,
          color: Colors.yellow,
          child: const Center(
            child: Text("类型3"),
          ),
        );
      } else {
        return Container();
      }
    },
    shrinkWrap: true,
    primary: true,
    physics: const BouncingScrollPhysics(),
    //是否根据子组件的总长度来设置ListView的长度,默认值为false
    padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
    // ListView的内边距 const EdgeInsets.all(30),
    scrollDirection: Axis.vertical,
    //沿竖直方向上布局
    itemBuilder: (context, index) {
      return Text("ListView item $index");
    },
    itemCount: 100);
}

fwtes

固定高度列表

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

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

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

其他

ListTile Flutter 内置的 itemView

this.leading,              // 内容的==>前置图标
this.title,                // 内容的==>标题
this.subtitle,             // 内容的==>副标题
this.trailing,             // 内容的==>后置图标
this.isThreeLine = false,  // 内容的==>是否三行显示
this.dense,                // 内容的==>直观感受是整体大小
this.contentPadding,       // 内容的==>内容内边距
this.enabled = true,       // 内容 是否禁用
this.onTap,                // item onTap 点击事件
this.onLongPress,          // item onLongPress 长按事件
this.selected = false,     // item 是否选中状态

ListView children 与 ListView.builder 的区别

ListView 案例

新闻列表

class ListViewDemo {
  static ListView list3(BuildContext context) {
    return ListView(scrollDirection: Axis.vertical, children: const <Widget>[
      ListTile(
        // 主标题
        title: Text('Flutter 由 Google 的工程师团队打造,用于创建高性能、跨平台的移动应用',
                    //文字左对齐
                    textAlign: TextAlign.left,
                    //超出显示省略号
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(
              //数字必须是Double类型的
              fontSize: 20.0,
              //  设置字体的颜色
              color: Color.fromARGB(200, 100, 100, 8))),
        // 副标题
        subtitle: Text('你好flutter'),
      ),
      ListTile(
        title: Text('Flutter 由 Google 的工程师团队打造,用于创建高性能、跨平台的移动应用'),
        subtitle: Text('你好flutter'),
      ),
      ListTile(
        // 主标题
        // 在前面设置图标
        leading: Icon(
          //设置图标类型
          Icons.settings,
          //0x后面开始 两位FF表示透明度16进制,
          color: Color(0xFFFFB6C1),
          //这是图标的大小
          size: 30.0),
        // 在后面设置图标
        trailing: Icon(Icons.accessible),
        title: Text('flutter教程_2021 Dart Flutter入门实战视频教程132讲',
                    //文字左对齐
                    textAlign: TextAlign.left,
                    //超出显示省略号
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(
              fontSize: 20.0, //数字必须是Double类型的
              //  设置字体的颜色
              color: Color(0xFFFFB6C1))),
        subtitle: Text('不管是Ios还是Android开发都可以在flutter官网上查到安装及使用步骤,这里我就不累述太多'),
      ),
      ListTile(
        // 主标题
        // 通过leading可以将图片放在前面
        leading: Image(
          image: NetworkImage("https://10.url.cn/eth/ajNVdqHZLLB2ibIiaR23jaQpq0rTL1eXfBDkQzHc15ZH2qbl5Tn7A6HMnGfCfU3nDSqHHEuh8Lw7I/"),
          fit: BoxFit.scaleDown,
          color: Colors.blueGrey,
          colorBlendMode: BlendMode.multiply,
        ),
        title: Text('flutter教程_2021 Dart Flutter入门实战视频教程132讲',
                    textAlign: TextAlign.left, //文字左对齐
                    overflow: TextOverflow.ellipsis, //超出显示省略号
                    style: TextStyle(
              fontSize: 20.0, //数字必须是Double类型的
              //  设置字体的颜色
              color: Color(0xFFFFB6C1))),
        subtitle: Text('不管是Ios还是Android开发都可以在flutter官网上查到安装及使用步骤,这里我就不累述太多'),
      ),
    ]);
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demos',
      home: Scaffold(
        appBar: AppBar(
          title: const Text("Flutter Demos"),
        ),
        // body: list(context),
        body: Container(
            color: const Color.fromARGB(220, 135, 167, 11), child:ListViewDemo.list3(context)),
      ),
    );
  }
}

效果:
xqn4s

ListView.builder 构建动态列表

import 'package:flutter/material.dart';

void main() => runApp(MyApp(
      items: List<String>.generate(1000, (i) => "Item $i"),
    ));

class MyApp extends StatelessWidget {
  final List<String> items;

  const MyApp({Key key, this.items}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "ListView Widget",
      home: Scaffold(
        body: ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            return ListTile(
              title: Text('$items[index]'),
            );
          },
        ),
      ),
    );
  }
}

o1q1x

案例

typedef OnItemClickListener = void Function(int position, ItemBean itemBean);

class ItemWidget extends StatefulWidget {
  final int position;
  final ItemBean itemBean;
  final OnItemClickListener? onItemClickListener;

  const ItemWidget(this.position, this.itemBean, this.onItemClickListener,
      {super.key});

  @override
  ItemWidgetState createState() => ItemWidgetState();
}

class ItemWidgetState extends State<ItemWidget> {
  Color _color = Colors.white;

  @override
  void initState() {
    super.initState();
    _color = Colors.white;
  }

  @override
  Widget build(BuildContext context) {
    Column column = Column(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        const SizedBox(
          height: 8,
        ),
        Text(widget.itemBean.title),
        const SizedBox(
          height: 6,
        ),
        Text(widget.itemBean.description),
        const SizedBox(
          height: 8,
        ),
        const Divider(
          color: Colors.grey,
          height: 0.5,
        ),
      ],
    );
    var container = Container(
      color: _color,
      padding: const EdgeInsets.only(left: 16.0),
      child: column,
    );
    return GestureDetector(
      child: container,
      onTap: () {
        print('onTap ${widget.position} ${widget.itemBean.title}');
        widget.onItemClickListener?.call(widget.position, widget.itemBean);
      },
      onTapDown: (_) => _updatePressedColor(),
      onTapUp: (_) => _updateNormalColor(),
      onTapCancel: () => _updateNormalColor(),
    );
  }

  void _updateNormalColor() {
    Future.delayed(const Duration(milliseconds: 100), () {
      // 解决快速点击没有效果的问题
      setState(() {
        _color = Colors.white;
      });
    });
  }

  void _updatePressedColor() {
    setState(() {
      _color = const Color(0xFFF0F1F2);
    });
  }
}

class ListViewWidget extends StatefulWidget {
  final OnItemClickListener listener;

  const ListViewWidget(this.listener, {super.key});

  @override
  ListViewWidgetState createState() => ListViewWidgetState();
}

class ListViewWidgetState extends State<ListViewWidget> {
  final List<ItemBean> itemBeans = [];

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

  // 实际场景可能是从网络拉取,这里演示就直接填充数据源了
  void _initData() {
    itemBeans.add(ItemBean('第一句', '关注微信公众号「AndroidTraveler」'));
    itemBeans.add(ItemBean('第二句', '星河滚烫,你是人间理想'));
    itemBeans.add(ItemBean('第三句', '我明白你会来,所以我等。'));
    itemBeans.add(ItemBean('第四句', '家人闲坐,灯火可亲。'));
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: ListView.builder(
        itemCount: itemBeans.length,
        itemBuilder: (context, pos) {
          return ItemWidget(pos, itemBeans[pos], widget.listener);
        },
      ),
    );
  }
}

class MyListViewApp extends StatelessWidget {
  const MyListViewApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: _buildWidget(),
        ),
      ),
    );
  }

  Widget _buildWidget() {
    return ListViewWidget((pos, itemBean) => print('点击了第 $pos 项 $itemBean'));
  }
}

ho7ya

无限加载列表

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

  @override
  InfiniteListViewState createState() => InfiniteListViewState();
}

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

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

  List<String> _generateWordPair() {
    var list = <String>[];
    const char = "abcdefghijklmnopqrstuvwxyz";
    for (var i = 0; i < 20; i++) {
      Random random = Random();
      var index = random.nextInt(char.length);
      list.add(char[index]);
    }
    return list;
  }

  void _retrieveData() {
    Future.delayed(const Duration(seconds: 2)).then((value) => {
          setState(() {
            List<String> list = _generateWordPair();
            _words.insertAll(
                _words.length - 1,
                // 每次生成20个单词
                list.take(20).map((e) => e).toList());
          })
        });
  }

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

3t2a9

ListView 原理

ListView 内部组合了 Scrollable、Viewport 和 Sliver,需要注意:

  1. ListView 中的列表项组件都是 RenderBox,并不是 Sliver, 这个一定要注意。
  2. 一个 ListView 中只有一个 Sliver,对列表项进行按需加载的逻辑是 Sliver 中实现的。
  3. ListView 的 Sliver 默认是 SliverList,如果指定了 itemExtent ,则会使用 SliverFixedExtentList;如果 prototypeItem 属性不为空,则会使用 SliverPrototypeExtentList,无论是是哪个,都实现了子组件的按需加载模型。

AnimatedList

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

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

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

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

示例:

class AnimatedListViewPage extends StatelessWidget {
  const AnimatedListViewPage({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text("AnimatedListView Demo"),
        ),
        body: Container(
          decoration: const BoxDecoration(
            color: Colors.transparent,
          ),
          child: const AnimatedListRoute(),
        ),
      ),
    );
  }
}

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

  @override
  _AnimatedListRouteState createState() => _AnimatedListRouteState();
}

class _AnimatedListRouteState extends State<AnimatedListRoute> {
  var data = <String>[]; // 列表数据
  int counter = 5;

  final globalKey = GlobalKey<AnimatedListState>();

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

  @override
  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: const 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
      );
    });
  }
}

x2ila

ScrollController

ScrollController 属性和方法

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

滚动位置恢复

PageStorage 是一个用于保存页面 (路由) 相关数据的组件,它并不会影响子树的 UI 外观,其实,PageStorage 是一个功能型组件,它拥有一个存储桶(bucket),子树中的 Widget 可以通过指定不同的 PageStorageKey 来存储各自的数据或状态。每次滚动结束,可滚动组件都会将滚动位置 offset 存储到 PageStorage 中,当可滚动组件重新创建时再恢复。

当一个路由中包含多个可滚动组件时,如果你发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这时你可以通过显式指定 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 保存在 ScrollController 的 positions 属性中(List<ScrollPosition>)。ScrollPosition 是真正保存滑动位置信息的对象,offset 只是一个便捷属性:

double get offset => position.pixels;

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

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

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

ScrollController 控制原理

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

当 ScrollController 和可滚动组件关联时,可滚动组件首先会调用 ScrollController 的 createScrollPosition() 方法来创建一个 ScrollPosition 来存储滚动位置信息,接着,可滚动组件会调用 attach() 方法,将创建的 ScrollPosition 添加到 ScrollController 的 positions 属性中,这一步称为 " 注册位置 ",只有注册后 animateTo() 和 jumpTo() 才可以被调用。

当可滚动组件销毁时,会调用 ScrollController 的 detach() 方法,将其 ScrollPosition 对象从 ScrollController 的 positions 属性中移除,这一步称为 " 注销位置 ",注销后 animateTo() 和 jumpTo() 将不能再被调用。

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

案例

示例 1

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

class ScrollControllerTestRoute extends StatefulWidget {
  const ScrollControllerTestRoute({super.key});
  @override
  ScrollControllerTestRouteState createState() {
    return ScrollControllerTestRouteState();
  }
}
class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
  final ScrollController _controller = ScrollController();
  bool showToTopBtn = false; // 是否显示“返回到顶部”按钮
  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      print(_controller.offset);

      if (_controller.offset < 1000 && showToTopBtn) {
        // 滚动位置小于1000像素时,隐藏“返回到顶部”按钮
        setState(() {
          showToTopBtn = false;
        });
      } else if (_controller.offset >= 1000 && showToTopBtn == false) {
        // 滚动位置超过1000像素时,显示“返回到顶部”按钮
        setState(() {
          showToTopBtn = true;
        });
      }
    });
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("ListView 滚动控制 ScrollController")),
      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: const Icon(Icons.arrow_upward),
              onPressed: () {
                print(
                    'ScrollControllerTestRouteState onPressed offset=${_controller.offset} position=${_controller.position}');
                // 返回到顶部时执行动画
                _controller.animateTo(.0,
                    duration: const Duration(milliseconds: 200),
                    curve: Curves.ease);
              }),
    );
  }
  @override
  void dispose() {
    _controller.dispose(); // 释放资源
    print('ScrollControllerTestRouteState dispose');
    super.dispose();
  }
}

g7mmn

GridView

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>[],
    // ...
})

SliverGridDelegateWithFixedCrossAxisCount
实现了一个横轴为固定数量子元素的 layout 算法:

SliverGridDelegateWithFixedCrossAxisCount({
  @required double crossAxisCount, 
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})

子元素的大小是通过 crossAxisCount 和 childAspectRatio 两个参数共同决定的
示例:

GridView(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 3, //横轴三个子widget
      childAspectRatio: 1.0 //宽高比为1时,子widget
  ),
  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)
  ]
);

qb2k7
SliverGridDelegateWithMaxCrossAxisExtent
实现了一个横轴子元素为固定最大长度的 layout 算法

SliverGridDelegateWithMaxCrossAxisExtent({
  double maxCrossAxisExtent,
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})

示例:

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

e4g7p

GridView.count

GridView.count 构造函数内部使用了 SliverGridDelegateWithFixedCrossAxisCount,我们通过它可以快速的创建副轴固定数量子元素的 GridView

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

ep6dg

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

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

示例:

class InfiniteGridView extends StatefulWidget {
  @override
  _InfiniteGridViewState createState() => _InfiniteGridViewState();
}

class _InfiniteGridViewState extends State<InfiniteGridView> {
  List<IconData> _icons = []; //保存Icon数据

  @override
  void initState() {
    super.initState();
    // 初始化数据
    _retrieveIcons();
  }

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3, //每行三列
        childAspectRatio: 1.0, //显示区域宽高相等
      ),
      itemCount: _icons.length,
      itemBuilder: (context, index) {
        //如果显示到最后一个并且Icon总数小于200时继续获取数据
        if (index == _icons.length - 1 && _icons.length < 200) {
          _retrieveIcons();
        }
        return Icon(_icons[index]);
      },
    );
  }

  //模拟异步获取数据
  void _retrieveIcons() {
    Future.delayed(Duration(milliseconds: 200)).then((e) {
      setState(() {
        _icons.addAll([
          Icons.ac_unit,
          Icons.airport_shuttle,
          Icons.all_inclusive,
          Icons.beach_access,
          Icons.cake,
          Icons.free_breakfast,
        ]);
      });
    });
  }
}

4pwut

GridView 网格列表组件

class MyGridView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GridView(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          mainAxisSpacing: 2.0,
          crossAxisSpacing: 2.0,
          childAspectRatio: 0.7),
      children: <Widget>[
        new Image.network(
            'http://img5.mtime.cn/mt/2018/10/22/104316.77318635_180X260X4.jpg',
            fit: BoxFit.cover),
        new Image.network(
            'http://img5.mtime.cn/mt/2018/10/10/112514.30587089_180X260X4.jpg',
            fit: BoxFit.cover),
        new Image.network(
            'http://img5.mtime.cn/mt/2018/11/13/093605.61422332_180X260X4.jpg',
            fit: BoxFit.cover),
        new Image.network(
            'http://img5.mtime.cn/mt/2018/11/07/092515.55805319_180X260X4.jpg',
            fit: BoxFit.cover),
        new Image.network(
            'http://img5.mtime.cn/mt/2018/11/21/090246.16772408_135X190X4.jpg',
            fit: BoxFit.cover),
        new Image.network(
            'http://img5.mtime.cn/mt/2018/11/17/162028.94879602_135X190X4.jpg',
            fit: BoxFit.cover),
        new Image.network(
            'http://img5.mtime.cn/mt/2018/11/19/165350.52237320_135X190X4.jpg',
            fit: BoxFit.cover),
        new Image.network(
            'http://img5.mtime.cn/mt/2018/11/16/115256.24365160_180X260X4.jpg',
            fit: BoxFit.cover),
        new Image.network(
            'http://img5.mtime.cn/mt/2018/11/20/141608.71613590_135X190X4.jpg',
            fit: BoxFit.cover),
      ],
    );
  }
}

6dow9

案例:仿微信支付页面

class WxPageTest extends StatelessWidget {
  const WxPageTest({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(100.0),
          gradient: const LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xFF56AF6D),
                Color(0x21FF00FF),
                Color(0xFF56AA6D),
              ])),
    );
  }
}

class WxPage extends StatelessWidget {
  const WxPage({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('微信支付页面模拟',
              style: Theme.of(context)
                  .textTheme
                  .headline4), // brightness: Brightness.dark,
        ),
        body: ListView(
          children: [
            _headerGridButtons(),
            _dynamicGridButtons(GridMockData.financeGrids(), "金融理财"),
            _dynamicGridButtons(GridMockData.serviceGrids(), "生活服务"),
            _dynamicGridButtons(GridMockData.thirdpartyGrids(), "购物消费")
          ],
        ),
      ),
    );
  }
}

/// 菜单项Widget
Column _getMenuItem(String icon, String name, {Color color = Colors.black}) {
  return Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      SizedBox(
        width: 50,
        height: 50,
        child: Image.asset(icon),
      ),
      const SizedBox(
        height: 5,
      ),
      Text(
        name,
        style: TextStyle(fontSize: 14.0, color: color, height: 2),
      )
    ],
  );
}

/// 顶部两个按钮
const double MARGIN = 10;

Widget _headerGridButtons() {
  List<Map<String, String>> buttons = GridMockData.headerGrids();
  return Container(
    height: 144,
    margin: const EdgeInsets.fromLTRB(MARGIN, MARGIN, MARGIN, MARGIN / 2.0),
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(4.0),
      gradient: const LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [
            Color(0xFF56AF6D),
            Color(0xFF56AA6D),
          ]),
    ),
    child: Center(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: buttons
            .map((item) => _getMenuItem(item['icon'] ?? "", item['name'] ?? "",
                color: Colors.white))
            .toList(),
      ),
    ),
  );
}

/// 菜单布局
Widget _dynamicGridButtons(List<Map<String, String>> buttons, String title,
    {int crossAxisCount = 4}) {
  return Container(
    margin: const EdgeInsets.fromLTRB(MARGIN, MARGIN, MARGIN, MARGIN / 2),
    padding: const EdgeInsets.all(MARGIN),
    decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(4.0), color: Colors.white),
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(title, style: TextStyle(color: Colors.grey[700])),
        SizedBox(height: 20),
        _gridButtons(buttons, crossAxisCount, textColor: Colors.black)
      ],
    ),
  );
}

Widget _gridButtons(List<Map<String, String>> buttons, int crossAxisCount,
    {Color textColor = Colors.white}) {
  double gridSpace = 5.0;
  return GridView.count(
    crossAxisSpacing: gridSpace,
    mainAxisSpacing: gridSpace,
    crossAxisCount: crossAxisCount,
    shrinkWrap: true,
    physics: const NeverScrollableScrollPhysics(),
    children: buttons
        .map((item) => _getMenuItem(item['icon'] ?? "", item['name'] ?? "",
            color: textColor))
        .toList(),
  );
}

模拟数据:

class GridMockData {
  static List<Map<String, String>> headerGrids() {
    return [
      {'name': '收付款', 'icon': 'images/grid-buttons/grid-3-1.png'},
      {'name': '钱包', 'icon': 'images/grid-buttons/grid-3-2.png'},
    ];
  }
  static List<Map<String, String>> financeGrids() {
    return [
      {'name': '信用卡还款', 'icon': 'images/grid-buttons/grid-1-1.png'},
      {'name': '借钱', 'icon': 'images/grid-buttons/grid-1-2.png'},
      {'name': '理财', 'icon': 'images/grid-buttons/grid-1-3.png'},
      {'name': '保险', 'icon': 'images/grid-buttons/grid-1-4.png'},
    ];
  }
  static List<Map<String, String>> serviceGrids() {
    return [
      {'name': '手机充值', 'icon': 'images/grid-buttons/grid-2-1.png'},
      {'name': '生活缴费', 'icon': 'images/grid-buttons/grid-2-2.png'},
      {'name': '充值', 'icon': 'images/grid-buttons/grid-2-3.png'},
      {'name': '城市服务', 'icon': 'images/grid-buttons/grid-2-4.png'},
      {'name': '公益', 'icon': 'images/grid-buttons/grid-4-1.png'},
      {'name': '医疗', 'icon': 'images/grid-buttons/grid-4-2.png'},
      {'name': '健康码', 'icon': 'images/grid-buttons/grid-4-3.png'},
    ];
  }
  static List<Map<String, String>> thirdpartyGrids() {
    return [
      {'name': '某东购物', 'icon': 'images/grid-buttons/grid-5-1.png'},
      {'name': '某团外卖', 'icon': 'images/grid-buttons/grid-5-2.png'},
      {'name': '电影票务', 'icon': 'images/grid-buttons/grid-5-3.png'},
      {'name': '某团团购', 'icon': 'images/grid-buttons/grid-5-4.png'},
      {'name': '拼夕夕', 'icon': 'images/grid-buttons/grid-5-5.png'},
      {'name': '女装', 'icon': 'images/grid-buttons/grid-5-6.png'},
      {'name': '某特卖', 'icon': 'images/grid-buttons/grid-5-7.png'},
      {'name': '某二手', 'icon': 'images/grid-buttons/grid-5-8.png'},
      {'name': '找房', 'icon': 'images/grid-buttons/grid-5-9.png'},
    ];
  }
}

效果:
l6zgt

PageView

用于实现页面切换、Tab 布局。

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,
  // 主要是配合辅助功能用的,设置为true和ViewPager一样缓存前后各一页
  this.allowImplicitScrolling = false,
  //  
  this.padEnds = true,
})

示例:

Widget demoWidget() {
  return PageView(
    scrollDirection: Axis.horizontal,
    allowImplicitScrolling: true,
    children: const <Widget>[
      Page(text: 'A'),
      Page(text: 'B'),
      Page(text: 'C'),
    ],
  );
}


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

  final String text;

  @override
  _PageState createState() => _PageState();
}

class _PageState extends State<Page> {
  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(
        child: Container(
      decoration: const BoxDecoration(color: Colors.green),
      child: Text(widget.text, textScaleFactor: 5),
    ));
  }
}

pdnld

可滚动子组件缓存

AutomaticKeepAlive

AutomaticKeepAlive 介绍

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

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

AutomaticKeepAlive 缓存示例

class AutomaticKeepAliveDemo extends StatelessWidget {
  const AutomaticKeepAliveDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text("AutomaticKeepAlive Demo"),
        ),
        body: Container(
          decoration: const BoxDecoration(
            color: Colors.transparent,
          ),
          child: demoWidget(),
        ),
      ),
    );
  }

  Widget demoWidget() {
    return PageView(
      scrollDirection: Axis.horizontal,
      allowImplicitScrolling: true,
      children: const <Widget>[
        Page(text: 'A'),
        Page(text: 'B'),
        Page(text: 'C'),
        Page(text: 'D'),
        Page(text: 'E'),
      ],
    );
  }
}

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

  final String text;

  @override
  _PageState createState() => _PageState();
}

class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    print("AutomaticKeepAliveClientMixin build ${widget.text}");
    return Center(
        child: Container(
      decoration: const BoxDecoration(color: Colors.green),
      child: Text(widget.text, textScaleFactor: 5),
    ));
  }

  @override
  bool get wantKeepAlive => true; // 是否需要缓存 保持状态
}

KeepAliveWrapper

复用带 cache 的 list
示例:

class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper({
    Key? key,
    this.keepAlive = true,
    required this.child,
  }) : super(key: key);
  final bool keepAlive;
  final Widget child;

  @override
  _KeepAliveWrapperState createState() => _KeepAliveWrapperState();
}

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

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

  @override
  bool get wantKeepAlive => widget.keepAlive;
}

TabBarView & TabBar

TabBarView

TabBarView 是 Material 组件库中提供了 Tab 布局组件,通常和 TabBar 配合使用。

 TabBarView({
  Key? key,
  required this.children, // tab 页
  this.controller, // TabController
  this.physics,
  this.dragStartBehavior = DragStartBehavior.start,
})

TabController 用于监听和控制 TabBarView 的页面切换,通常和 TabBar 联动。如果没有指定,则会在组件树中向上查找并使用最近的一个 DefaultTabController
TabBarView 的页面缓存参考 PageView

TabBar

TabBar 为 TabBarView 的导航标题。
TabBar 有很多配置参数,通过这些参数我们可以定义 TabBar 的样式,很多属性都是在配置 indicator 和 label

const TabBar({
  Key? key,
  required this.tabs, // 具体的 Tabs,需要我们创建
  this.controller,
  this.isScrollable = false, // 是否可以滑动
  this.padding,
  this.indicatorColor,// 指示器颜色,默认是高度为2的一条下划线
  this.automaticIndicatorColorAdjustment = true,
  this.indicatorWeight = 2.0,// 指示器高度
  this.indicatorPadding = EdgeInsets.zero, //指示器padding
  this.indicator, // 指示器
  this.indicatorSize, // 指示器长度,有两个可选值,一个tab的长度,一个是label长度
  this.labelColor, 
  this.labelStyle,
  this.labelPadding,
  this.unselectedLabelColor,
  this.unselectedLabelStyle,
  this.mouseCursor,
  this.onTap,
  // ...
})

TabBar 通常位于 AppBar 的底部,它也可以接收一个 TabController ,如果需要和 TabBarView 联动, TabBar 和 TabBarView 使用同一个 TabController 即可,注意,联动时 TabBar 和 TabBarView 的孩子数量需要一致。如果没有指定 controller,则会在组件树中向上查找并使用最近的一个 DefaultTabController 。另外我们需要创建需要的 tab 并通过 tabs 传给 TabBar, tab 可以是任何 Widget,不过 Material 组件库中已经实现了一个 Tab 组件,我们一般都会直接使用它:

const Tab({
  Key? key,
  this.text, //文本
  this.icon, // 图标
  this.iconMargin = const EdgeInsets.only(bottom: 10.0),
  this.height,
  this.child, // 自定义 widget
})

注意,text 和 child 是互斥的,不能同时制定。

案例

class TabBarDemo extends StatelessWidget {
  const TabBarDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const TabViewRoute1(),
    );
  }
}

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

  @override
  _TabViewRoute1State createState() => _TabViewRoute1State();
}

class _TabViewRoute1State extends State<TabViewRoute1>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;
  List tabs = ["新闻", "历史", "图片"];

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: tabs.length, vsync: this);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("TabBar Demo"),
        bottom: TabBar(
          controller: _tabController,
          tabs: tabs.map((e) => Tab(text: e)).toList(),
        ),
      ),
      body: TabBarView(
        //构建
        controller: _tabController,
        children: tabs.map((e) {
          return KeepAliveWrapper(
            child: Container(
              alignment: Alignment.center,
              child: Text(e, textScaleFactor: 5),
            ),
          );
        }).toList(),
      ),
    );
  }

  @override
  void dispose() {
    // 释放资源
    _tabController.dispose();
    super.dispose();
  }
}

class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper({
    Key? key,
    this.keepAlive = true,
    required this.child,
  }) : super(key: key);
  final bool keepAlive;
  final Widget child;

  @override
  _KeepAliveWrapperState createState() => _KeepAliveWrapperState();
}

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

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

  @override
  bool get wantKeepAlive => widget.keepAlive;
}

er4fw

class TabViewRoute2 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    List tabs = ["新闻", "历史", "图片"];
    return DefaultTabController(
      length: tabs.length,
      child: Scaffold(
        appBar: AppBar(
          title: Text("App Name"),
          bottom: TabBar(
            tabs: tabs.map((e) => Tab(text: e)).toList(),
          ),
        ),
        body: TabBarView( //构建
          children: tabs.map((e) {
            return KeepAliveWrapper(
              child: Container(
                alignment: Alignment.center,
                child: Text(e, textScaleFactor: 5),
              ),
            );
          }).toList(),
        ),
      ),
    );
  }
}

可以看到我们无需去手动管理 Controller 的生命周期,也不需要提供 SingleTickerProviderStateMixin,同时也没有其他的状态需要管理,也就不需要用 StatefulWidget 了

CustomScrollView

CustomScrollView 概述

CustomScrollView 的主要功能是提供一个公共的 Scrollable 和 Viewport,来组合多个 Sliver,CustomScrollView:
hpoun

ListView、GridView、PageView 都是一个完整的可滚动组件,所谓完整是指它们都包括 Scrollable 、 Viewport 和 Sliver。
Flutter 提供了一个 CustomScrollView 组件来帮助我们创建一个公共的 Scrollable 和 Viewport ,然后它的 slivers 参数接受一个 Sliver 数组。

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

CustomScrollView 常用属性

常用的 Sliver

可滚动组件都有对应的 Sliver:

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

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

Sliver 名称 对应 RenderBox
SliverPadding Padding
SliverVisibility、SliverOpacity Visibility、Opacity
SliverFadeTransition FadeTransition
SliverLayoutBuilder LayoutBuilder

还有一些其他常用的 Sliver:

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

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

SliverAppBar

SliverAppBar 对应 AppBar,两者不同之处在于 SliverAppBar 可以集成到 CustomScrollView。SliverAppBar 可以结合 FlexibleSpaceBar 实现 Material Design 中头部伸缩的模型;但有些其他的属性:

SliverAppBar _getAppBar(String title) {
  return SliverAppBar(
    pinned: true,
    snap: true,
    floating: true,
    expandedHeight: 200,
    flexibleSpace: FlexibleSpaceBar(
      title: Text(title),
      background: Image.network(
        imageUrl,
        fit: BoxFit.cover,
      ),
    ),
  );
}

SliverPersistentHeader

SliverPersistentHeader 的功能是当滑动到 CustomScrollView 的顶部时,可以将组件固定在顶部。
需要注意, Flutter 中设计 SliverPersistentHeader 组件的初衷是为了实现 SliverAppBar,所以它的一些属性和回调在 SliverAppBar 中才会用到。

const SliverPersistentHeader({
  Key? key,
  // 构造 header 组件的委托
  required SliverPersistentHeaderDelegate delegate,
  this.pinned = false, // header 滑动到可视区域顶部时是否固定在顶部
  this.floating = false, // 
})
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;

}

需要关注的就是 maxExtentminExtent;pined 为 true 时,当 header 刚刚固定到顶部,此时会对它应用 maxExtent (最大高度);当用户继续往上滑动时,header 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtent。如果我们想让 header 高度固定,则将 maxExtent 和 minExtent 指定为同样的值即可。
封装一个通用的委托构造器 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;

  @override
  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);
  }

  @override
  double get maxExtent => maxHeight;

  @override
  double get minExtent => minHeight;

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

使用:

class PersistentHeaderRoute extends StatelessWidget {
  @override
  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"),
    );
  }
}

n1icx
注意点
SliverPersistentHeader 的 builder 参数 overlapsContent 一般不建议使用,使用时要当心。因为按照 overlapsContent 变量名的字面意思,只要有内容和 Sliver 重叠时就应该为 true,但是如果我们在上面示例的 builder 中打印一下 overlapsContent 的值就会发现第一个 PersistentHeader 1 的 overlapsContent 值一直都是 false,而 PersistentHeader 2 则是正常的,如果我们再添加几个 SliverPersistentHeader ,发现新添加的也都正常。总结一下:当有多个 SliverPersistentHeader 时,需要注意第一个 SliverPersistentHeader 的 overlapsContent 值会一直为 false。
这可能是一个 bug,也可能就是这么设计的,因为 SliverPersistentHeader 的设计初衷主要是为了实现 SliverAppBar,可能并没有考虑到通用的场景,但是不管怎样,flutter 2.5 版本中表现就是如此。为此,我们可以定一条约定:如果我们在使用 SliverPersistentHeader 构建子组件时需要依赖 overlapsContent 参数,则必须保证之前至少还有一个 SliverPersistentHeader 或 SliverAppBar(SliverAppBar 在当前 Flutter 版本的实现中内部包含了 SliverPersistentHeader)。

SliverToBoxAdapter

在实际布局中,我们通常需要往 CustomScrollView 中添加一些自定义的组件,而这些组件并非都有 Sliver 版本,为此 Flutter 提供了一个 SliverToBoxAdapter 组件,它是一个适配器:可以将 RenderBox 适配为 Sliver。
比如我们想在列表顶部添加一个可以横向滑动的 PageView,可以使用 SliverToBoxAdapter 来配置:

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(
      child: SizedBox(
        height: 300,
        child: PageView(
          children: [Text("1"), Text("2")],
        ),
      ),
    ),
    buildSliverFixedList(),
  ],
);

注意,上面的代码是可以正常运行的,但是如果将 PageView 换成一个滑动方向和 CustomScrollView 一致的 ListView 则不会正常工作!原因是:CustomScrollView 组合 Sliver 的原理是为所有子 Sliver 提供一个共享的 Scrollable,然后统一处理指定滑动方向的滑动事件,如果 Sliver 中引入了其他的 Scrollable,则滑动事件便会冲突。上例中 PageView 之所以能正常工作,是因为 PageView 的 Scrollable 只处理水平方向的滑动,而 CustomScrollView 是处理垂直方向的,两者并未冲突,所以不会有问题,但是换一个也是垂直方向的 ListView 时则不能正常工作,最终的效果是,在 ListView 内滑动时只会对 ListView 起作用,原因是滑动事件被 ListView 的 Scrollable 优先消费,CustomScrollView 的 Scrollable 便接收不到滑动事件了。

CustomScrollView 示例

class WxPage2 extends StatelessWidget {
  const WxPage2({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: CustomScrollView(
        slivers: [
          _getAppBar('个人中心'),
          _headerGridButtons(),
          _getMenuTitle('金融理财'),
          _gridButtons(GridMockData.financeGrids()),
          _getMenuTitle('生活服务'),
          _gridButtons(GridMockData.serviceGrids()),
          _getMenuTitle('购物消费'),
          _gridButtons(GridMockData.thirdpartyGrids()),
        ],
      ),
    );
  }
}

const String imageUrl =
    'https://t7.baidu.com/it/u=1595072465,3644073269&fm=193&f=GIF';

SliverAppBar _getAppBar(String title) {
  return SliverAppBar(
    pinned: true,
    snap: true,
    floating: true,
    expandedHeight: 200,
    flexibleSpace: FlexibleSpaceBar(
      title: Text(title),
      background: Image.network(
        imageUrl,
        fit: BoxFit.cover,
      ),
    ),
  );
}

SliverGrid _gridButtons(List<Map<String, String>> buttons,
    {int crossAxisCount = 4, Color textColor = Colors.black}) {
  double gridSpace = 5.0;
  return SliverGrid.count(
    crossAxisCount: crossAxisCount,
    mainAxisSpacing: gridSpace,
    crossAxisSpacing: gridSpace,
    children: buttons.map((item) {
      return _getMenus(item['icon'] ?? "", item['name'] ?? "",
          color: textColor);
    }).toList(),
  );
}

Column _getMenus(String icon, String name, {Color color = Colors.black}) {
  return Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
      SizedBox(
        width: 50,
        height: 50,
        child: Image.asset(icon),
      ),
      const SizedBox(
        height: 5,
      ),
      Text(name, style: TextStyle(fontSize: 14.0, color: color, height: 1)),
    ],
  );
}

Widget _getMenuTitle(String title) {
  return SliverToBoxAdapter(
    child: Container(
      margin: const EdgeInsets.fromLTRB(margin, margin, margin, margin / 2.0),
      padding: const EdgeInsets.all(margin),
      decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(4.0), color: Colors.white),
      child: Text(
        title,
        style: TextStyle(color: Colors.grey[700]),
      ),
    ),
  );
}

/// 菜单项Widget
Column _getMenuItem(String icon, String name, {Color color = Colors.black}) {
  return Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      SizedBox(
        width: 50,
        height: 50,
        child: Image.asset(icon),
      ),
      const SizedBox(
        height: 5,
      ),
      Text(
        name,
        style: TextStyle(fontSize: 14.0, color: color, height: 2),
      )
    ],
  );
}

/// 顶部两个按钮
const double margin = 10;

Widget _headerGridButtons() {
  double height = 144;
  List<Map<String, String>> buttons = GridMockData.headerGrids();
  return SliverToBoxAdapter(
    child: Container(
      height: height,
      margin: const EdgeInsets.fromLTRB(margin, margin, margin, margin / 2.0),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(4.0),
        gradient: const LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Color(0xFF56AF6D),
              Color(0xFF56AA6D),
            ]),
      ),
      child: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: buttons
              .map((item) => _getMenuItem(
                  item['icon'] ?? "", item['name'] ?? "",
                  color: Colors.white))
              .toList(),
        ),
      ),
    ),
  );
}

效果:

ogdg6# 自定义 Sliver

NestedScrollView 嵌套可滚动组件

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

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

ExpansionTitle?