flutter:PageView与轮播图

是什么

在 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) { ... } → 翻页时触发(PageControlleranimateToPage方法执行后)。

什么时候执行 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,它就只会构建 index 0,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);
        },
      ),
    );
  }
}

实际执行顺序

  1. 构造 _controller(此时没有 PageView 绑定)。
  2. 进入 initState,注册 Future.delayed(Duration.zero, () { ... })
  3. 执行 build()PageView.builder 创建并绑定 _controller
  4. 某些情况下doWhile 里的代码可能会比 “PageView attach 完全” 先跑。
    • 这时候 hasClients == false → 不会调用 animateToPage。
  5. 下一次循环(3 秒后),此时 PageView 已经 attach → hasClients == true → 可以正常调用 animateToPage
  6. 调用 _controller.animateToPage(nextPage, ...) → 触发 PageView 滑动动画。
  7. PageView 内部会在翻页结束时调用 onPageChanged(index)。这个 index 就是刚刚跳转到的目标页,也就是animateToPage(nextPage, ...)中的 nextPage
  8. 触发 当前 widget 重建build() 重新执行

PageView + PageController + itemBuilder 的协作机制

最后一页显示完成

  • 比如现在在 index = 2imgList.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

  • 所以,即使逻辑上看是“循环”,本质上还是在不同的索引间来回切换。

×

喜欢就点赞,疼爱就打赏