Flutter 中有两种布局模型:
RenderBox
的盒布局模型RenderSliver
的按需加载列表布局Sliver 的作用是: 加载子组件并确定每一个子组件的布局和绘制信息,如果 Sliver 可以包含多个子组件时,通常会实现按需加载模型。
可滚动组件中有很多支持 基于 Sliver 的按需加载模型,例如 ListView
、 GridView
,也有不支持该模型的组件,例如 SingleChildScrollView
。
Flutter 中的可滚动主要有下面三个角色组成:
Scrollable
Viewport
Sliver
具体的布局过程是:
Scrollable
监听到用户行为后,根据最新的滑动偏移创建 ViewportSliverConstraints
传递给 Sliver布局结构如下所示:
其中 Scrollable、ViewPort、Sliver 占满整个屏幕控件,而 Sliver 的父组件为 ViewPort, ViewPort 的父组件为 Scrollable。
ListView 中只有一个 Sliver ,在 Sliver 中实现了子组件按需加载。
用于处理滑动手势,确定滑动偏移, 滑动偏移变化时去构建 ViewPort ,它的关键属性如下:
class Scrollable extends StatefulWidget {
const Scrollable({
...
this.axisDirection = AxisDirection.down,
this.controller,
this.physics,
required this.viewportBuilder,
})
axisDirection
physics
ScrollPhysics
,用于决定组件如何响应用户操作。 默认情况下, 会根据具体平台分别使用不同的 ScrollPhysics
对象,应用不同的显示效果, 如 Android 在滑到边界时,会有微光效果(ClampingScrollPhysics
),而 iOS 会有弹性效果(BouncingScrollPhysics
)controller
ScrollController
对象, 控制滚动位置和监听滚动事件。 默认的话, Widget树有一个 PrimaryScrollController
,如果子树中的可滚动组件没有显示的指定 controller
, 并且 primary
属性值为 true,可滚动组件会默认使用这个 controllerviewportBuilder
Viewport 用于渲染当前窗口需要显示的 Sliver
class Viewport extends MultiChildRenderObjectWidget {
Viewport({
...
this.axisDirection = AxisDirection.down,
this.crossAxisDirection,
this.anchor = 0.0,
required this.offset,
this.center,
this.cacheExtent,
this.cacheExtentStyle = CacheExtentStyle.pixel,
this.clipBehavior = Clip.hardEdge,
List<Widget> slivers = const <Widget>[],
})
center
cacheExtent
,CacheExtentStyle
CacheExtentStyle
是一个枚举类, 有 pixel、 viewport 两个取值。对子组件进行构建和布局, 比如 ListView 的Sliver 需要实现子组件按需加载功能, 只有当列表项进入到渲染区域时才会去对它进行构建和布局、渲染。
Sliver 对应的渲染对象是 RenderSliver
,它和 RenderBox
的都继承自 RenderObject,不同点是:布局时的约束信息不同。 Render通过传递 BoxConstraints,值包含最大宽高的约束, 而 RenderSliver 传递的则是 SliverConstraints
。
几乎所有的可滚动组件都可以在构造时去指定 scrollDirection
、reverse
、controller
、 physics
、 cacheExtent
, 这些属性最终都会向上透传给 Scrollable 和 Viewport,这些属性我们可以认为是可滚动组件的通用属性
可滚动组件都有一个 controller 属性,通过该属性我们可以指定一个 ScrollController 来控制,比如来同步多个组件之间的滑动联动。
按需加载在大多数场景中都有正收益,但是也可能在一些场景中产生了副作用,例如:
一个页面由 ListView 组成,它的顶部第一项内容的数据需要在每次页面打开的时进行网络请求获取。 为此我们事先了一个 Header 组件实现,是一个 StatefulWidget ,会在 initState
中请求网络数据。
但这时的问题是, 因为 ListView 是按需加载子节点的,意味 Header 如果不在 Viewport 的预渲染区域内,就会被销毁,重新滑入后有重新构建,这可能会产生大量的网络请求,不符合预期。
为了解决这样的问题, 可滚动组件提供了一种缓存子节点的通用方案, 我们可以对特定的子节点(字界限)进行缓存,后续会介绍到
Scrollbar
是一个 Material 风格的滚动指示器,如果要给可滚动组件添加滚动条,只需要将 Scrollbar 作为可滚动组件的任意一个父组件即可,如:
Scrollbar(
child: SingleChildScrollView(
....
),
),
拓展一个 CupertinoScrollbar
, 它是一个 iOS风格的可滚动条,如果使用的是 Scrollbar, 那么在 iOS 会自动切换到这个 Bar。
SingleChildScrollView
类似于 Android 中的 ScrollView,其定义如下:
class SingleChildScrollView extends StatelessWidget {
...
const SingleChildScrollView({
...
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.padding,
bool? primary,
this.physics,
this.controller,
this.child,
this.dragStartBehavior = DragStartBehavior.start,
this.clipBehavior = Clip.hardEdge,
this.restorationId,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
})
其中大部分属性都是之前已经介绍过的通用组件,这边重点看 primary
,这个属性表示是否使用 widget 树中默认的 PrimaryScrollController
(MaterialApp 组件树中已经默认包含一个 PrimaryScrollController了),当滑动方向为垂直方向且没有指定 controller
时,primary
默认为 true
通常情况下, SingleChildScrollView 只应用期望的内容不会超过屏幕太多时使用,这是因为其不支持 Sliver按需加载模型,(Android中的 ScrollView 也是不支持按需加载的),所以如果视口可能包含超出屏幕尺寸太多的内容时,使用这个Widget的代价是非常高的,此时就需要考虑使用按需加载的滚动组件
@override
Widget build(BuildContext context) {
String str = "ABCDEFGHIJKLMNOPGRSTUVWXYZ";
return Scrollbar(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Center(
child: Column(
// 动态创建一个 List
children:
// 每一个字母都用一个 Text 显示,字体为原来的两倍
str.split("").map((e) => Text(e, textScaleFactor: 2.0)).toList(),
),
),
));
ListView 是支持懒加载的可滚动组件,来看看 ListView 的定义:
class ListView extends BoxScrollView {
ListView({
....
Axis scrollDirection = Axis.vertical,
bool reverse = false,
ScrollController? controller,
bool? primary,
ScrollPhysics? physics,
bool shrinkWrap = false,
EdgeInsetsGeometry? padding,
// ListView 各个构造函数的共同参数
this.itemExtent,
// 列表项原型
this.prototypeItem,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
// 预渲染区域的长度
double? cacheExtent,
List<Widget> children = const <Widget>[],
})
下面来介绍一下重要的参数:
itemExtent
children
的长度为 itemExtent
的值,这里的长度是指滑动方向的长度。itemExtent
比让子组件自己决定自身长度有更好的性能,因为指定后,滚动系统可以提前知道列表的长度,而无需每次构建子组件的时候再去计算一下, 尤其是在滚动位置频繁变化的时prototypeItem
prototypeItem
(列表项原型)。指定后,可滚动组件在 layout 时计算一次它延主轴方向的长度,这样就预先知道了所有列表项的延主轴方向的长度,所以和指定 itemExtent
一样,指定后有更好的性能提升。 注意的是 两者是互斥的,不能同时指定shrinkWrap
ListView
的长度,默认为 falseaddAutomaticKeepAlives
addRepaintBoundaries
RepaintBoundary
组件中。这个组件可以先简单的理解为一个“绘制边界”,将列表项包裹在其中可以避免列表不必要的重绘。 但如果列表重绘的开销本来就小(例如是文本或者颜色块时),不用这个玩意反而会更加的高效。必须传入一个 children
参数来接受一个 Widget 列表,这种方式只适合子组件已知且数量比较少的情况,如下:
ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(20.0),
children: <Widget>[
const Text('AAA'),
const Text('BBB'),
const Text('CCC'),
const Text('DDD'),
],
);
但是我们平时开发时,也遇不到这种情况,按需加载一般用在子组件较多且未知数量、未知内容的情况下,这个时候,我们就需要使用 ListView.builder
或其他构造函数来解决
这也是一个构造函数,它的参数和默认的差不多,主要来看下它特有的参数:
required IndexedWidgetBuilder itemBuilder
itemCount
下面来看一个例子:
ListView.builder(
itemCount: 200,
itemExtent: 50.0,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("$index"));
}),
ListView.separated 可以在生成的列表项中间添加一个分割组件,它比 ListView.builder
多了一个 separtorBuilder
参数,用于生成分割组件
下面来看下列子,奇数行下面添加一条红色的线, 偶数行下面添加蓝色的线:
@override
Widget build(BuildContext context) {
Widget divider1 = const Divider(color: Colors.red);
Widget divider2 = const Divider(color: Colors.blue);
return Scaffold(
body: ListView.separated(itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("$index"));
}, separatorBuilder: (BuildContext context, int index) {
return index % 2 != 0 ? divider1 : divider2;
}, itemCount: 200),
);
}
功能点:
代码实现如下:
先导包,我们需要用到一个 english_words 的包,用于生成随机单词
// pubspec.yaml
dependencies:
english_words: ^4.0.0
class _ScrollWidgetRoteState extends State<ScrollerWidgetRoute> {
// 表尾标记
static const loadingTag = "##loading##";
// _words 是一个 String 数组,也是 ListView 的数据源
final _words = <String>[loadingTag];
@override
void initState() {
super.initState();
_retrieveData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.separated(
itemBuilder: (context, index) {
// 如果到了表尾
if (_words[index] == loadingTag) {
// 没有100条,继续获取数据
if (_words.length < 101) {
// 异步拉取数据, 用一个 loading 态展示
_retrieveData();
return Container(
padding: const EdgeInsets.all(18.0),
alignment: Alignment.center,
child: const SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(strokeWidth: 2.0)),
);
} else {
// 已经加载了 100条数据,不再获取数据
return Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(18.0),
child: const Text(
"没有更多了",
style: TextStyle(color: Colors.grey),
),
);
}
}
// 显示单词列表
return ListTile(title: Text(_words[index]));
},
separatorBuilder: (context, index) => const Divider(height: .0),
itemCount: _words.length));
}
void _retrieveData() {
Future.delayed(const Duration(seconds: 2)).then((value) {
setState(() {
// 重新构建列表
_words.insertAll(
_words.length - 1,
// 每次生成20个单词
generateWordPairs()
.take(20)
.map((e) => e.asPascalCase)
.toList());
});
});
}
}
有时候,我们需要给列表加一个固定表头。 这里抖机灵,实现了下面这样的代码:
Column(
children: [
const ListTile(title: Text("我是表头")),
ListView.builder(itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("$index"));
})
],
)
运行后,报错: Error: Cannot hit test a render box that has never been laid out.
这是因为 ListView 高度边界无法确定, 我们给其指定一个 SizedBox 试试:
..
SizedBox(
height: 400,
child: ListView.builder(
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("$index"));
}))
可以看到,我们设置了 400 大小的 ListView, 而屏幕的高度大于400, 会导致底部留白。
假如我们想要列表铺满表头以外的屏幕控件该怎么做?
最直观的就是我们动态去计算,使用 屏幕高度减去状态栏、导航栏、表头的高度就是剩余屏幕的高度,但这种做法不太美,也不够现实,因为布局发生变化,比如表头布局调整导致表头高度改变,那么剩余空间的高度就得重新计算。
这里的答案是使用 Flex
弹性布局, Expanded
组件的效果就是自动拉伸组件大小,因为 Column 是继承自 Flex 的,所以我们使用 Colum + Expanded 来配合实现:
Column(
children: [
const ListTile(title: Text("我是表头")),
Expanded(
child: ListView.builder(
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("$index"));
}))
],
)
接下来来学习 ScrollController
的用法
ScrollController
的构造函数如下:
class ScrollController extends ChangeNotifier {
ScrollController({
double initialScrollOffset = 0.0,
this.keepScrollOffset = true,
this.debugLabel,
})
下面是属性和常用方法介绍
initialScrollOffset
keepScrollOffset
offset
jumpTo(double offset)
、animateTo(double offset, ..)
ScrollController 间接继承自 Listenable
,所以可以根据其来监听滚动事件:
controller.addListener(()=>print(controller.offset))
我们创建一个 ListView
,当滚动位置发生变化时,我们先打印出当前的滚动位置,然后判断当前位置是否超过 1000像素,如果超过,则在屏幕右下角显示一个 “返回顶部” 的按钮,点击后可以使 ListView 恢复到初始位置,如果没有超过 1000 像素,则隐藏这个按钮。
代码实现如下:
class _ScrollerControllerRouteState extends State<ScrollControllerRoute> {
final ScrollController _controller = ScrollController();
//是否显示“返回到顶部”的按钮
bool showToTopBtn = false;
@override
void initState() {
super.initState();
// 监听滚动事件, 打印滚动事件
_controller.addListener(() {
print("offset: ${_controller.offset}");
if (_controller.offset < 1000 && showToTopBtn) {
setState(() {
showToTopBtn = false;
});
} else if (_controller.offset >= 1000 && !showToTopBtn) {
setState(() {
showToTopBtn = true;
});
}
});
}
@override
void dispose() {
// 避免内存泄漏,需要回收 ScrollerController
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("滚动控制")),
body: Scrollbar(
child: ListView.builder(
itemCount: 100,
itemExtent: 45,
controller: _controller,
itemBuilder: (context, index) {
return ListTile(title: Text("$index"));
}),
),
floatingActionButton: !showToTopBtn
? null
: FloatingActionButton(
onPressed: () {
// 回到顶部
_controller.animateTo(.0,
duration: const Duration(milliseconds: 200), curve: Curves.ease); // Curves 是动画曲线
},
child: const Icon(Icons.arrow_upward)),
);
}
}
PageStorage
是一个用于保存页面相关数据的组件,它并不会影响子树的UI外观,它是一个功能型组件,子树的 Widget 可以通过制定不同的 PageStorageKey
来存储各自的数据或状态。
每次滚动结束,可滚动组件都会滚动位置 offset
存储到 PageStorage
中, 当可滚动组件重新创建时再恢复, 如果 ScrollController.keepScrollOffset
为 false 时, 则滚动位置将不会被存储,可滚动组件重新创建的时候会使用 ScrollController.initialScrollOffset
; 为 true 时, 可滚动在 第一次 创建时,会滚动到 initialScrollOffset
处,在接下来的滚动中会存储、恢复滚动位置,而 initialScrollOffset
就会被忽略
当一个路由中包含多个可滚动组件时,如果发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这个时候就可以显示指定 PageStorageKey
来分别跟踪不同的可滚动组件的偏移,如:
ListView(key: PageStorageKey(1), ...);
...
ListView(key: PageStorageKey(2), ...);
不同的 PageStorageKey 需要不同的值,才可以做到分别监听
ScrollPosition
是用来保存可滚动组件的滚动位置的。 一个 ScrollController
对象可以同时被多个可滚动组件使用, ScrollController 会为每个滚动组件创建一个 ScrollPosition 对象,这些对象保存在 ScrollController 的 position
属性中。
ScrollPosition 是真正保存滑动位置信息的对象, offset
只是它的便捷属性而已:
double get offset => position.pixels;
假设我们的 ScrollController
被两个 可滚动Widget 使用, 我们又想分别读取两个Widget的偏移量, 这个时候就可以使用 ScrollPostion 来读取了:
controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels
实际上 ScrollController 调用的 animateTo()
、 jumpTo()
其实就是调用 ScrollPostion 的对应方法
来看下 ScrollController
的另外三个方法:
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition oldPosition);
void attach(ScrollPosition position);
void detach(ScrollPosition position);
当 ScrollController
和可滚动组件关联时:
ScrollController
的 createScrollPosition()
来创建一个 ScrollPostion
来存储滚动信息attach()
方法, 将创建的 ScrollPosition
添加到 ScrollController 的 position 属性animateTo
、 jumpTo()
的调用才会生效当可滚动组件销毁时:
ScrollController
的 detach()
方法animateTo
、 jumpTo()
的调用不会生效Flutter Widget 树中子 Widget 可以通过发送通知 (Notification) 与 父(包括祖先)Widget来通信。父组件则可以通过 NotificationListener
组件来监听自己关注的通知。
可滚动组件在滚动时会发送 ScrollNotification
类型的通知, ScrollBar
正是通过监听这个通知来实现。
通过 NotificationListener
监听滚动事件和 ScrollController
监听有两个主要的不同点:
下面我们通过监听 ListView
的滚动通知,然后显示当前滚动的百分比:
class _ScrollNotificationRouteState extends State<ScrollNotificationRoute> {
// 保存进度百分比
String _progress = "0%";
@override
Widget build(BuildContext context) {
return Scaffold(
body: 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;
},
child: Stack(
alignment: Alignment.center,
children: [
ListView.builder(
itemBuilder: (context, index) =>
ListTile(title: Text("$index")),
itemCount: 100,
itemExtent: 45),
CircleAvatar(
// 显示进度百分比
radius: 30.0,
child: Text(_progress),
backgroundColor: Colors.black45,
)
],
),
),
));
}
}
在接收到滚动事件时,参数类型为 ScrollNotification
,它包括一个 metrics
属性,它的类型是 ScrollMetrics
,该属性包含当前 Viewport 以及滚动位置信息,它的信息有:
pixels
maxScrollExtent
extentBefore
extentInside
extentAfter
atEdge