基于ListView实现水平和垂直方式滚动的列表
垂直列表
核心就在于 ListView
,它负责把一组子 Widget 按垂直方向排列并提供滚动能力。
-
使用
ListView
组件
ListView
是 Flutter 提供的可滚动列表视图,默认滚动方向为 垂直 (scrollDirection: Axis.vertical
默认值)。只要其内容超过父布局可视范围,就会自动启用滚动。 -
通过
children
传入多条子组件示例中这里用
ListView(children: _buildList())
的方式一次性把所有列表项以数组形式交给ListView
。_buildList()
把cityNames
中的每个字符串转换成一个_item
组件(Container
+Text
),形成一个List<Widget>
。ListView
会按顺序把这些子组件从 上到下 依次布局,超出视口的部分由内部的Scrollable
机制负责滑动显示。
-
滚动行为由
Scrollable
&Viewport
自动管理
ListView
内部组合了Scrollable
和Viewport
:- 当手指在屏幕垂直拖动时,
Scrollable
捕获手势并改变内部ScrollPosition
。 Viewport
根据新的滚动偏移(offset)决定哪些子组件渲染到屏幕、哪些保持在缓存区,从而实现流畅、高效的惰性渲染。
- 当手指在屏幕垂直拖动时,
-
垂直方向声明式特性
因为未显式指定
scrollDirection
,ListView
会使用默认的垂直方向。在需要水平列表时才需显式写scrollDirection: Axis.horizontal
。
dart
import 'package:flutter/material.dart';
/// 需要在列表中展示的城市名称集合
const cityNames = [
'北京', '上海', '广州', '深圳', '杭州', '苏州',
'成都', '武汉', '郑州', '洛阳', '厦门', '青岛', '拉萨'
];
/// 垂直滚动列表页面
class VerticalListPage extends StatelessWidget {
const VerticalListPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const title = 'list'; // AppBar 标题
return MaterialApp( // 整个应用的根组件
title: title,
home: Scaffold( // 提供页面的基本可视结构:AppBar + Body
appBar: AppBar(
title: const Text(title),
),
// ListView 默认方向为垂直 (Axis.vertical)
body: ListView(
// children 接收一个 Widget 列表,这里由 _buildList() 生成
children: _buildList(),
),
),
);
}
/// 将城市名称映射(map)为一个个列表项组件
List<Widget> _buildList() {
return cityNames // 遍历 cityNames
.map((city) => _item(city)) // 每个城市生成一个 _item
.toList(); // 转成 List<Widget>
}
/// 单个列表项(80 px 高、底色为 Amber、文字白色)
Widget _item(String city) {
return Container(
height: 80,
margin: const EdgeInsets.only(bottom: 5), // 每个条目底部留 5 px 间距
alignment: Alignment.center,
decoration: const BoxDecoration(color: Colors.amber),
child: Text(
city,
style: const TextStyle(color: Colors.white, fontSize: 20),
),
);
}
}

水平列表
核心要点 :在 ListView
中把 scrollDirection
改成 Axis.horizontal
就能"变横";其余都是布局细节(限定高度、设置子项宽度、留空隙等)来保证 UI 协调。
dart
import 'package:flutter/material.dart';
/// 城市名称列表
const cityNames = [
'北京', '上海', '广州', '深圳', '杭州', '苏州', '成都', '武汉', '郑州', '洛阳', '厦门', '青岛', '拉萨'
];
/// 水平滚动列表示例页
class HorizontalListPage extends StatelessWidget {
const HorizontalListPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const title = '水平'; // AppBar 标题
return MaterialApp(
title: title,
home: Scaffold(
appBar: AppBar(title: const Text(title)),
// 仅为 ListView 限定高度,防止占满屏幕导致溢出
body: SizedBox(
height: 200,
child: ListView(
// ★ 关键:将主轴方向改成水平
scrollDirection: Axis.horizontal,
// children 接受一组子 Widget
children: _buildList(),
),
),
),
);
}
/// 将城市名称转换成 Widget 列表
List<Widget> _buildList() {
// map: 把每个 city 字符串映射成 _item(city) 小部件
return cityNames.map((city) => _item(city)).toList();
}
/// 单个城市条目
Widget _item(String city) {
return Container(
width: 160, // 定宽,让水平滚动更自然
margin: const EdgeInsets.only(right: 5), // 条目间隔
alignment: Alignment.center,
decoration: const BoxDecoration(color: Colors.amber), // 背景色
child: Text(
city,
style: const TextStyle(color: Colors.white, fontSize: 20),
),
);
}
}

可展开的列表
ExpansionTile
利用状态切换 + 尺寸动画 (SizeTransition
) 来控制子列表显隐,从而在 ListView
中实现可展开/折叠的分组列表
ExpansionTile
实现可展开列表的原理
-
内部状态管理
ExpansionTile
是一个StatefulWidget
。- 它内部维护一个布尔型
_isExpanded
状态,用于记录当前是「展开」还是「折叠」。 - 在标题(
ListTile
+InkWell
包裹)被点击时,切换_isExpanded
并调用setState()
触发重建。
- 它内部维护一个布尔型
-
动画效果
-
展开/折叠动画通过
ExpandIcon
+RotationTransition
+SizeTransition
实现:ExpandIcon
(箭头)旋转角度与_isExpanded
绑定。- 子节点(
children
)被包在ClipRect
→Align
→SizeTransition
中,SizeTransition
的axisAlignment
为-1.0
,只在垂直方向拉伸,形成上下收起 / 展开动画。
-
默认动画曲线使用
Curves.easeInOut
,时长 200 ms,用户可通过animationDuration
参数自定义。
-
-
惰性渲染
- 当
children
处于折叠状态,内部SizeTransition
的尺寸为 0,高度为 0,不会占用可见空间;但是 Widget 树仍然存在,有轻微内存占用。 - 若想在折叠时完全释放子树资源,可结合
ExpansionPanelList
或自行实现「懒加载」逻辑。
- 当
-
与
ListView
的协同ListView
本身负责垂直滚动。ExpansionTile
放在ListView
的子节点列表中,只要总高度超出视口,Scrollable
机制会按需滚动。- 展开某个城市时,
ExpansionTile
内部的高度变化会通知ListView
重新布局,从而出现平滑的「列表撑开」效果。
-
自定义能力
- 通过
trailing
、leading
等参数可定制标题区域外观; - 通过
backgroundColor
、tilePadding
等参数可改变布局与配色; - 若需一次只能展开一项,可结合
ExpansionPanelList.radio
或在外层统一管理_isExpanded
状态。
- 通过
dart
import 'package:flutter/material.dart';
/// 一个 Map,键为城市,值为该城市下辖的区县(子列表)
const cityNames = {
'北京': ['东城区', '西城区', '朝阳区', '丰台区', '石景山区', '海淀区', '顺义区'],
'上海': ['黄浦区', '徐汇区', '长宁区', '静安区', '普陀区', '闸北区', '虹口区'],
'广州': ['越秀', '海珠', '荔湾', '天河', '白云', '黄埔', '南沙', '番禺'],
'深圳': ['南山', '福田', '罗湖', '盐田', '龙岗', '宝安', '龙华'],
'杭州': ['上城区', '下城区', '江干区', '拱墅区', '西湖区', '滨江区'],
'苏州': ['姑苏区', '吴中区', '相城区', '高新区', '虎丘区', '工业园区', '吴江区']
};
/// 页面:使用 ExpansionTile 构建可展开/折叠的列表
class ExpansionTilePage extends StatelessWidget {
const ExpansionTilePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const title = '可展开的列表';
return MaterialApp(
title: title,
home: Scaffold(
appBar: AppBar(title: const Text(title)),
// ListView 外层滚动容器
body: ListView(
// 将所有城市条目组合成 children
children: _buildList(),
),
),
);
}
/// 根据 cityNames 生成城市条目列表
List<Widget> _buildList() {
List<Widget> widgets = [];
for (var key in cityNames.keys) {
// 每个城市对应一个 ExpansionTile
widgets.add(_item(key, cityNames[key]!));
}
return widgets;
}
/// 单个城市条目(可展开)
Widget _item(String city, List<String> subCities) {
return ExpansionTile(
// 标题区域(始终可见)
title: Text(
city,
style: const TextStyle(fontSize: 20, color: Colors.black54),
),
// children:展开后展示的子 Widget 列表
children: subCities
.map((subCity) => _buildSub(subCity))
.toList(), // 将子区县转换为 Widget
);
}
/// 子列表条目(区县)
Widget _buildSub(String subCity) {
return FractionallySizedBox(
widthFactor: 1, // 让子项宽度占满父级 ExpansionTile 的宽度
child: Container(
height: 50,
margin: const EdgeInsets.only(bottom: 5),
decoration: const BoxDecoration(color: Colors.amber),
alignment: Alignment.centerLeft,
child: Text(
subCity,
style: const TextStyle(fontSize: 16, color: Colors.white),
),
),
);
}
}

基于GridView实现网格布局
GridView.count
通过固定列数 + SliverGrid
代理,自动计算单元格尺寸并在滚动时按需加载,轻松实现性能友好的网格布局;我们只需决定列数、间距及子项 Widget 即可。
GridView.count
实现网格布局的原理
组件层级 | 作用 |
---|---|
GridView |
一种可 滚动 的二维布局容器,内部使用 Sliver 机制实现惰性渲染。 |
GridView.count |
GridView 的便捷构造函数,通过 crossAxisCount 指定横轴单元格数量(即列数)。 |
SliverGrid |
GridView 背后的核心,负责把列表数据排布成网格。 |
SliverChildBuilderDelegate / SliverChildListDelegate |
根据滑动位置按需创建 / 销毁子项,避免一次生成全部子组件导致性能浪费。 |
工作流程
-
布局规则(
SliverGridDelegate
)-
GridView.count
内部创建SliverGridDelegateWithFixedCrossAxisCount
:crossAxisCount=2
→ 横轴固定 2 列。- 计算每个格子的 宽度 :
(可用宽度 - 总水平间距) / 2
。 - 高度默认与宽度等比;若要自定义可通过
childAspectRatio
控制。
-
-
惰性加载
GridView
继承自BoxScrollView
→Scrollable
。- 当页面滚动时,
Viewport
只会要求SliverGrid
创建可见范围内的子项,其余部分延迟构建或回收,节省内存与布局时间。
-
轴向解释
- 主轴 (
mainAxis
) = 滚动方向(本例为垂直方向)。 - 交叉轴 (
crossAxis
) = 与滚动方向垂直的轴(横向)。 crossAxisCount=2
表示交叉轴上固定 2 单元格;主轴方向无限延展,超过视口部分即可滚动。
- 主轴 (
-
间距与外边距
- 子项自身
margin
负责条目间外间距。 - 若需整体网格内边距可用
padding
:GridView.count(padding: EdgeInsets.all(8), ...)
。
- 子项自身
-
自定义方案
- 除
GridView.count
外,还可使用GridView.builder
(按需构建)或SliverGrid
+CustomScrollView
手动组合更复杂的瀑布流 / 不等宽高场景。
- 除
dart
import 'package:flutter/material.dart';
/// 需要在网格中展示的城市名称
const cityNames = [
'北京', '上海', '广州', '深圳', '杭州', '苏州',
'成都', '武汉', '郑州', '洛阳', '厦门', '青岛', '拉萨'
];
/// 基于 GridView 构建的网格布局页面
class GridViewPage extends StatelessWidget {
const GridViewPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const title = 'GridView'; // AppBar 标题
return MaterialApp(
title: title,
home: Scaffold(
appBar: AppBar(title: const Text(title)),
body: GridView.count(
// crossAxisCount 指定一行放两个格子
crossAxisCount: 2,
// 通过 _buildList() 生成所有网格单元
children: _buildList(),
),
),
);
}
/// 将 cityNames 转换为 Widget 列表
List<Widget> _buildList() {
return cityNames.map((city) => _item(city)).toList();
}
/// 单个网格子项
Widget _item(String city) {
return Container(
height: 80,
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
alignment: Alignment.center,
decoration: const BoxDecoration(color: Colors.amber),
child: Text(
city,
style: const TextStyle(fontSize: 20, color: Colors.white),
),
);
}
}

高级功能列表下拉刷新与上拉加载更多功能实现
下拉刷新依赖 RefreshIndicator
内置手势检测与动画;上拉加载则通过监听滚动位置自主触发异步数据追加。两者结合即可实现常见移动端列表的「下拉翻新 + 滑到底部自动分页」。
功能实现原理
功能 | 关键组件 / API | 工作机制 |
---|---|---|
下拉刷新 | RefreshIndicator |
内部通过 ScrollNotification 监听垂直拖拽距离,当滑动到列表顶部再继续下拉且达到阈值 ≈ 80 px 时,触发回调 onRefresh ;随后显示内置的「水滴→圆环」动画,等待返回的 Future 完成后收起。 |
上拉加载 | ScrollController 或 NotificationListener<ScrollNotification> |
判断 position.pixels == position.maxScrollExtent ⇒ 已到底部,再调用自定义 _loadData() ;此方法通常发起网络请求,将新数据添加到数据源后 setState() 刷新 UI。 |
细节说明
-
ScrollController 与 ListView
- 通过
controller
属性将ScrollController
绑定到ListView
; ScrollController.position
提供当前滚动相关信息,如pixels
、maxScrollExtent
、minScrollExtent
;- 滑动过程中触发监听器
_scrollController.addListener()
。
- 通过
-
避免重复加载
- 增加
_isLoadingMore
标志,异步结束前屏蔽再次触发; - 若使用分页接口,还需维护当前页码 / 是否还有更多数据等状态。
- 增加
-
资源释放
ScrollController
属于ChangeNotifier
,需要在dispose()
中dispose()
防止内存泄漏。
-
性能优化
- 列表项较多时,推荐改用
ListView.builder
(惰性构建)。 - 加载时可在列表尾部插入"加载中"占位 Widget,提升用户感知。
- 列表项较多时,推荐改用
-
进阶做法
- 使用第三方库如 pull_to_refresh 、flutter_easyrefresh,可获得更丰富的刷新指示器与分页控制;
- 使用
ScrollNotification
统一处理顶部 / 底部各种状态,逻辑更集中。
dart
import 'package:flutter/material.dart';
/// 高级功能:下拉刷新 & 上拉加载更多
class RefreshLoadMorePage extends StatefulWidget {
const RefreshLoadMorePage({Key? key}) : super(key: key);
@override
State<RefreshLoadMorePage> createState() => _RefreshLoadMorePageState();
}
class _RefreshLoadMorePageState extends State<RefreshLoadMorePage> {
/// 用于监听滚动位置,判断是否滑到底部
final ScrollController _scrollController = ScrollController();
/// 列表数据源
List<String> cityNames = [
'北京', '上海', '广州', '深圳', '杭州', '苏州',
'成都', '武汉', '郑州', '洛阳', '厦门', '青岛', '拉萨',
];
/// 是否正在加载更多,避免重复触发
bool _isLoadingMore = false;
@override
void initState() {
super.initState();
// 监听滚动事件:当像素位置 == 最大滚动范围,说明滑到底部
_scrollController.addListener(_onScroll);
}
/// 释放资源,避免内存泄漏
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
/// 滚动监听回调
void _onScroll() {
// 已经到底部且当前不在加载状态
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent &&
!_isLoadingMore) {
_loadData(); // 触发上拉加载
}
}
@override
Widget build(BuildContext context) {
const title = '下拉刷新 + 上拉加载';
return MaterialApp(
title: title,
home: Scaffold(
appBar: AppBar(title: const Text(title)),
// RefreshIndicator 实现下拉刷新
body: RefreshIndicator(
onRefresh: _handleRefresh, // 下拉手势触发的方法
child: ListView(
controller: _scrollController, // 绑定滚动控制器
children: _buildList(), // 构建列表项
),
),
),
);
}
/// 将 cityNames 转为 Widget 列表
List<Widget> _buildList() {
return cityNames.map(_item).toList();
}
/// 单个条目样式
Widget _item(String city) {
return Container(
height: 80,
margin: const EdgeInsets.only(bottom: 5),
alignment: Alignment.centerLeft,
decoration: const BoxDecoration(color: Colors.redAccent),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(city, style: const TextStyle(fontSize: 20, color: Colors.white)),
),
);
}
/* ---------------- 下拉刷新 ---------------- */
/// RefreshIndicator 要求返回 Future<void>
Future<void> _handleRefresh() async {
// 模拟网络请求 2 秒
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
setState(() {
// 简单演示:把列表倒序,以便看出刷新效果
cityNames = cityNames.reversed.toList();
});
}
/* ---------------- 上拉加载更多 ---------------- */
Future<void> _loadData() async {
_isLoadingMore = true;
// 给用户一点"加载中"的时间感
await Future.delayed(const Duration(milliseconds: 800));
if (!mounted) return;
setState(() {
// 演示:往末尾再追加一份数据
cityNames = List<String>.from(cityNames)..addAll(cityNames);
});
_isLoadingMore = false;
}
}
