是什么
在 Flutter 里,PageView 是一个可以在页面之间水平(或垂直)滑动的组件。它和 ListView 有点像,区别在于:
ListView用来展示一长串连续的滚动内容。PageView用来展示一页一页的内容(通常是全屏或大块区域),可以左右(或上下)翻页。
可以把它想象成 手机屏幕的左右滑动页面容器。
使用场景
- App 引导页(首次安装时左右滑动的介绍页面)
- Banner 轮播图(电商首页广告)
- 分页内容(比如标签页对应的内容页)
- 幻灯片 / 图片浏览器
常用属性
PageView({
Key? key,
Axis scrollDirection = Axis.horizontal, // 滚动方向,水平/垂直
bool reverse = false, // 是否反向
PageController? controller, // 控制器
ScrollPhysics? physics, // 滚动物理特性(比如禁止滑动)
bool pageSnapping = true, // 是否一页一页停靠(false时可自由停留)
List<Widget> children = const <Widget>[],// 静态子组件
int? itemCount, // 动态数量(builder模式)
required IndexedWidgetBuilder itemBuilder,// 动态构建
ValueChanged<int>? onPageChanged, // 页面切换时回调
})
翻页方向
scrollDirection: Axis.horizontal(默认水平)scrollDirection: Axis.vertical(上下翻页)
控制翻页
controller: PageController(...): 用PageController控制初始页、当前页、跳转。
反向
reverse: true→ 从右往左翻,而不是默认的左往右。
滑动物理特性
physics: NeverScrollableScrollPhysics()→ 禁止手动滑动,只能代码切换。physics: BouncingScrollPhysics()→ iOS 弹性效果。physics: ClampingScrollPhysics()→ Android 端边缘停止。
是否一页一页停靠
pageSnapping: true(默认) → 一次滑动自动停在整页。false→ 可以停在页面中间。
数据来源
children: [ ... ]→ 静态页面列表。一次性构建所有页面,哪怕用户永远看不到后面的。PageView.builder(itemCount: ..., itemBuilder: ...)→ 动态构建,适合大量数据。 懒加载,只在需要时才调用itemBuilder。
监听页面切换
onPageChanged: (index) { ... }→ 翻页时触发(PageController的animateToPage方法执行后)。
什么时候执行 itemBuilder
PageView 第一次创建时
- Flutter 需要渲染出“当前页 + 相邻页”(一般是前后各 1 页)。
- 所以
itemBuilder会被调用几次,去构建这些需要显示的子 Widget。
翻页的时候
- 当你滑动到新的一页时,PageView 会触发新的
itemBuilder调用,用来构建还没构建过的页面。 - 同时,它可能会回收远离当前的页面,以节省内存(类似 ListView 的懒加载)。
setState 导致 rebuild 时
- 整个
build()方法会再次执行,PageView 也会被“重新声明”。 - 这时,
itemBuilder会再次被调用来描述页面内容。 - 但 Flutter 框架会 复用旧的 Element/RenderObject,所以性能不会受太大影响。
PageView.builder(
itemCount: 5,
itemBuilder: (context, index) {
print("构建页面 $index");
return Center(child: Text("Page $index"));
},
)
启动时:假设初始在第 0 页,控制台会打印:
构建页面 0 构建页面 1 // 因为相邻页要预加载滑动到第 1 页:
构建页面 2滑动到第 2 页:
构建页面 3
可以看到,它是 按需构建,不是一次性全构建。
itemCount 的作用
在 PageView.builder 里:
PageView.builder({
required IndexedWidgetBuilder itemBuilder,
int? itemCount,
...
})
itemCount告诉 PageView 一共有多少个页面。- 当你指定了
itemCount,PageView 在调用itemBuilder(context, index)时,index会从0开始,最大到itemCount - 1。 - 超过范围的 index,PageView 不会再请求。
可以理解为: itemCount 决定了 PageView 的最大长度,限制了 itemBuilder 的 index 范围。
PageView 内部在管理 页数 的时候,会用到 itemCount,但我们在外面看不到。
- 如果你没传
itemCount,理论上PageView.builder就会当成“无限页数”,会不停地往后请求新的itemBuilder(index)。当 index 超过imgList.length - 1时,就会访问 数组越界 ❌,直接抛出异常。 - 如果你传了
itemCount = 3,它就只会构建 index0,1,2,滑到2再往后滑,就没东西了。也就是说,PageView 在最后一页停住,不会越界。
PageController 的方法和属性
PageController({
int initialPage = 0, // 初始页
bool keepPage = true, // 是否记住上次停留的页
double viewportFraction = 1.0, // 每页占屏幕的比例
});
常用属性
page→ 当前的 精确页码(可以是小数,比如滑动中在 1.3 页)。initialPage→ 初始页索引。viewportFraction→ 每个页面占屏幕的比例(默认 1.0,改成 0.8 会出现“中间放大、两边露出”的效果)。
常用方法
jumpToPage(int page)→ 立刻跳转到某页。animateToPage(int page, {duration, curve})→ 动画切换到某页。int page:目标页码(从0开始计数)。例如:0表示第一页,1表示第二页。Duration duration:动画持续时间。类型是Duration,必须传。Curve curve:动画曲线,决定动画的速度变化。Curves.linear→ 匀速。Curves.easeIn→ 开始慢,结束快。Curves.easeOut→ 开始快,结束慢。Curves.easeInOut(常用) → 两头慢,中间快。Curves.bounceOut→ 结束时带弹跳效果。Curves.elasticOut→ 带弹性回弹。
nextPage({duration, curve})→ 切换到下一页。previousPage({duration, curve})→ 切换到上一页。hasClients→ 是否有 PageView 绑定到这个控制器。
PageView实现轮播图
代码
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(home: const PageViewDemo());
}
}
class PageViewDemo extends StatefulWidget {
const PageViewDemo({super.key});
@override
_PageViewDemoState createState() => _PageViewDemoState();
}
class _PageViewDemoState extends State<PageViewDemo> {
final PageController _controller = PageController();
final List<String> imgList = [
'https://picsum.photos/800/400?img=1',
'https://picsum.photos/800/400?img=2',
'https://picsum.photos/800/400?img=3',
];
int _currentPage = 0;
@override
void initState() {
super.initState();
// 自动播放逻辑
Future.delayed(Duration.zero, () {
Future.doWhile(() async {
await Future.delayed(Duration(seconds: 3));
// controller.hasClients: 是否有 PageView 绑定到这个控制器
if (_controller.hasClients) {
// _currentPage: 当前页面索引
// nextPage: 下一个页面索引
// 这里取模% imgList.length:主要是为了循环播放,比如到2的时候,就会回到0
int nextPage = (_currentPage + 1) % imgList.length;
_controller.animateToPage(
nextPage,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
return true;
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("PageView Demo")),
// 创建 PageView
body: PageView.builder(
// 绑定控制器
controller: _controller,
// itemCount: 图片数量
itemCount: imgList.length,
// onPageChanged: 页面改变时的回调
onPageChanged: (index) => setState(() => _currentPage = index),
// itemBuilder: 构建每个页面
// required NullableIndexedWidgetBuilder itemBuilder,
// typedef NullableIndexedWidgetBuilder = Widget? Function(BuildContext context, int index);
itemBuilder: (context, index) {
return Image.network(imgList[index], fit: BoxFit.cover);
},
),
);
}
}
实际执行顺序
- 构造
_controller(此时没有 PageView 绑定)。 - 进入
initState,注册Future.delayed(Duration.zero, () { ... })。 - 执行
build(),PageView.builder创建并绑定_controller。 - 某些情况下,
doWhile里的代码可能会比 “PageView attach 完全” 先跑。- 这时候
hasClients == false→ 不会调用 animateToPage。
- 这时候
- 下一次循环(3 秒后),此时
PageView已经 attach →hasClients == true→ 可以正常调用animateToPage。 - 调用
_controller.animateToPage(nextPage, ...)→ 触发 PageView 滑动动画。 - PageView 内部会在翻页结束时调用
onPageChanged(index)。这个index就是刚刚跳转到的目标页,也就是animateToPage(nextPage, ...)中的nextPage。 - 触发 当前 widget 重建 →
build()重新执行
PageView + PageController + itemBuilder 的协作机制
最后一页显示完成
- 比如现在在
index = 2(imgList.length - 1)。
定时器触发
代码执行:
int nextPage = (_currentPage + 1) % imgList.length; // 当 _currentPage = 2 时,nextPage = 0 _controller.animateToPage(0, ...);
PageController 通知 PageView
animateToPage(0, …)会让 PageView 滚动到index = 0的位置。
PageView.builder 调用 itemBuilder
PageView 内部检测到需要显示
index = 0的页面。它会调用:
itemBuilder(context, 0)所以又去执行
Image.network(imgList[0])。
页面切换完成
PageView 会触发
onPageChanged(0),你在里面写了:setState(() => _currentPage = index);_currentPage更新为 0,下一次定时器计算nextPage又能正确运行
总结:
itemBuilder只会在 PageView 需要显示某个页面 时才调用。当
animateToPage(0)把页面滚回第一页时,PageView 需要显示index = 0,就会 重新调用itemBuilder生成第一页的 widget。所以,即使逻辑上看是“循环”,本质上还是在不同的索引间来回切换。