Flutter和Dart系列文章 和代码GitHub地址
在Flutter
中, 当内容超过显示视图时,如果没有特殊处理,Flutter
则会提示Overflow
错误
Flutter
提供了多种可滚动(Scrollable Widget
)用于显示列表和长布局
可滚动Widget
都直接或间接包含一个Scrollable
, 下面是常用的几个可滚动的Widget
SingleChildScrollView
ListView
GridView
CustomScrollView
滚动监听及控制ScrollController
Scrollbar
是一个Material
风格的滚动指示器(滚动条),如果要给可滚动widget
添加滚动条,只需将Scrollbar
作为可滚动widget
的父widget
即可
CupertinoScrollbar
是iOS
风格的滚动条,如果你使用的是Scrollbar
,那么在iOS
平台它会自动切换为CupertinoScrollbar
Scrollbar
和CupertinoScrollbar
都是通过ScrollController
来监听滚动事件来确定滚动条位置,关于ScrollController
详细的内容我们将在后面专门一节介绍
下面是Scrollbar
和CupertinoScrollbar
的构造函数, 都只有一个child
属性, 用于接受一个可滚动的Widget
1 2 3 4 5 6 7 8 9 const Scrollbar({ Key key, @required this .child, }) const CupertinoScrollbar({ Key key, @required this .child, })
在可滚动widget
的坐标描述中,通常将滚动方向称为主轴,非滚动方向称为纵轴。
由于可滚动widget
的默认方向一般都是沿垂直方向,所以默认情况下主轴就是指垂直方向,水平方向同理
SingleChildScrollView
类似于开发中常用的ScrollView
, 不再详细介绍了, 下面看一下具体使用介绍吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const SingleChildScrollView({ Key key, this .scrollDirection = Axis.vertical, this .reverse = false , this .padding, bool primary, this .physics, this .controller, this .child, })
设置视图的滚动方向(默认垂直方向), 需要对应的设置其子Widget
是Column
或者Row
, 否则会报Overflow
错误
1 2 3 4 5 6 7 8 9 scrollDirection: Axis.vertical, enum Axis { horizontal, vertical, }
reverse
是否按照阅读方向相反的方向滑动
设置水平滚动时
若reverse: false
,则滚动内容头部和左侧对其, 那么滑动方向就是从左向右
reverse: true
时,则滚动内容尾部和右侧对其, 那么滑动方向就是从右往左。
其实此属性本质上是决定可滚动widget
的初始滚动位置是在头还是尾,取false
时,初始滚动位置在头,反之则在尾
physics
此属性接受一个ScrollPhysics
对象,它决定可滚动Widget
如何响应用户操作
比如用户滑动完抬起手指后,继续执行动画;或者滑动到边界时,如何显示。
默认情况下,Flutter
会根据具体平台分别使用不同的ScrollPhysics
对象,应用不同的显示效果,如当滑动到边界时,继续拖动的话,在iOS
上会出现弹性效果,而在Android
上会出现微光效果。
如果你想在所有平台下使用同一种效果,可以显式指定,Flutter SDK
中包含了两个ScrollPhysics
的子类可以直接使用:
ClampingScrollPhysics
:安卓下微光效果。
BouncingScrollPhysics
:iOS
下弹性效果。
controller
此属性接受一个ScrollController
对象
ScrollController
的主要作用是控制滚动位置和监听滚动事件。
默认情况下,widget
中会有一个默认的PrimaryScrollController
,如果子widget
中的可滚动widget
没有显式的指定controller
并且primary
属性值为true
时(默认就为true
),可滚动widget
会使用这个默认的PrimaryScrollController
这种机制带来的好处是父widget
可以控制子树中可滚动widget
的滚动,例如,Scaffold
使用这种机制在iOS
中实现了”回到顶部”的手势
代码示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class ScrollView extends StatelessWidget { @override Widget build(BuildContext context) { String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ; return Scrollbar( child: SingleChildScrollView( scrollDirection: Axis.vertical, reverse: true , padding: EdgeInsets.all(0.0 ), physics: BouncingScrollPhysics(), child: Center( child: Column( children: str.split("" ) .map((c) => Text(c, textScaleFactor: 2.0 )) .toList(), ), ), ), ); } }
ListView
ListView
是最常用的可滚动widget
,它可以沿一个方向线性排布所有子widget
, 类似于ReactNative
中的ListView
ListView
共有四种构造函数
ListView()
默认构造函数
ListView.builder()
ListView.separated()
ListView custom()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 ListView({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, EdgeInsetsGeometry padding, bool shrinkWrap = false , this .itemExtent, bool addAutomaticKeepAlives = true , bool addRepaintBoundaries = true , bool addSemanticIndexes = true , double cacheExtent, List <Widget> children = const <Widget>[], int semanticChildCount, })
属性介绍 shrinkWrap
表示是否根据子widget
的总长度来设置ListView
的长度,默认值为false
。
默认情况下,ListView
的会在滚动方向尽可能多的占用空间
当ListView
在一个无边界(滚动方向上)的容器中时,shrinkWrap
必须为true
itemExtent
该参数如果不为null
,则会强制children
的”长度”为itemExtent
的值
这里的”长度”是指滚动方向上子widget
的长度,即如果滚动方向是垂直方向,则代表子widget
的高度,如果滚动方向为水平方向,则代表子widget
的长度
在ListView
中,指定itemExtent
比让子widget
自己决定自身长度会更高效,这是因为指定itemExtent
后,滚动系统可以提前知道列表的长度,而不是总是动态去计算,尤其是在滚动位置频繁变化时
addAutomaticKeepAlives
表示是否将列表项包裹在AutomaticKeepAlive
中
在一个懒加载列表中,如果将列表项包裹在AutomaticKeepAlive
中,在该列表项滑出视口时该列表项不会被GC,它会使用KeepAliveNotification
来保存其状态
如果列表项自己维护其KeepAlive
状态,那么此参数必须置为false
addRepaintBoundaries
性表示是否将列表项包裹在RepaintBoundary
中
当可滚动widget
滚动时,将列表项包裹在RepaintBoundary
中可以避免列表项重绘,但是当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加RepaintBoundary
反而会更高效
和addAutomaticKeepAlive
一样,如果列表项自己维护其KeepAlive
状态,那么此参数必须置为false
使用示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class ScrollView extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( itemExtent: 60 , cacheExtent: 100 , addAutomaticKeepAlives: false , children: renderCell(), ); } List <Widget> renderCell() { String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ; return str.split("" ) .map((item) => ListTile( title: Text('字母--$item ' ), subtitle: Text('这是字母列表' ), leading: Icon(Icons.wifi), )).toList(); } }
ListTile
ListTile
是Flutter
给我们准备好的用于创建ListView
的子widget
提供非常常见的构造和定义方式,包括文字,icon,点击事件,一般是能够满足基本需求,但是就不能自己定义了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 const ListTile({ Key key, this .leading, this .title, this .subtitle, this .trailing, this .isThreeLine = false , this .dense, this .contentPadding, this .enabled = true , this .onTap, this .onLongPress, this .selected = false , }) return ListTile( title: Text('index--$index ' ), subtitle: Text('我是一只小鸭子, 咿呀咿呀哟; 我是一只小鸭子, 咿呀咿呀哟; 我是一只小鸭子, 咿呀咿呀哟;' ), leading: Icon(Icons.wifi), trailing: Icon(Icons.keyboard_arrow_right), isThreeLine: true , dense: false , contentPadding: EdgeInsets.all(10 ), enabled: index % 3 != 0 , onTap: () => print ('index = $index ' ), onLongPress: () => print ('long-Index = $index ' ), selected: index % 2 == 0 , );
ListView.builder
ListView.builder
适合列表项比较多(或者无限)的情况,因为只有当子Widget
真正显示的时候才会被创建
适用于自定义子Widget
且所有子Widget
的样式一样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ListView.builder({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, bool shrinkWrap = false , EdgeInsetsGeometry padding, this .itemExtent, @required IndexedWidgetBuilder itemBuilder, int itemCount, bool addAutomaticKeepAlives = true , bool addRepaintBoundaries = true , bool addSemanticIndexes = true , double cacheExtent, int semanticChildCount, })
itemCount 列表项的数量,如果为null,则为无限列表
itemBuilder
它是列表项的构建器,类型为IndexedWidgetBuilder
,返回值为一个widget
当列表滚动到具体的index
位置时,会调用该构建器构建列表项
代码示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class ListBuild extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.builder( itemCount: 30 , itemBuilder: (content, index) { return ListTile( title: Text('index--$index ' ), subtitle: Text('数字列表' ), leading: Icon(Icons.wifi), ); }, ); } }
ListView.separated ListView.separated
可以生成列表项之间的分割器,它除了比ListView.builder
多了一个separatorBuilder
参数外, 其他参数都一样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ListView.separated({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, bool shrinkWrap = false , EdgeInsetsGeometry padding, @required IndexedWidgetBuilder itemBuilder, @required IndexedWidgetBuilder separatorBuilder, @required int itemCount, bool addAutomaticKeepAlives = true , bool addRepaintBoundaries = true , bool addSemanticIndexes = true , double cacheExtent, })
separatorBuilder 该参数是一个分割生成器, 同样是一个IndexedWidgetBuilder
类型的参数
1 typedef IndexedWidgetBuilder = Widget Function (BuildContext context, int index);
代码示例 奇数行添加一条红色下划线,偶数行添加一条蓝色下划线。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 lass SeparatedList extends StatelessWidget { Widget lineView1 = Divider(color: Colors.red, height: 2 , indent: 10 ,); Widget lineView2 = Divider(color: Colors.blue, height: 5 , indent: 30 ); @override Widget build(BuildContext context) { return ListView.separated( itemCount: 30 , itemBuilder: (content, index) { return ListTile( title: Text('index--$index ' ), subtitle: Text('数字列表' ), leading: Icon(Icons.wifi), ); }, separatorBuilder: (context, index) { return index % 2 == 0 ? lineView1 : lineView2; }, ); } }
Divider 设置每一个子WIdget
的分割线
1 2 3 4 5 6 7 8 9 const Divider({ Key key, this .height = 16.0 , this .indent = 0.0 , this .color })
ListView.custom
大家可能对前两种比较熟悉,分别是传入一个子元素列表或是传入一个根据索引创建子元素的函数。
其实前两种方式都是custom
方式的“快捷方式”
ListView
内部是靠这个childrenDelegate
属性动态初始化子元素的
我们使用builder
和separated
比较多,这个custom
相对来说就比较少了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const ListView.custom({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, bool shrinkWrap = false , EdgeInsetsGeometry padding, this .itemExtent, @required this .childrenDelegate, double cacheExtent, int semanticChildCount, })
childrenDelegate 其实在ListView
的前面几种构造函数中, 都默认设置了childrenDelegate
这个属性, 更多可参考官方文档
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 ListView({ }) : childrenDelegate = SliverChildListDelegate( children, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, addSemanticIndexes: addSemanticIndexes, ), super (); ListView.builder({ }) : childrenDelegate = SliverChildBuilderDelegate( itemBuilder, childCount: itemCount, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, addSemanticIndexes: addSemanticIndexes, ), super (); ListView.separated({ }) : childrenDelegate = SliverChildBuilderDelegate( ), super ();
上面代码中可见,这里自动帮我们创建了一个SliverChildListDelegate
的实例
而SliverChildListDelegate
是抽象类SliverChildDelegate
的子类
SliverChildListDelegate
中主要逻辑就是实现了SliverChildDelegate
中定义的build
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 Widget build(BuildContext context, int index) { assert (builder != null ); if (index < 0 || (childCount != null && index >= childCount)) return null ; Widget child; try { child = builder(context, index); } catch (exception, stackTrace) { child = _createErrorWidget(exception, stackTrace); } if (child == null ) return null ; if (addRepaintBoundaries) child = RepaintBoundary.wrap(child, index); if (addSemanticIndexes) { final int semanticIndex = semanticIndexCallback(child, index); if (semanticIndex != null ) child = IndexedSemantics(index: semanticIndex + semanticIndexOffset, child: child); } if (addAutomaticKeepAlives) child = AutomaticKeepAlive(child: child); return child; }
从上面代码的逻辑可以看出, 就是根据传入的索引返回children
列表中对应的元素
每当ListView
的底层实现需要加载一个元素时,就会把该元素的索引传递给SliverChildDelegate
的build
方法,由该方法返回具体的元素
另外在SliverChildDelegate
内部,除了定义了build
方法外,还定义了 一个名为didFinishLayout
的方法
1 2 3 4 5 6 void didFinishLayout() { assert (debugAssertChildListLocked()); final int firstIndex = _childElements.firstKey() ?? 0 ; final int lastIndex = _childElements.lastKey() ?? 0 ; widget.delegate.didFinishLayout(firstIndex, lastIndex); }
每当ListView
完成一次layout
之后都会调用该方法, 同时传入两个索引值
这两个值分别是此次layout
中第一个元素和最后一个元素在ListView
所有子元素中的索引值, 也就是可视区域内的元素在子元素列表中的位置
然而不论是SliverChildListDelegate
还是SliverChildBuilderDelegate
的代码中,都没有didFinishLayout
的具体实现。所以我们需要编写一个它们的子类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class MySliverBuilderDelegate extends SliverChildBuilderDelegate { MySliverBuilderDelegate( Widget Function (BuildContext, int ) builder, { int childCount, bool addAutomaticKeepAlives = true , bool addRepaintBoundaries = true , }) : super (builder, childCount: childCount, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries); @override void didFinishLayout(int firstIndex, int lastIndex) { print ('firstIndex: $firstIndex , lastIndex: $lastIndex ' ); } }
然后我们创建一个ListView.custom
的列表视图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class CustomList extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.custom( childrenDelegate: MySliverBuilderDelegate( (BuildContext context, int index) { return ListTile( title: Text('index--$index ' ), subtitle: Text('数字列表' ), leading: Icon(Icons.wifi), ); }, childCount: 30 , ), ); } }
GridView GridView
可以构建二维网格列表, 系统给出了五中构造函数
GridView()
GridView.count
GridView.extent
GridView.builder
GridView.custom
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 , bool addSemanticIndexes = true , double cacheExtent, List <Widget> children = const <Widget>[], int semanticChildCount, })
可以看到, 除了gridDelegate
属性外, 其他属性和ListView
的属性都一样, 含义也都相同
gridDelegate
参数的类型是SliverGridDelegate
,它的作用是控制GridView
子widget
如何排列
SliverGridDelegate
是一个抽象类,定义了GridView
排列相关接口,子类需要通过实现它们来实现具体的布局算法
Flutter
中提供了两个SliverGridDelegate
的子类SliverGridDelegateWithFixedCrossAxisCount
和SliverGridDelegateWithMaxCrossAxisExtent
, 下面我们分别介绍
SliverGridDelegateWithFixedCrossAxisCount 该子类实现了一个横轴为固定数量子元素的排列算法,其构造函数为:
1 2 3 4 5 6 7 8 9 10 const SliverGridDelegateWithFixedCrossAxisCount({ @required this .crossAxisCount, this .mainAxisSpacing = 0.0 , this .crossAxisSpacing = 0.0 , this .childAspectRatio = 1.0 , })
从上面的个属性可以发现,子元素的大小是通过crossAxisCount
和childAspectRatio
两个参数共同决定的。注意,这里的子元素指的是子widget
的最大显示空间,注意确保子widget
的实际大小不要超出子元素的空间, 代码示例如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class ScrollView extends StatelessWidget { @override Widget build(BuildContext context) { return GridView( padding: EdgeInsets.all(10 ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3 , childAspectRatio: 1 , mainAxisSpacing: 10 , crossAxisSpacing: 10 ), children: <Widget>[ Container(color: Colors.orange), Container(color: Colors.blue), Container(color: Colors.orange), Container(color: Colors.yellow), Container(color: Colors.pink) ], ); } }
GridView.count GridView.count
构造函数内部使用了SliverGridDelegateWithFixedCrossAxisCount
,我们通过它可以快速的创建横轴固定数量子元素的GridView
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 GridView.count({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, bool shrinkWrap = false , EdgeInsetsGeometry padding, @required int crossAxisCount, double mainAxisSpacing = 0.0 , double crossAxisSpacing = 0.0 , double childAspectRatio = 1.0 , bool addAutomaticKeepAlives = true , bool addRepaintBoundaries = true , bool addSemanticIndexes = true , double cacheExtent, List <Widget> children = const <Widget>[], int semanticChildCount, })
上面SliverGridDelegateWithFixedCrossAxisCount
中给出的示例代码等价于:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class CountGridView extends StatelessWidget { @override Widget build(BuildContext context) { return GridView.count( padding: EdgeInsets.all(10 ), crossAxisCount: 3 , mainAxisSpacing: 10 , crossAxisSpacing: 10 , childAspectRatio: 1 , children: <Widget>[ Container(color: Colors.orange), Container(color: Colors.blue), Container(color: Colors.orange), Container(color: Colors.yellow), Container(color: Colors.pink) ], ); } }
SliverGridDelegateWithMaxCrossAxisExtent 该子类实现了一个侧轴子元素为固定最大长度的排列算法,其构造函数为:
1 2 3 4 5 6 const SliverGridDelegateWithMaxCrossAxisExtent({ @required this .maxCrossAxisExtent, this .mainAxisSpacing = 0.0 , this .crossAxisSpacing = 0.0 , this .childAspectRatio = 1.0 , })
maxCrossAxisExtent
为子元素在侧轴上的最大长度,之所以是“最大”长度,是因为横轴方向每个子元素的长度仍然是等分的
同样侧轴上子Widget
的个数, 也是由该属性决定
其它参数和SliverGridDelegateWithFixedCrossAxisCount
相同
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class ExtentScrollView extends StatelessWidget { @override Widget build(BuildContext context) { return GridView( padding: EdgeInsets.all(10 ), gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 100 , childAspectRatio: 1 , mainAxisSpacing: 10 , crossAxisSpacing: 10 ), children: <Widget>[ Container(color: Colors.orange), Container(color: Colors.blue), Container(color: Colors.orange), Container(color: Colors.yellow), Container(color: Colors.pink) ], ); } }
GridView.extent 同样GridView.extent
构造函数内部使用了SliverGridDelegateWithMaxCrossAxisExtent
,我们通过它可以快速的创建侧轴子元素为固定最大长度的的GridView
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 GridView.extent({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, bool shrinkWrap = false , EdgeInsetsGeometry padding, @required double maxCrossAxisExtent, double mainAxisSpacing = 0.0 , double crossAxisSpacing = 0.0 , double childAspectRatio = 1.0 , bool addAutomaticKeepAlives = true , bool addRepaintBoundaries = true , bool addSemanticIndexes = true , List <Widget> children = const <Widget>[], int semanticChildCount, })
上面SliverGridDelegateWithMaxCrossAxisExtent
中给出的示例代码等价于:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class ExtentScrollView extends StatelessWidget { @override Widget build(BuildContext context) { return GridView.extent( padding: EdgeInsets.all(10 ), maxCrossAxisExtent: 100 , childAspectRatio: 1 , mainAxisSpacing: 10 , crossAxisSpacing: 10 , children: <Widget>[ Container(color: Colors.orange), Container(color: Colors.blue), Container(color: Colors.orange), Container(color: Colors.yellow), Container(color: Colors.pink) ], ); } }
GridView.builder
上面我们介绍的GridView
都需要一个Widget
数组作为其子元素,这些方式都会提前将所有子widget
都构建好,所以只适用于子Widget
数量比较少时
当子widget
比较多时,我们可以通过GridView.builder
来动态创建子Widget
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 GridView.builder({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, bool shrinkWrap = false , EdgeInsetsGeometry padding, @required this .gridDelegate, @required IndexedWidgetBuilder itemBuilder, int itemCount, bool addAutomaticKeepAlives = true , bool addRepaintBoundaries = true , bool addSemanticIndexes = true , double cacheExtent, int semanticChildCount, })
可以看出GridView.builder
必须指定的参数有两个,其中gridDelegate
之前已经介绍过了
属性itemBuilder
在之前ListView
中也有介绍过类似的, 用于构建子Widget
使用示例如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class BuilderGridView extends StatelessWidget { @override Widget build(BuildContext context) { return GridView.builder( itemCount: 50 , gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4 , mainAxisSpacing: 10 , crossAxisSpacing: 10 ), itemBuilder: (content, index) { return Container( color: Colors.orange, child: Center( child: Text('$index ' ), ), ); }, ); } }
GridView.custom 和ListView.custom
一样, 用于构建自定义子Widget
, 有两个必须指定的参数, 这里就不在赘述了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const GridView.custom({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, bool shrinkWrap = false , EdgeInsetsGeometry padding, @required this .gridDelegate, @required this .childrenDelegate, double cacheExtent, int semanticChildCount, })
CustomScrollView
使用sliver
来自定义滚动模型(效果, 它可以包含多种滚动模型
假设有一个页面,顶部需要一个GridView
,底部需要一个ListView
,而要求整个页面的滑动效果是统一的,即它们看起来是一个整体
如果使用GridView+ListView
来实现的话,就不能保证一致的滑动效果,因为它们的滚动效果是分离的,所以这时就需要一个”胶水”,把这些彼此独立的可滚动widget
“粘”起来,而CustomScrollView
的功能就相当于“胶水”
1 2 3 4 5 6 7 8 9 10 11 12 13 const CustomScrollView({ Key key, Axis scrollDirection = Axis.vertical, bool reverse = false , ScrollController controller, bool primary, ScrollPhysics physics, bool shrinkWrap = false , double cacheExtent, this .slivers = const <Widget>[], int semanticChildCount, })
上述属性除了slivers
之外, 前面都有提到过, 接受一个Widget
数组, 但是这里的Widget
必须是Sliver
类型的, 至于原因, 下面会详解
在Flutter
中,Sliver
通常指具有特定滚动效果的可滚动块
可滚动widget
,如ListView
、GridView
等都有对应的Sliver
实现如SliverList
、SliverGrid
等
对于大多数Sliver
来说,它们和可滚动Widget
最主要的区别是Sliver
不会包含Scrollable
,也就是说Sliver
本身不包含滚动交互模型
正因如此,CustomScrollView
才可以将多个Sliver
“粘”在一起,这些Sliver
共用CustomScrollView
的Scrollable
,最终实现统一的滑动效果
前面之所以说“大多数“Sliver
都和可滚动Widget
对应,是由于还有一些如SliverPadding
、SliverAppBar
等是和可滚动Widget
无关的
它们主要是为了结合CustomScrollView
一起使用,这是因为CustomScrollView
的子widget
必须都是Sliver
下面是常用的一些Sliver
:
SliverToBoxAdapter
: 将各种视图组合在一起, 类似于Container
SliverPersistentHeader
: 实现sticky
吸顶的效果的Sliver
SliverList
:类似于我们之前使用过的ListView
;
SliverFixedExtentList
:类似于SliverList
只是可以设置滚动的高度;
SliverGrid
:类似于我们之前使用过的GridView
SliverPadding
:设置Sliver
的内边距,因为可能要单独给Sliver
设置内边距;
SliverAppBar
:添加一个AppBar
,通常用来作为CustomScrollView
的HeaderView
SliverSafeArea
:设置内容显示在安全区域(比如不让齐刘海挡住我们的内容)
SliverAppBar
AppBar
和SliverAppBar
是Material Design
中的导航栏
AppBar
和SliverAppBar
都是继承StatefulWidget
类,二者的区别在于AppBar
位置的固定的应用最上面的;而SliverAppBar
是可以跟随内容滚动的
其中大部分的属性和AppBar
都一样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 const SliverAppBar({ Key key, this .leading, this .automaticallyImplyLeading = true , this .title, this .actions, this .flexibleSpace, this .bottom, this .elevation = 4.0 , this .backgroundColor, this .brightness, this .iconTheme, this .textTheme, this .primary = true , this .centerTitle, this .titleSpacing = NavigationToolbar.kMiddleSpacing, this .expandedHeight, this .floating = false , this .pinned = false , this .snap = false , })
使用示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 class CustomScrollViewTestRoute extends StatelessWidget { @override Widget build(BuildContext context) { return Material( child: CustomScrollView( slivers: <Widget>[ SliverAppBar( pinned: true , expandedHeight: 250.0 , flexibleSpace: FlexibleSpaceBar( title: const Text('Demo' ), background: Image.asset( "./images/avatar.png" , fit: BoxFit.cover,), ), ), SliverPadding( padding: const EdgeInsets.all(8.0 ), sliver: new SliverGrid( gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2 , mainAxisSpacing: 10.0 , crossAxisSpacing: 10.0 , childAspectRatio: 4.0 , ), delegate: new SliverChildBuilderDelegate( (BuildContext context, int index) { return new Container( alignment: Alignment.center, color: Colors.cyan[100 * (index % 9 )], child: new Text('grid item $index ' ), ); }, childCount: 20 , ), ), ), new SliverFixedExtentList( itemExtent: 50.0 , delegate: new SliverChildBuilderDelegate( (BuildContext context, int index) { return new Container( alignment: Alignment.center, color: Colors.lightBlue[100 * (index % 9 )], child: new Text('list item $index ' ), ); }, childCount: 50 ), ), ], ), ); } }
ScrollController
用于控制可滚动widget
的滚动位置,这里以ListView
为例,展示一下ScrollController
的具体用法
最后再介绍一下路由切换时如何来保存滚动位置
下面先看一下ScrollController
的构造函数
1 2 3 4 5 6 7 8 ScrollController({ double initialScrollOffset = 0.0 , this .keepScrollOffset = true , this .debugLabel, })
相关属性和方法 offset 可滚动Widget
当前滚动的位置
jumpTo() 跳转到指定的位置, 没有动画效果
1 2 3 4 5 void jumpTo(double value) { assert (_positions.isNotEmpty, 'ScrollController not attached to any scroll views.' ); for (ScrollPosition position in List <ScrollPosition>.from(_positions)) position.jumpTo(value); }
animateTo() 跳转到指定的位置, 跳转时会有一个动画效果
1 2 3 4 5 6 7 8 9 10 Future<void > animateTo(double offset, { @required Duration duration, @required Curve curve, }) { assert (_positions.isNotEmpty, 'ScrollController not attached to any scroll views.' ); final List <Future<void >> animations = List <Future<void >>(_positions.length); for (int i = 0 ; i < _positions.length; i += 1 ) animations[i] = _positions[i].animateTo(offset, duration: duration, curve: curve); return Future.wait<void >(animations).then<void >((List <void > _) => null ); }
positions
一个ScrollController
可以同时被多个Scrollable
使用,ScrollController
会为每一个Scrollable
创建一个ScrollPosition
对象,这些ScrollPosition
保存在ScrollController
的positions
属性中(是一个数组)
ScrollPosition
是真正保存滑动位置信息的对象,offset
只是一个便捷属性, 其他更多属性可查看相关官方文档
一个ScrollController
虽然可以对应多个Scrollable
,但是有一些操作,如读取滚动位置offset
,则需要一对一,但是我们仍然可以在一对多的情况下,通过其它方法读取滚动位置
1 2 3 4 5 6 double get offset => position.pixels;controller.positions.elementAt(0 ).pixels controller.positions.elementAt(1 ).pixels
滚动监听 ScrollController
间接继承自Listenable
,我们可以根据ScrollController
来监听滚动事件。如:
1 controller.addListener(()=>print (controller.offset))
先看一下ScrollController
另外几个方法的实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 ScrollPosition createScrollPosition( ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition, ) { return ScrollPositionWithSingleContext( physics: physics, context: context, initialPixels: initialScrollOffset, keepScrollOffset: keepScrollOffset, oldPosition: oldPosition, debugLabel: debugLabel, ); } void attach(ScrollPosition position) { assert (!_positions.contains(position)); _positions.add(position); position.addListener(notifyListeners); } void detach(ScrollPosition position) { assert (_positions.contains(position)); position.removeListener(notifyListeners); _positions.remove(position); } @override void dispose() { for (ScrollPosition position in _positions) position.removeListener(notifyListeners); super .dispose(); }
当ScrollController
和Scrollable
关联时,Scrollable
首先会调用ScrollController
的createScrollPosition()
方法来创建一个ScrollPosition
来存储滚动位置信息
然后Scrollable
会调用attach()
方法,将创建的ScrollPosition
添加到ScrollController
的positions
属性中,这一步称为“注册位置”,只有注册后animateTo()
和jumpTo()
才可以被调用
当Scrollable
销毁时,会调用ScrollController
的detach()
方法,将其ScrollPosition
对象从ScrollController
的positions
属性中移除,这一步称为“注销位置”,注销后animateTo()
和jumpTo()
将不能再被调用
需要注意的是,ScrollController
的animateTo()
和jumpTo()
内部会调用所有ScrollPosition
的animateTo()
和jumpTo()
,以实现所有和该ScrollController
关联的Scrollable
都滚动到指定的位置
代码示例 创建一个ListView
,当滚动位置发生变化时,我们先打印出当前滚动位置,然后判断当前位置是否超过1000像素,如果超过则在屏幕右下角显示一个“返回顶部”的按钮,该按钮点击后可以使ListView
恢复到初始位置;如果没有超过1000像素,则隐藏“返回顶部”按钮。代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 class ScrollControllerTestRoute extends StatefulWidget { @override ScrollControllerTestRouteState createState() { return new ScrollControllerTestRouteState(); } } class ScrollControllerTestRouteState extends State <ScrollControllerTestRoute > { ScrollController _controller = new ScrollController(); bool showToTopBtn = false ; @override void initState() { _controller.addListener(() { print (_controller.offset); if (_controller.offset < 1000 && showToTopBtn) { setState(() { showToTopBtn = false ; }); } else if (_controller.offset >= 1000 && showToTopBtn == false ) { setState(() { showToTopBtn = true ; }); } }); } @override void dispose() { _controller.dispose(); super .dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("滚动控制" )), body: Scrollbar( child: ListView.builder( itemCount: 100 , itemExtent: 50.0 , controller: _controller, itemBuilder: (context, index) { return ListTile(title: Text("$index " ),); } ), ), floatingActionButton: !showToTopBtn ? null : FloatingActionButton( child: Icon(Icons.arrow_upward), onPressed: () { _controller.animateTo(.0 , duration: Duration (milliseconds: 200 ), curve: Curves.ease ); } ), ); } }
参考文献