Flutter 中的可滚动主要由三个角色组成:Scrollable、Viewport 和 Sliver:
具体布局过程:
Scrollable 监听到滑动后,根据滑动偏移构建 Viewport ,Viewport 将当前视图信息和配置信息通过 SliverConstraints 传递给 Sliver,Sliver 中对子组件按需进行构建和布局。
顶部和底部灰色的区域为 cacheExtent,cacheExtent 的默认值是 250,在构建可滚动列表时我们可以指定这个值,这个值最终会传给 Viewport。
它表示预渲染的高度,需要注意这是在可视区域之外,如果 RenderBox 进入这个区域内,即使它还未显示在屏幕上,也是要先进行构建的,预渲染是为了后面进入 Viewport 的时候更丝滑。
Scrollable({
...
this.axisDirection = AxisDirection.down,//滚动方向
this.controller,//控制滚动位置和监听滚动事件
this.physics,//滚动组件如何响应用户操作
required this.viewportBuilder, //构建 Viewport 的回调。
})
physics可以显式指定一个固定的ScrollPhysics,Flutter SDK中包含了两个ScrollPhysics的子类,他们可以直接使用:
controller默认PrimaryScrollController,父组件可以控制子滚动组件的滚动行为
viewportBuilder用于构建 Viewport 的回调。当用户滑动时,Scrollable 会调用此回调构建新的 Viewport,Viewport 变化时对应的 RenderViewport 会更新信息,不会随着 Widget 进行重新构建。
在可滚动组件的坐标描述中,通常将滚动方向称为主轴,非滚动方向称为纵轴
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 主要作用是对子组件进行构建和布局
几乎所有的可滚动组件在构造时都能指定 scrollDirection(滑动的主轴)、reverse(滑动方向是否反向)、controller、physics 、cacheExtent ,这些属性最终会透传给对应的 Scrollable 和 Viewport,这些属性我们可以认为是可滚动组件的通用属性
可滚动组件都有一个 controller 属性,通过该属性我们可以指定一个 ScrollController 来控制可滚动组件的滚动
Scrollbar是一个Material风格的滚动指示器(滚动条),如果要给可滚动组件添加滚动条,只需将Scrollbar作为可滚动组件的任意一个父级组件即可
Scrollbar(
child: SingleChildScrollView(
...
),
);
CupertinoScrollbar是 iOS 风格的滚动条,如果你使用的是Scrollbar,那么在iOS平台它会自动切换为CupertinoScrollbar
//只能接收一个子组件
SingleChildScrollView({
this.scrollDirection = Axis.vertical, //滚动方向,默认是垂直方向
this.reverse = false,
this.padding,
bool primary,
this.physics,
this.controller,
this.child,
})
SingleChildScrollView在不会超过屏幕太多时使用,
因为SingleChildScrollView不支持基于 Sliver 的延迟加载模型会导致性能差,
超出屏幕太多应该使用一些支持Sliver延迟加载的可滚动组件,如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>[],
})
默认构造函数有一个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公共参数已省略
...
required IndexedWidgetBuilder itemBuilder,
int itemCount,
...
})
ListView.builder(
itemCount: 100,
itemExtent: 50.0, //强制高度为50.0
itemBuilder: (BuildContext context, int index) {
return ListTitle(title: Text("$index"));
}
);
ListView.separated可以在生成的列表项之间添加一个分割组件,它比ListView.builder多了一个separatorBuilder参数,该参数是一个分割组件生成器
实例:奇数行添加一条蓝色下划线,偶数行添加一条绿色下划线。
class ListView3 extends StatelessWidget {
@override
Widget build(BuildContext context) {
//下划线widget预定义以供复用。
Widget divider1=Divider(color: Colors.blue,);
Widget divider2=Divider(color: Colors.green);
return ListView.separated(
itemCount: 100,
//列表项构造器
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("$index"));
},
//分割器构造器
separatorBuilder: (BuildContext context, int index) {
return index%2==0?divider1:divider2;
},
);
}
}
给列表指定 itemExtent 或 prototypeItem 会有更高的性能,所以当我们知道列表项的高度都相同时,强烈建议指定 itemExtent 或 prototypeItem
ListView 内部组合了 Scrollable、Viewport 和 Sliver,需要注意:
记载更多时显示一个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];
@override
void initState() {
super.initState();
_retrieveData();
}
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: _words.length,
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: 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(),
);
});
});
}
}
@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"));
}),
),
]);
}
Flex是弹性布局,Column是继承自Flex的,加Expanded自动拉伸组件大小,所以使用Column + Expanded来实现
ScrollController({
double initialScrollOffset = 0.0, //初始滚动位置
this.keepScrollOffset = true,//是否保存滚动位置
...
})
jumpTo(double offset)、animateTo(double offset,…):这两个方法用于跳转到指定的位置,后者在跳转时会执行一个动画,而前者不会
controller.addListener(()=>print(controller.offset))
实例
滚动时打印出当前滚动位置,
如果超过1000像素,显示“返回顶部”的按钮,
如果没有超过1000像素,则隐藏“返回顶部”按钮。
按钮点击使ListView恢复到初始位置;
class ScrollControllerTestRoute extends StatefulWidget {
@override
ScrollControllerTestRouteState createState() {
return ScrollControllerTestRouteState();
}
}
class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
ScrollController _controller = ScrollController();
bool showToTopBtn = false; //是否显示“返回到顶部”按钮
@override
void initState() {
super.initState();
//监听滚动事件,打印滚动位置
_controller.addListener(() {
print(_controller.offset); //打印滚动位置
if (_controller.offset < 1000 && showToTopBtn) {
setState(() {
showToTopBtn = false;
});
} else if (_controller.offset >= 1000 && showToTopBtn == false) {
setState(() {
showToTopBtn = true;
});
}
});
}
@override
void dispose() {
//为了避免内存泄露,需要调用_controller.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,
);
}
),
);
}
}
item高度为 50 像素,当滑动到第 20 个时 “返回顶部” 按钮显示,
点击后ListView 会在返回顶部并执行一个滚动动画,动画时间是 200 毫秒,动画曲线是 Curves.ease
PageStorage是一个用于保存页面(路由)相关数据的组件,每次滚动结束,可滚动组件都会将滚动位置offset存储到PageStorage中,
ScrollController.keepScrollOffset为false,则滚动位置将不会被存储ScrollController.keepScrollOffset为true时,第一次创建时会滚动到initialScrollOffset处,这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而initialScrollOffset会被忽略
一个路由中包含多个可滚动组件时,可指定PageStorageKey保存不同滚动位置,但也不一定
只有当Widget发生结构变化,导致可滚动组件的State销毁或重新构建时才会丢失状态,这种情况就需要显式指定PageStorageKey,通过PageStorage来存储滚动位置,
一个典型的场景是在使用TabBarView时,在Tab发生切换时,Tab页中的可滚动组件的State就会销毁,这时如果想恢复滚动位置就需要指定PageStorageKey
ScrollPosition是用来保存可滚动组件的滚动位置,ScrollController会为每一个可滚动组件创建一个ScrollPosition对象
一个ScrollController同时被两个可滚动组件使用,那么我们可以通过如下方式分别读取他们的滚动位置
controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels
我们可以通过controller.positions.length来确定controller被几个可滚动组件使用。
ScrollPosition有两个常用方法:animateTo() 和 jumpTo(),它们是真正来控制跳转滚动位置的方法
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和ScrollController的不同
NotificationListener可以在可滚动组件到widget树根之间任意位置监听。
而ScrollController只能和具体的可滚动组件关联后才可以。
收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置
实例:滚动显示百分比
import 'package:flutter/material.dart';
class ScrollNotificationTestRoute extends StatefulWidget {
@override
_ScrollNotificationTestRouteState createState() =>
_ScrollNotificationTestRouteState();
}
class _ScrollNotificationTestRouteState
extends State<ScrollNotificationTestRoute> {
String _progress = "0%"; //保存进度百分比
@override
Widget build(BuildContext context) {
return Scrollbar(
//进度条
// 监听滚动通知
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
double progress = notification.metrics.pixels /
notification.metrics.maxScrollExtent;
//重新构建
setState(() {
_progress = "${(progress * 100).toInt()}%";
});
print("BottomEdge: ${notification.metrics.extentAfter == 0}");
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,
)
],
),
),
);
}
}
在接收到滚动事件时,参数类型为ScrollNotification,它包括一个metrics属性,它的类型是ScrollMetrics,该属性包含当前ViewPort及滚动位置等信息:
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(
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(
//数字不会重复,所以作为Key
key: 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变为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: Duration(milliseconds: 200), // 动画时间为 200 ms
);
});
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({
@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)
]
);
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),
],
);
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构造函数内部使用了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),
],
);
当子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数据
@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,
]);
});
});
}
}
_retrieveIcons():此方法中通过Future.delayed来模拟从异步数据源获取数据,每次获取数据需要200毫秒,获取成功后将新数据添加到_icons,然后调用setState重新构建。
在 itemBuilder 中,如果显示到最后一个时,判断是否需要继续获取数据,然后返回一个Icon
图片轮动以及抖音上下滑页切换视频功能,这些都可以通过 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> {
@override
Widget 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
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);
@override
Widget build(BuildContext context) {
return ListView.builder(itemBuilder: (_, index) {
return KeepAliveWrapper(
// 为 true 后会缓存所有的列表项,列表项将不会销毁。
// 为 false 时,列表项滑出预加载区域后将会别销毁。
// 使用时一定要注意是否必要,因为对所有列表项都缓存的会导致更多的内存消耗
keepAlive: true,
child: ListItem(index: index),
);
});
}
}
class ListItem extends StatefulWidget {
const ListItem({Key? key, required this.index}) : super(key: key);
final int index;
@override
_ListItemState createState() => _ListItemState();
}
class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(title: Text('${widget.index}'));
}
@override
void dispose() {
//keepAlive 设为 false,日志面板将有输出
print('dispose ${widget.index}');
super.dispose();
}
}
TabBarView 封装了 PageView
TabBarView({
Key? key,
required this.children, // tab 页
//TabController 用于监听和控制 TabBarView 的页面切换,
//通常和 TabBar 联动。如果没有指定,则会在组件树中向上查找并使用最近的一个 DefaultTabController
this.controller, // TabController
this.physics,
this.dragStartBehavior = DragStartBehavior.start,
})
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 即可
另外我们需要创建需要的 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 = ["新闻", "历史", "图片"];
@override
void initState() {
super.initState();
_tabController = TabController(length: tabs.length, vsync: this);
}
@override
Widget 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(),
),
);
}
@override
void dispose() {
//由于 TabController 中会执行动画,持有一些资源,所以我们在页面销毁时必须得释放资源(dispose)
// 释放资源
_tabController.dispose();
super.dispose();
}
}
创建 TabController 的过程还是比较复杂,也可以使用系统DefaultTabController 简单实现
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 了,这样简单很多
因为TabBarView 内部封装了 PageView,如果要缓存页面,和PageView同样处理,使用 KeepAliveWrapper 包裹一下它即可。
需求: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
还有一些用于对 Sliver 进行布局、装饰的组件,它们的子组件必须是 Sliver,我们列举几个常用的:
还有一些其他常用的 Sliver:
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(
//Grid
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, //Grid按两列显示
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
childAspectRatio: 4.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建子widget
return Container(
alignment: Alignment.center,
color: Colors.cyan[100 * (index % 9)],
child: Text('grid item $index'),
);
},
childCount: 20,
),
),
),
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像素的列表。
可以将 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 的功能是当滑动到 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 逐渐减小到 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;
}
最需要关注的就是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 为 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"),
);
}
}
Sliver 的布局协议如下:
这个过程有两个重要的对象 SliverConstraints 和 SliverGeometry
class SliverConstraints extends Constraints {
//主轴方向
AxisDirection? axisDirection;
//Sliver 沿着主轴从列表的哪个方向插入?枚举类型,正向或反向
GrowthDirection? growthDirection;
//用户滑动方向
ScrollDirection? userScrollDirection;
//当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移
double? scrollOffset;
//当前Sliver之前的Sliver占据的总高度,因为列表是懒加载,如果不能预估时,该值为double.infinity
double? precedingScrollExtent;
//上一个 sliver 覆盖当前 sliver 的长度(重叠部分的长度),通常在 sliver 是 pinned/floating
//或者处于列表头尾时有效,我们在后面的小节中会有相关的例子。
double? overlap;
//当前Sliver在Viewport中的最大可以绘制的区域。
//绘制如果超过该区域会比较低效(因为不会显示)
double? remainingPaintExtent;
//纵轴的长度;如果列表滚动方向是垂直方向,则表示列表宽度。
double? crossAxisExtent;
//纵轴方向
AxisDirection? crossAxisDirection;
//Viewport在主轴方向的长度;如果列表滚动方向是垂直方向,则表示列表高度。
double? viewportMainAxisExtent;
//Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]
double? cacheOrigin;
//Viewport加载区域的长度,范围:
//[viewportMainAxisExtent,viewportMainAxisExtent + Viewport.cacheExtent*2]
double? remainingCacheExtent;
}
const SliverGeometry({
//Sliver在主轴方向预估长度,大多数情况是固定值,用于计算sliverConstraints.scrollOffset
this.scrollExtent = 0.0,
this.paintExtent = 0.0, // 可视区域中的绘制长度
this.paintOrigin = 0.0, // 绘制的坐标原点,相对于自身布局位置
//在 Viewport中占用的长度;如果列表滚动方向是垂直方向,则表示列表高度。
//范围[0,paintExtent]
double? layoutExtent,
this.maxPaintExtent = 0.0,//最大绘制长度
this.maxScrollObstructionExtent = 0.0,
double? hitTestExtent, // 点击测试的范围
bool? visible,// 是否显示
//是否会溢出Viewport,如果为true,Viewport便会裁剪
this.hasVisualOverflow = false,
//scrollExtent的修正值:layoutExtent变化后,为了防止sliver突然跳动(应用新的layoutExtent)
//可以先进行修正,具体的作用在后面 SliverFlexibleHeader 示例中会介绍。
this.scrollOffsetCorrection,
double? cacheExtent, // 在预渲染区域中占据的长度
})
Sliver布局模型和盒布局模型的区别?
实例:
可参考flukit组件库中的SliverFlexibleHeader、ExtraInfoBoxConstraints 以及 SliverPersistentHeaderToBox
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 核心功能就是通过一个协调器来协调外部(outer)可滚动组件和内部(inner)可滚动组件的滚动,以使滑动效果连贯统一,协调器的实现原理就是分别给内外可滚动组件分别设置一个 controller,然后通过这两个controller 来协调控制它们的滚动。
注意:
内部的可滚动组件(body的)不能设置 controller 和 primary,这是因为 NestedScrollView 的协调器中已经指定了它的 controller,如果重新设定则协调器将会失效
SliverAppBar 是 AppBar 的Sliver 版,大多数参数都相同,但 SliverAppBar 会有一些特有的功能
const SliverAppBar({
this.collapsedHeight, // 收缩起来的高度
this.expandedHeight,// 展开时的高度
this.pinned = false, // 是否固定
this.floating = false, //是否漂浮
this.snap = false, // 当漂浮时,此参数才有效
bool forceElevated //导航栏下面是否一直显示阴影
...
})
用户上滑时,导航栏滑出屏幕,用户下滑时,导航栏回到屏幕
class NestedTabBarView1 extends StatelessWidget {
const NestedTabBarView1({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final _tabs = <String>['猜你喜欢', '今日特价', '发现更多'];
// 构建 tabBar
return 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/