跳到主要内容

六、常用滚动组件

在Flutter中,Container widget 本身并不提供滚动功能。如果想创建一个可滑动的列表,应该使用专门用于滚动内容的widgets,如 ListViewCustomScrollView

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

SingleChildScrollView

SingleChildScrollView类似于Android和小程序中的ScrollView,它只能接收一个子组件:

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

SingleChildScrollView并没有一个默认的高度。它会尽可能地适应其父级widget的约束。如果SingleChildScrollView的父级没有提供明确的大小约束,SingleChildScrollView将会尽可能地大以适应所有的子widget。

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

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() {
runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('Scrollable 示例'),
),
body: Container(
color: Colors.blue,
width: double.infinity, // 撑满屏幕宽度
child: SizedBox(
height: 200, // 为SingleChildScrollView设置一个固定的高度
child: SingleChildScrollView(
child: Column(
children: List.generate(30, (index) => Text('Item $index')),
),
),
),
)
),
));
}

没有滚动条,可以使用Scrollbar控件将SingleChildScrollView进行包裹来实现

ListView

ListView是用于创建滚动列表的一个非常常用且强大的 widget。它可以水平或垂直显示其子 widget 列表,并且在列表项过多时可以高效地滚动。ListView 适用于显示一系列具有相同类型的数据项,例如文本列表、图片列表等。

ListView({
...
//可滚动widget公共参数
Axis scrollDirection = Axis.vertical,
bool reverse = false,
ScrollController? controller,
bool? primary,
ScrollPhysics? physics,
EdgeInsetsGeometry? padding,

//ListView各个构造函数的共同参数
double? itemExtent,
Widget? prototypeItem, //列表项原型,后面解释
bool shrinkWrap = false,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
double? cacheExtent, // 预渲染区域长度

//子widget列表
List<Widget> children = const <Widget>[],
})

ListView 的构造函数:

  • 默认构造函数:直接传递一个 children 列表。
  • ListView.builder:使用 ItemBuilder 函数动态创建子 widget。适用于数据项较多或数据项需要按需生成时,可以提高性能。
  • ListView.separated:类似于 ListView.builder,但可以在每两个列表项之间添加分隔符。
  • ListView.custom:使用自定义的 SliverChildDelegate 来创建列表,提供了最高的自定义灵活性。

ListView各个构造函数的参数:

属性类型描述
itemExtentdouble?可选地强制子项在滚动方向上具有给定的范围。当所有子项高度相同时,指定这个可以提高滚动性能。
prototypeItemWidget?用于确定每个子项大小的widget原型。这个原型项不会被绘制,但会被测量以获取子项的默认大小。
shrinkWrapbool决定视图是否应该尽可能小地包裹其内容的高度。
addAutomaticKeepAlivesbool是否将AutomaticKeepAlive包装在每个子项周围。
addRepaintBoundariesbool是否将RepaintBoundary包装在每个子项周围。
cacheExtentdouble?预渲染的区域长度,可以提前渲染位于当前视口之外但可能很快滚动到视口中的子项。

公共参数:

  • scrollDirection:滚动方向,默认为垂直方向 (Axis.vertical),也可以设置为水平方向 (Axis.horizontal)。
  • padding:列表的内边距。
  • reverse:是否反转列表的显示方向,例如在聊天应用中,可能需要反转列表,使最新消息显示在底部。
  • controller:控制列表滚动的 ScrollController
  • physics:滚动物理效果,如 BouncingScrollPhysicsClampingScrollPhysics 等。

ListView 默认构造函数

默认示例:

body: Container(width: double.infinity, height: 100, color: Colors.blue, child: 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,
...
})

ListView.builder(
itemCount: 100, // 列表项的数量
itemExtent: 50.0, //强制高度为50.0
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text('列表项 $index'));
},
)

ListView.separated

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

class ListView3 extends StatelessWidget {

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

ListView 原理

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

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

滚动监听及控制

ScrollController

ScrollController 在 Flutter 中用于控制可滚动的 widget,例如 ListViewGridViewSingleChildScrollView 等。它可以用来控制滚动位置,执行动画滚动,监听滚动事件等。

如何使用

  1. 先创建一个 ScrollController 并将其传递给可滚动的 widget:
ScrollController _scrollController = ScrollController();
  1. 将 ScrollController 与滚动视图关联
ListView(
controller: _scrollController,
children: [...],
)
  1. 使用 ScrollController,可以使用 ScrollController 做一些操作,如:
    • 查询滚动位置:_scrollController.offset
    • 滚动到特定位置:_scrollController.jumpTo(value)
    • 动画滚动到特定位置:_scrollController.animateTo(value, duration: Duration, curve: Curve)
    • 监听滚动事件:通过添加监听器 _scrollController.addListener(() { /* 监听滚动事件 */ });
    • 控制滚动到顶部或底部:_scrollController.jumpTo(_scrollController.position.minScrollExtent)_scrollController.jumpTo(_scrollController.position.maxScrollExtent)

示例


class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
home: MyHomePage(),
);
}
}

class MyHomePage extends StatefulWidget {

_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
ScrollController _scrollController = ScrollController();


void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels == 0) {
print('At the top');
} else {
print('At the bottom');
}
}
});
}


void dispose() {
_scrollController.dispose(); // Remember to dispose the controller
super.dispose();
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('ScrollController 示例'),
),
body: ListView.builder(
controller: _scrollController,
itemCount: 30,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text('列表项 $index'));
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.arrow_upward),
onPressed: () {
// 滚动到顶部
_scrollController.animateTo(
0,
duration: Duration(seconds: 1),
curve: Curves.easeOut,
);
},
),
);
}
}

NotificationListener

子Widget可以通过发送通知(Notification)与父(包括祖先)Widget通信。父级组件可以通过NotificationListener组件来监听自己关注的通知,这种通信方式类似于Web开发中浏览器的事件冒泡。

可滚动组件在滚动时会发送ScrollNotification类型的通知,ScrollBar正是通过监听滚动通知来实现的。通过NotificationListener监听滚动事件和通过ScrollController有两个主要的不同:

  1. NotificationListener可以在可滚动组件到widget树根之间任意位置监听。而ScrollController只能和具体的可滚动组件关联后才可以。
  2. 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置。

AnimatedList

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

如何使用

  1. 创建一个 GlobalKey<AnimatedListState> 来与 AnimatedList 的状态关联。
  2. 使用 AnimatedList widget,并通过 key 属性将其与上面创建的 GlobalKey 关联。
  3. 通过调用 AnimatedListStateinsertItemremoveItem 方法来控制列表项的动画插入和移除。
  4. itemBuilder 函数中返回列表项的widget,并处理动画。
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
home: MyHomePage(),
);
}
}

class MyHomePage extends StatefulWidget {

_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
List<String> _items = ['Item 1', 'Item 2', 'Item 3'];

void _addItem() {
final int index = _items.length;
_items.add('Item ${_items.length + 1}');
_listKey.currentState?.insertItem(index);
}

void _removeItem(int index) {
final String removedItem = _items.removeAt(index);
_listKey.currentState?.removeItem(
index,
(context, animation) => _buildItem(removedItem, animation),
);
}

Widget _buildItem(String item, Animation<double> animation) {
return SizeTransition(
sizeFactor: animation,
child: ListTile(
title: Text(item),
onTap: () => _removeItem(_items.indexOf(item)),
),
);
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('AnimatedList 示例'),
),
body: AnimatedList(
key: _listKey,
initialItemCount: _items.length,
itemBuilder: (context, index, animation) {
return _buildItem(_items[index], animation);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: _addItem,
),
);
}
}

GridView

GridView 是 Flutter 中用于创建网格布局的一个滚动widget。它可以创建多列布局,并且每列中的项可以是任意的widget。GridView 对于构建产品列表、图库、键盘、九宫格等布局非常有用。

构造函数

  • GridView:最基本的构造函数,需要显式指定children列表。
  • GridView.count:可以指定列数的构造函数,自动为你创建网格。
  • GridView.builder:通过itemBuilder动态创建子项的构造函数,适用于具有大量(或无限)子项的网格,因为它只构建那些实际可见的子项。
  • GridView.custom:使用自定义的SliverGridDelegateSliverChildDelegate
  • GridView.extent:可以指定最大子项宽度的构造函数,网格列数将根据屏幕宽度和子项最大宽度动态计算。

主要属性

  • gridDelegate:控制网格布局的策略。通常使用SliverGridDelegateWithFixedCrossAxisCount(固定列数)或SliverGridDelegateWithMaxCrossAxisExtent(最大子项宽度)。
  • children:静态子项列表。
  • itemBuilder:动态构建子项的函数。
  • scrollDirection:滚动方向,默认垂直滚动。
  • crossAxisCount:列数(GridView.count构造函数),此属性值确定后子元素在横轴的长度就确定了,即ViewPort横轴长度除以crossAxisCount的商。
  • maxCrossAxisExtent:最大子项宽度(GridView.extent构造函数)。
GridView.count(
crossAxisCount: 3, // 每行三列
children: List.generate(20, (index) {
return Center(
child: Text('Item $index'),
);
}),
)

GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 每行三列
),
itemCount: 100, // 100个子项
itemBuilder: (BuildContext context, int index) {
return Center(
child: Text('Item $index'),
);
},
)

SliverGridDelegateWithFixedCrossAxisCount

该子类实现了一个横轴为固定数量子元素的layout算法,其构造函数为:

SliverGridDelegateWithFixedCrossAxisCount({
double crossAxisCount,
double mainAxisSpacing = 0.0,
double crossAxisSpacing = 0.0,
double childAspectRatio = 1.0,
})
  • crossAxisCount:横轴子元素的数量。此属性值确定后子元素在横轴的长度就确定了,即ViewPort横轴长度除以crossAxisCount的商。
  • mainAxisSpacing:主轴方向的间距。
  • crossAxisSpacing:横轴方向子元素的间距。
  • childAspectRatio:子元素在横轴长度和主轴长度的比例。由于crossAxisCount指定后,子元素横轴长度就确定了,然后通过此参数值就可以确定子元素在主轴的长度。

SliverGridDelegateWithMaxCrossAxisExtent

该子类实现了一个横轴子元素为固定最大长度的layout算法,其构造函数为:

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

maxCrossAxisExtent为子元素在横轴上的最大长度,之所以是“最大”长度,是因为横轴方向每个子元素的长度仍然是等分的,举个例子,如果ViewPort的横轴长度是450,那么当maxCrossAxisExtent的值在区间(450/4,450/3)内的话,子元素最终实际长度都为112.5,而childAspectRatio所指的子元素横轴和主轴的长度比为最终的长度比。其他参数和SliverGridDelegateWithFixedCrossAxisCount相同。

示例 justify-content: space-betweenspace-around

通过调整网格内边距(padding)和网格间距(crossAxisSpacingmainAxisSpacing)来模拟这种效果。

crossAxisSpacingmainAxisSpacing分别控制列之间和行之间的间距。如果想在网格的两侧有空隙,可以在GridViewpadding属性中添加水平方向上的内边距

body: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 每行三列
crossAxisSpacing: 8.0, // 列间距
mainAxisSpacing: 8.0, // 行间距
),
padding: EdgeInsets.symmetric(horizontal: 8.0), // 水平内边距,两侧空隙--注释该行实现space-between
itemCount: 20, // 20个子项
itemBuilder: (BuildContext context, int index) {
return Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8.0),
),
child: Text('Item $index'),
);
},
)

PageView - 整屏滚动

PageView 通常用于构建引导页、图片轮播、使用滑动来切换不同的视图等场景。

PageView 是一个可滚动的列表,允许水平滚动或垂直滚动来切换页面。每个子widget通常是使用整个屏幕的页面。它用于实现滑动切换页面的效果,比如大多数 App 都包含 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能等等,类似于 Android 中的 ViewPager 或 iOS 中的 UIPageViewController。

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,
})
import 'package:flutter/material.dart';

void main() {
runApp(const MaterialApp(
home: MyApp(),
));
}

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


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Scrollable 示例'),
),
body: MyHomePage());
}
}

class MyHomePage extends StatefulWidget {

_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

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

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

final String text;


_PageState createState() => _PageState();
}

class _PageState extends State<Page> {

Widget build(BuildContext context) {
print("build ${widget.text}");
return Center(child: Text("${widget.text}", textScaleFactor: 5));
}
}

页面缓存

PageView 创建的每个页面默认会在不可见时从widget树中卸载,然后在滚动回来时重新创建。如果你想要PageView在页面切换时缓存页面,可以使用AutomaticKeepAliveClientMixin,这样当页面滑出视窗时,它的状态仍然会被保留

class MyPage extends StatefulWidget {

_MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> with AutomaticKeepAliveClientMixin {

bool get wantKeepAlive => true; // 是否希望保持页面存活


Widget build(BuildContext context) {
super.build(context); // 需要调用super.build
// 构建页面内容
}
}

TabBarView

TabBarView 在 Flutter 中是与 TabBar 一起使用的,用于创建带有对应标签页的滑动视图。每个 TabBarView 的子widget通常对应一个 TabBar 项,用户可以通过点击 TabBar 项或者水平滑动 TabBarView 来切换页面。

TabBarView 的每个子页面都是懒加载的,这意味着页面会在它们即将显示在视图中时才会被创建。因为TabBarView 内部封装了 PageView,如果要缓存页面,可以参考 6.8 可滚动组件子项缓存 | 《Flutter实战·第二版》

TabBarView 默认不会保持其页面的状态。如果你的页面需要保持状态(例如滚动位置),你可以将每个页面的根widget包装在 AutomaticKeepAlive widget 中

TabBarView

TabBarView({
Key? key,
required this.children, // tab 页 与 `TabBar` 的标签数量相匹配的 widget 列表
this.controller, // TabController,用于协调 `TabBar` 和 `TabBarView` 之间的选项卡选择。如果你使用 `DefaultTabController`,则不需要手动提供这个
this.physics, // 滚动物理效果,如 `BouncingScrollPhysics`、`ClampingScrollPhysics` 等。
this.dragStartBehavior = DragStartBehavior.start,
})

TabBar

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

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

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

使用 TabController

创建了TabController之后,需要将它同时传递给TabBarTabBarView

_tabController = TabController(vsync: this, length: 3); // 3个标签页

TabBar(
controller: _tabController,
tabs: myTabs,
),
TabBarView(
controller: _tabController,
children: myTabViews,
),

简单示例

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
home: DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
bottom: TabBar(
tabs: [
Tab(icon: Icon(Icons.directions_car)),
Tab(icon: Icon(Icons.directions_transit)),
],
),
title: Text('Tabs Demo'),
),
body: TabBarView(
children: [
Icon(Icons.directions_car),
Icon(Icons.directions_transit),
],
),
),
),
);
}
}

CustomScrollView

目前对于还未入门的我来说有点超纲,且目前需求可能使用率不高,mark一下先:6.10 CustomScrollView 和 Slivers | 《Flutter实战·第二版》