> 文章列表 > Flutter(六)可滚动组件

Flutter(六)可滚动组件

Flutter(六)可滚动组件

目录

  • 1.可滚动组件简介
    • Sliver布局模型
    • Scrollable
    • Viewport
    • Sliver
    • 可滚动组件的通用配置
  • 2.SingleChildScrollView
  • 3.ListView
    • 默认构造函数
    • ListView.builder
    • ListView.separated
    • 固定高度列表
    • ListView 原理
    • 无限加载列表,分页
      • 添加Header
  • 4.滚动监听及控制
    • ScrollController
      • 滚动位置恢复PageStorage
      • ScrollPosition
      • ScrollController控制原理
    • 滚动监听NotificationListener
  • 5.AnimatedList
  • 6.GridView
    • 默认构造函数
      • SliverGridDelegateWithFixedCrossAxisCount
      • SliverGridDelegateWithMaxCrossAxisExtent
    • GridView.count
    • GridView.extent
    • GridView.builder
  • 7.PageView与页面缓存
    • 页面缓存
  • 8.可滚动组件子项缓存
  • 9.TabBarView
    • TabBarView
    • TabBar
  • 10.CustomScrollView 和 Slivers
    • Flutter 中常用的 Sliver
      • SliverToBoxAdapter
      • SliverPersistentHeader
  • 11.自定义 Sliver
  • 12.嵌套可滚动组件NestedScrollView
    • NestedScrollView 原理
    • SliverAppBar
    • 嵌套 TabBarView

1.可滚动组件简介

Sliver布局模型

Flutter 中的可滚动主要由三个角色组成:Scrollable、Viewport 和 Sliver:

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

具体布局过程:

Scrollable 监听到滑动后,根据滑动偏移构建 Viewport ,Viewport 将当前视图信息和配置信息通过 SliverConstraints 传递给 Sliver,Sliver 中对子组件按需进行构建和布局。
Flutter(六)可滚动组件
顶部和底部灰色的区域为 cacheExtent,cacheExtent 的默认值是 250,在构建可滚动列表时我们可以指定这个值,这个值最终会传给 Viewport。

它表示预渲染的高度,需要注意这是在可视区域之外,如果 RenderBox 进入这个区域内,即使它还未显示在屏幕上,也是要先进行构建的,预渲染是为了后面进入 Viewport 的时候更丝滑

Scrollable

Scrollable({...this.axisDirection = AxisDirection.down,//滚动方向this.controller,//控制滚动位置和监听滚动事件this.physics,//滚动组件如何响应用户操作required this.viewportBuilder, //构建 Viewport 的回调。
})

physics可以显式指定一个固定的ScrollPhysics,Flutter SDK中包含了两个ScrollPhysics的子类,他们可以直接使用:

  • ClampingScrollPhysics:列表滑动到边界时将不能继续滑动,通常在Android 中 配合 GlowingOverscrollIndicator(实现微光效果的组件) 使用。
  • BouncingScrollPhysics:iOS 下弹性效果。

controller默认PrimaryScrollController,父组件可以控制子滚动组件的滚动行为

viewportBuilder用于构建 Viewport 的回调。当用户滑动时,Scrollable 会调用此回调构建新的 Viewport,Viewport 变化时对应的 RenderViewport 会更新信息,不会随着 Widget 进行重新构建。

在可滚动组件的坐标描述中,通常将滚动方向称为主轴,非滚动方向称为纵轴

Viewport

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

cacheExtent 和 cacheExtentStyle:CacheExtentStyle 是一个枚举,有 pixel 和 viewport 两个取值。

当 cacheExtentStyle 值为 pixel 时,cacheExtent 为预渲染区域的具体像素长度
当值为 viewport 时,cacheExtent 的值是一个乘数,表示有几个 viewport 的长度,最终的预渲染区域的像素长度为:cacheExtent * viewport 的积,

这在每一个列表项都占满整个 Viewport 时比较实用,这时 cacheExtent 的值就表示前后各缓存几个页面。

Sliver

Sliver 主要作用是对子组件进行构建和布局

可滚动组件的通用配置

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

可滚动组件都有一个 controller 属性,通过该属性我们可以指定一个 ScrollController 来控制可滚动组件的滚动

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

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

CupertinoScrollbar是 iOS 风格的滚动条,如果你使用的是Scrollbar,那么在iOS平台它会自动切换为CupertinoScrollbar

2.SingleChildScrollView

//只能接收一个子组件
SingleChildScrollView({this.scrollDirection = Axis.vertical, //滚动方向,默认是垂直方向this.reverse = false, this.padding, bool primary, this.physics, this.controller,this.child,
})

SingleChildScrollView在不会超过屏幕太多时使用
因为SingleChildScrollView不支持基于 Sliver 的延迟加载模型会导致性能差,
超出屏幕太多应该使用一些支持Sliver延迟加载的可滚动组件,如ListView

3.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>[],
})
  • itemExtent
    在ListView中,指定itemExtent比让子组件自己决定自身长度会有更好的性能,指定itemExtent后,滚动系统可以提前知道列表的长度,而无需每次构建子组件时都去再计算一下,尤其是在滚动位置频繁变化时(滚动系统需要频繁去计算列表高度)。
  • prototypeItem
    指定 prototypeItem 后,会在 layout 时计算一次长度,这样也就预先知道了所有列表项的长度,
    所以和指定 itemExtent 一样,指定 prototypeItem 会有更好的性能。注意,itemExtent 和prototypeItem 互斥,不能同时指定它们。
  • shrinkWrap:
    是否根据子组件的总长度来设置ListView的长度,默认值为false 。默认ListView会在滚动方向尽可能多的占用空间

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

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'),],
);

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.0itemBuilder: (BuildContext context, int index) {return ListTitle(title: Text("$index"));}
);

ListView.separated

ListView.separated可以在生成的列表项之间添加一个分割组件,它比ListView.builder多了一个separatorBuilder参数,该参数是一个分割组件生成器
实例:奇数行添加一条蓝色下划线,偶数行添加一条绿色下划线。

class ListView3 extends StatelessWidget {@overrideWidget 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;},);}
}

Flutter(六)可滚动组件

固定高度列表

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

ListView 原理

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

  • ListView 中的列表项组件都是 RenderBox,并不是 Sliver
  • 一个 ListView只有一个Sliver,列表项按需加载是 Sliver 中实现的。
  • ListView 的 Sliver 默认是 SliverList
    指定了 itemExtent ,会使用 SliverFixedExtentList
    指定了prototypeItem,会使用 SliverPrototypeExtentList
    无论哪个,都实现了子组件的按需加载模型。

无限加载列表,分页

记载更多时显示一个loading,成功后将数据插入列表;
没有更多,则提示"没有更多"。代码如下:

import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';
import 'package:flutter/rendering.dart';class InfiniteListView extends StatefulWidget {@override_InfiniteListViewState createState() => _InfiniteListViewState();
}class _InfiniteListViewState extends State<InfiniteListView> {static const loadingTag = "##loading##"; //表尾标记var _words = <String>[loadingTag];@overridevoid initState() {super.initState();_retrieveData();}@overrideWidget build(BuildContext context) {return ListView.separated(itemCount: _words.length,itemBuilder: (context, index) {//如果到了表尾if (_words[index] == loadingTag) {//不足100条,继续获取数据if (_words.length - 1 < 100) {//获取数据_retrieveData();//加载时显示loadingreturn Container(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,//每次生成20个单词generateWordPairs().take(20).map((e) => e.asPascalCase).toList(),);});});}
}

Flutter(六)可滚动组件

添加Header

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

Flutter(六)可滚动组件

Flex是弹性布局,Column是继承自Flex的,加Expanded自动拉伸组件大小,所以使用Column + Expanded来实现

4.滚动监听及控制

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

jumpTo(double offset)、animateTo(double offset,…):这两个方法用于跳转到指定的位置,后者在跳转时会执行一个动画,而前者不会

ScrollController

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

实例
滚动时打印出当前滚动位置,
如果超过1000像素,显示“返回顶部”的按钮,
如果没有超过1000像素,则隐藏“返回顶部”按钮。
按钮点击使ListView恢复到初始位置;

class ScrollControllerTestRoute extends StatefulWidget {@overrideScrollControllerTestRouteState createState() {return ScrollControllerTestRouteState();}
}class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {ScrollController _controller = ScrollController();bool showToTopBtn = false; //是否显示“返回到顶部”按钮@overridevoid 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;});}});}@overridevoid dispose() {//为了避免内存泄露,需要调用_controller.dispose_controller.dispose();super.dispose();}@overrideWidget 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,);}),);}
}

Flutter(六)可滚动组件
item高度为 50 像素,当滑动到第 20 个时 “返回顶部” 按钮显示,
点击后ListView 会在返回顶部并执行一个滚动动画,动画时间是 200 毫秒,动画曲线是 Curves.ease

滚动位置恢复PageStorage

PageStorage是一个用于保存页面(路由)相关数据的组件,每次滚动结束,可滚动组件都会将滚动位置offset存储到PageStorage中,
ScrollController.keepScrollOffset为false,则滚动位置将不会被存储ScrollController.keepScrollOffset为true时,第一次创建时会滚动到initialScrollOffset处,这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而initialScrollOffset会被忽略

一个路由中包含多个可滚动组件时,可指定PageStorageKey保存不同滚动位置,但也不一定

只有当Widget发生结构变化,导致可滚动组件的State销毁或重新构建时才会丢失状态,这种情况就需要显式指定PageStorageKey,通过PageStorage来存储滚动位置,

一个典型的场景是在使用TabBarView时,在Tab发生切换时,Tab页中的可滚动组件的State就会销毁,这时如果想恢复滚动位置就需要指定PageStorageKey

ScrollPosition

ScrollPosition是用来保存可滚动组件的滚动位置,ScrollController会为每一个可滚动组件创建一个ScrollPosition对象

一个ScrollController同时被两个可滚动组件使用,那么我们可以通过如下方式分别读取他们的滚动位置

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

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

ScrollPosition有两个常用方法:animateTo() 和 jumpTo(),它们是真正来控制跳转滚动位置的方法

ScrollController控制原理

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

当ScrollController和可滚动组件关联时,可滚动组件

1.首先会调用ScrollController的createScrollPosition()方法来创建一个ScrollPosition来存储滚动位置信息,

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

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

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

滚动监听NotificationListener

NotificationListener和ScrollController的不同

  • NotificationListener可以在可滚动组件到widget树根之间任意位置监听。
    而ScrollController只能和具体的可滚动组件关联后才可以。

  • 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置

实例:滚动显示百分比

import 'package:flutter/material.dart';class ScrollNotificationTestRoute extends StatefulWidget {@override_ScrollNotificationTestRouteState createState() =>_ScrollNotificationTestRouteState();
}class _ScrollNotificationTestRouteStateextends State<ScrollNotificationTestRoute> {String _progress = "0%"; //保存进度百分比@overrideWidget 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}");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,child: Text(_progress),backgroundColor: Colors.black54,)],),),);}
}

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

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

5.AnimatedList

插入或删除有动画的ListView
在这里插入图片描述

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>();@overridevoid initState() {for (var i = 0; i < counter; i++) {data.add('${i + 1}');}super.initState();}@overrideWidget 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(child: FloatingActionButton(child: Icon(Icons.add),onPressed: () {// 添加一个列表项data.add('${++counter}');// 告诉列表项有新添加的列表项globalKey.currentState!.insertItem(data.length - 1);print('添加 $counter');},),bottom: 30,left: 0,right: 0,);}// 构建列表项Widget buildItem(context, index) {String char = data[index];return ListTile(//数字不会重复,所以作为Keykey: ValueKey(char),title: Text(char),trailing: IconButton(icon: Icon(Icons.delete),// 点击时删除onPressed: () => onDelete(context, index),),);}void onDelete(context, index) {//通过AnimatedListState 的 removeItem 方法来应用删除动画}
}

onDelete

setState(() {globalKey.currentState!.removeItem(index,(context, animation) {// 删除过程执行的是反向动画,animation.value 会从1变为0var 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: Duration(milliseconds: 200), // 动画时间为 200 ms);
});

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

gridDelegate参数,
类型是SliverGridDelegate,它的作用是控制GridView子组件如何排列(layout)。

Flutter中提供了两个SliverGridDelegate的子类SliverGridDelegateWithFixedCrossAxisCount(横轴为固定数量)和SliverGridDelegateWithMaxCrossAxisExtent(横轴子元素为固定最大长度)

SliverGridDelegateWithFixedCrossAxisCount

SliverGridDelegateWithFixedCrossAxisCount({@required double crossAxisCount, //横轴子元素的数量double mainAxisSpacing = 0.0,//主轴方向的间距double crossAxisSpacing = 0.0,//横轴方向子元素的间距double childAspectRatio = 1.0,//子元素在横轴长度和主轴长度的比例
})

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

GridView(gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, //横轴三个子widgetchildAspectRatio: 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)]
);

Flutter(六)可滚动组件

SliverGridDelegateWithMaxCrossAxisExtent

SliverGridDelegateWithMaxCrossAxisExtent({double maxCrossAxisExtent,//子元素在横轴上的最大长度double mainAxisSpacing = 0.0,double crossAxisSpacing = 0.0,double childAspectRatio = 1.0,
})

如果ViewPort的横轴长度是450,那么当maxCrossAxisExtent的值在区间[450/4,450/3)内的话,子元素最终实际长度都为112.5

实例

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),],
);

Flutter(六)可滚动组件

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

当子widget比较多时,我们可以通过GridView.builder来动态创建子widget。GridView.builder 必须指定的参数有两个

GridView.builder(...required SliverGridDelegate gridDelegate, required IndexedWidgetBuilder itemBuilder,//子widget构建器
)

实例:从一个异步数据源(如网络)分批获取一些Icon,然后用GridView来展示

class InfiniteGridView extends StatefulWidget {@override_InfiniteGridViewState createState() => _InfiniteGridViewState();
}class _InfiniteGridViewState extends State<InfiniteGridView> {List<IconData> _icons = []; //保存Icon数据@overridevoid initState() {super.initState();// 初始化数据_retrieveIcons();}@overrideWidget 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,]);});});}
}
  • _retrieveIcons():此方法中通过Future.delayed来模拟从异步数据源获取数据,每次获取数据需要200毫秒,获取成功后将新数据添加到_icons,然后调用setState重新构建。

  • 在 itemBuilder 中,如果显示到最后一个时,判断是否需要继续获取数据,然后返回一个Icon

7.PageView与页面缓存

图片轮动以及抖音上下滑页切换视频功能,这些都可以通过 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 页面 
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> {@overrideWidget build(BuildContext context) {print("build ${widget.text}");return Center(child: Text("${widget.text}", textScaleFactor: 5));}
}
@override
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,);
}

在这里插入图片描述

页面缓存

默认每当页面切换时都会触发新 Page 页的 build,没有缓存,一旦页面滑出屏幕它就会被销毁

allowImplicitScrolling 置为 true 时就只会缓存前后各一页,所以滑到第三页时,第一页就会销毁。

PageView为什么没有cacheExtent 参数?
发现PageView 中设置 cacheExtent 会和 iOS 中 辅助功能有冲突(读者可以先不用关注),所以暂时还没有什么好的办法。看到这可能国内的很多开发者要说我们的 App 不用考虑辅助功能,既然如此,那问题很好解决,将 PageView 的源码拷贝一份,然后透传 cacheExtent 即可。

拷源码的方式虽然很简单,但毕竟不是正统做法,可以使用KeepAliveWrapper

8.可滚动组件子项缓存

AutomaticKeepAlive 的组件,
keepAlive为false,item滑出加载区域,item会被销毁。
keepAlive为 true,item滑出加载区域,Viewport 会将item缓存起来,当item再次进入加载区域时,如果缓存有直接复用,没有就重新创建。

flukit 组件库中的KeepAliveWrapper
一个Page组件需要同时在列表中和列表外使用,为了在列表中缓存它,则我们必须实现两份。为了解决这个问题,可以使用KeepAliveWrapper
如果哪个列表项需要缓存,只需要使用 KeepAliveWrapper 包裹一下它即可。
实例: ListView 中使用KeepAliveWrapper

class KeepAliveTest extends StatelessWidget {const KeepAliveTest({Key? key}) : super(key: key);@overrideWidget 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;@override_ListItemState createState() => _ListItemState();
}class _ListItemState extends State<ListItem> {@overrideWidget build(BuildContext context) {return ListTile(title: Text('${widget.index}'));}@overridevoid dispose() {//keepAlive 设为 false,日志面板将有输出print('dispose ${widget.index}');super.dispose();}
}

9.TabBarView

TabBarView

TabBarView 封装了 PageView

TabBarView({Key? key,required this.children, // tab 页//TabController 用于监听和控制 TabBarView 的页面切换,//通常和 TabBar 联动。如果没有指定,则会在组件树中向上查找并使用最近的一个 DefaultTabControllerthis.controller, // TabControllerthis.physics,this.dragStartBehavior = DragStartBehavior.start,
}) 

TabBar

Flutter(六)可滚动组件

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, //指示器paddingthis.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 即可

另外我们需要创建需要的 tab 并通过 tabs 传给 TabBar, tab 可以是任何 Widget

//text 和 child 是互斥的,不能同时制定。
const Tab({Key? key,this.text, //文本this.icon, // 图标this.iconMargin = const EdgeInsets.only(bottom: 10.0),this.height,this.child, // 自定义 widget
})

实例:底部tab切换

class TabViewRoute1 extends StatefulWidget {@override_TabViewRoute1State createState() => _TabViewRoute1State();
}class _TabViewRoute1State extends State<TabViewRoute1>with SingleTickerProviderStateMixin {late TabController _tabController;List tabs = ["新闻", "历史", "图片"];@overridevoid initState() {super.initState();_tabController = TabController(length: tabs.length, vsync: this);}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text("App Name"),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(),),);}@overridevoid dispose() {//由于 TabController 中会执行动画,持有一些资源,所以我们在页面销毁时必须得释放资源(dispose)// 释放资源_tabController.dispose();super.dispose();}
}

Flutter(六)可滚动组件
创建 TabController 的过程还是比较复杂,也可以使用系统DefaultTabController 简单实现

class TabViewRoute2 extends StatelessWidget {@overrideWidget 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 了,这样简单很多

因为TabBarView 内部封装了 PageView,如果要缓存页面,和PageView同样处理,使用 KeepAliveWrapper 包裹一下它即可。

10.CustomScrollView 和 Slivers

需求:scrollview嵌套两个listview滑动
方案:创建共用的 Scrollable 和 Viewport 对象,然后再将两个 ListView 对应的 Sliver 添加到这个共用的 Viewport 对象中就可以实现。

Flutter 提供了一个 CustomScrollView 组件来帮助我们创建一个公共的 Scrollable 和 Viewport ,然后它的 slivers 参数接受一个 Sliver 数组

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 的主要功能是提供一个公共的的 Scrollable 和 Viewport,来组合多个 Sliver

Flutter(六)可滚动组件

Flutter 中常用的 Sliver

Flutter(六)可滚动组件
还有一些用于对 Sliver 进行布局、装饰的组件,它们的子组件必须是 Sliver,我们列举几个常用的:Flutter(六)可滚动组件
还有一些其他常用的 Sliver:
Flutter(六)可滚动组件
CustomScrollView的子组件必须都是Sliver

实例:

// 因为本路由没有使用 Scaffold,为了让子级Widget(如Text)使用
// Material Design 默认的样式风格,我们使用 Material 作为本路由的根。
Material(child: CustomScrollView(slivers: <Widget>[// AppBar,包含一个导航栏.SliverAppBar(pinned: true, // 滑动到顶端时会固定住expandedHeight: 250.0,flexibleSpace: FlexibleSpaceBar(title: const Text('Demo'),background: Image.asset("./imgs/sea.png",fit: BoxFit.cover,),),),SliverPadding(padding: const EdgeInsets.all(8.0),sliver: SliverGrid(//GridgridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2, //Grid按两列显示mainAxisSpacing: 10.0,crossAxisSpacing: 10.0,childAspectRatio: 4.0,),delegate: SliverChildBuilderDelegate((BuildContext context, int index) {//创建子widgetreturn Container(alignment: Alignment.center,color: Colors.cyan[100 * (index % 9)],child: Text('grid item $index'),);},childCount: 20,),),),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,),),],),
);

头部SliverAppBar:SliverAppBar对应AppBar,两者不同之处在于SliverAppBar可以集成到CustomScrollView。SliverAppBar可以结合FlexibleSpaceBar实现Material Design中头部伸缩的模型,具体效果,读者可以运行该示例查看。
中间的SliverGrid:它用SliverPadding包裹以给SliverGrid添加补白。SliverGrid是一个两列,宽高比为4的网格,它有20个子组件。
底部SliverFixedExtentList:它是一个所有子元素高度都为50像素的列表。
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 有孩子也是一个完整的可滚动组件且它们的滑动方向一致,则 CustomScrollView 不能正常工作。要解决这个问题,可以使用 NestedScrollView

SliverPersistentHeader

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

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

floating 的做用是:pinned 为 false 时 ,则 header 可以滑出可视区域(CustomScrollView 的 Viewport)(不会固定到顶部),当用户再次向下滑动时,此时不管 header 已经被滑出了多远,它都会立即出现在可视区域顶部并固定住,直到继续下滑到 header 在列表中原来的位置时,header 才会重新回到原来的位置(不再固定在顶部

delegate 是用于生成 header 的委托,类型为 SliverPersistentHeaderDelegate,它是一个抽象类,需要我们自己实现,先看下源码:

abstract class SliverPersistentHeaderDelegate {// header 最大高度;pined为 true 时,当 header 刚刚固定到顶部时高度为最大高度。double get maxExtent;// header 的最小高度;pined为true时,当header固定到顶部,用户继续往上滑动时,header// 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtentdouble 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;}

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

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

typedef SliverHeaderBuilder = Widget Function(BuildContext context, double shrinkOffset, bool overlapsContent);class SliverHeaderDelegate extends SliverPersistentHeaderDelegate {// child 为 headerSliverHeaderDelegate({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;@overrideWidget 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);}@overridedouble get maxExtent => maxHeight;@overridedouble get minExtent => minHeight;@overridebool shouldRebuild(SliverHeaderDelegate old) {return old.maxExtent != maxExtent || old.minExtent != minExtent;}
}

使用:

class PersistentHeaderRoute extends StatelessWidget {@overrideWidget 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,),);}// 构建 headerWidget buildHeader(int i) {return Container(color: Colors.lightBlue.shade200,alignment: Alignment.centerLeft,child: Text("PersistentHeader $i"),);}
}

在这里插入图片描述
总结一下:

  • 当有多个 SliverPersistentHeader时,需要注意第一个 SliverPersistentHeader 的 overlapsContent 值会一直为 false。
  • 如果我们在使用 SliverPersistentHeader 构建子组件时需要依赖 overlapsContent 参数,则必须保证之前至少还有一个 SliverPersistentHeader 或 SliverAppBar(SliverAppBar 在当前 Flutter 版本的实现中内部包含了SliverPersistentHeader)

11.自定义 Sliver

Sliver 的布局协议如下:

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

这个过程有两个重要的对象 SliverConstraintsSliverGeometry

class SliverConstraints extends Constraints {//主轴方向AxisDirection? axisDirection;//Sliver 沿着主轴从列表的哪个方向插入?枚举类型,正向或反向GrowthDirection? growthDirection;//用户滑动方向ScrollDirection? userScrollDirection;//当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移double? scrollOffset;//当前Sliver之前的Sliver占据的总高度,因为列表是懒加载,如果不能预估时,该值为double.infinitydouble? 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;
}
const SliverGeometry({//Sliver在主轴方向预估长度,大多数情况是固定值,用于计算sliverConstraints.scrollOffsetthis.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布局模型和盒布局模型的区别?

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

实例:
可参考flukit组件库中的SliverFlexibleHeader、ExtraInfoBoxConstraints 以及 SliverPersistentHeaderToBox

12.嵌套可滚动组件NestedScrollView

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

NestedScrollView 分为 header 和 body 两部分,header 是外部可滚动组件(outer scroll view),只能接收 Sliver,headerSliverBuilder 构建一个 Sliver 列表给外部的可滚动组件;body 可以接收任意的可滚动组件,称为内部可滚动组件 (inner scroll view)。

NestedScrollView 原理

Flutter(六)可滚动组件
NestedScrollView 核心功能就是通过一个协调器来协调外部(outer)可滚动组件和内部(inner)可滚动组件的滚动,以使滑动效果连贯统一,协调器的实现原理就是分别给内外可滚动组件分别设置一个 controller,然后通过这两个controller 来协调控制它们的滚动。
注意:
内部的可滚动组件(body的)不能设置 controller 和 primary,这是因为 NestedScrollView 的协调器中已经指定了它的 controller,如果重新设定则协调器将会失效

SliverAppBar

SliverAppBar 是 AppBar 的Sliver 版,大多数参数都相同,但 SliverAppBar 会有一些特有的功能

const SliverAppBar({this.collapsedHeight, // 收缩起来的高度this.expandedHeight,// 展开时的高度this.pinned = false, // 是否固定this.floating = false, //是否漂浮this.snap = false, // 当漂浮时,此参数才有效bool forceElevated //导航栏下面是否一直显示阴影...
})

嵌套 TabBarView

用户上滑时,导航栏滑出屏幕,用户下滑时,导航栏回到屏幕

class NestedTabBarView1 extends StatelessWidget {const NestedTabBarView1({Key? key}) : super(key: key);@overrideWidget build(BuildContext context) {final _tabs = <String>['猜你喜欢', '今日特价', '发现更多'];// 构建 tabBarreturn DefaultTabController(length: _tabs.length, // tab的数量.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(),),),),);}
}


更多:https://book.flutterchina.club/chapter6/