PageView 是 App 里经典的翻页组件——引导页、轮播图、横向翻阅的内容卡片。ArkTS 里是 Swiper 组件,Flutter 里是 PageView,核心功能类似,但 Flutter 的 PageView 更偏底层,提供了 builder 模式、PageController 和自定义过渡的能力。
这篇覆盖 4 个场景:基础 PageView + 圆点指示器、自动轮播(Timer + 3秒间隔)、纵向翻页、自定义过渡效果(缩放 + 淡入淡出)。
PageView 最简用法——一个 PageController 控制当前页,PageView.builder 高效构建:
final PageController _ctrl = PageController(initialPage: 0);
int _currentPage = 0;
PageView.builder(
controller: _ctrl,
onPageChanged: (i) => setState(() => _currentPage = i),
itemCount: _pages.length,
itemBuilder: (_, i) => _PageCard(page: _pages[i]),
)
PageController 控制当前页面,onPageChanged 在页面切换完成时回调。PageView.builder 跟 ListView.builder 一样,只构建当前可见项。
PageController(initialPage: 0) 可以指定初始页面,initialPage 默认是 0。
翻页通常需要底部圆点指示当前在第几页。Flutter 没有内置的指示器组件,自己手写一个很轻量:
Widget _buildIndicator(int count, int current, Color color) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
return AnimatedContainer(
duration: Duration(milliseconds: 200),
margin: EdgeInsets.symmetric(horizontal: 3),
width: current == i ? 20 : 8,
height: 8,
decoration: BoxDecoration(
color: current == i ? color : color.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
);
}),
);
}
AnimatedContainer 让圆点的尺寸在选中/非选中态之间平滑过渡——选中时变成长条形(宽 20),未选中时是圆形(宽 8)。和直接 switch 图标相比,视觉上温和不少。
如果觉得手写麻烦,也可以用 smooth_page_indicator 或 dots_indicator 等第三方包,支持更多样式。
很多 App 首页需要有自动播放的轮播图。用 Timer.periodic 定时翻页,配合 PageController.animateToPage:
Timer? _timer;
void _startCarousel() {
_timer = Timer.periodic(Duration(seconds: 3), (_) {
if (!mounted) return;
final next = (_carouselPage + 1) % _pages.length;
_carouselCtrl.animateToPage(
next,
duration: Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
});
}
(current + 1) % total 实现循环翻页——翻到最后一页后回到第一页。animateToPage 带 400ms 的动画,曲线用 easeInOut,开始和结束时慢,中间快。
数据少时的视觉效果。如果只有 3-5 页,这种轮播看起来就是前三后二的无限循环。更正式的无限循环需要伪无限列表(比如 itemCount 设一个大数),但 demo 里做了简化。
mounted 检查。异步回调里一定要检查 mounted,因为 dispose 后 Timer 可能还在跑。
Timer 的清理。dispose 中调用 _timer?.cancel(),防止组件销毁后 Timer 还在触发 setState。
PageView 默认是横向滑动。设 scrollDirection: Axis.vertical 变成纵向翻页:
PageView.builder(
controller: _verticalCtrl,
scrollDirection: Axis.vertical,
onPageChanged: (i) => setState(() => _verticalPage = i),
itemCount: _pages.length,
itemBuilder: (_, i) => _PageCard(page: _pages[i]),
)
纵向翻页适合"上下翻阅文章"或"纵向轮播"的场景。注意纵向时,子 Widget 的高度必须固定或通过 Flex 撑满,否则滑动区域不对。
PageView 的 scrollDirection 只有 Axis.horizontal 和 Axis.vertical,不能对角线滑动。
默认的 PageView 翻页是硬切——页面从右侧滑入,左侧滑出。如果需要类似缩放 + 淡入淡出的效果,可以用 PageView.transformation 构造器,或者自己在 PageView.builder 里加 LayoutBuilder + Transform。
demo 里用 LayoutBuilder 获取每页的位置信息,再叠加视觉效果。不过更标准的做法是用 PageView.custom + SliverPage 或者用 transformationController:
PageView.builder(
itemCount: _pages.length,
// 每个卡片自带圆角渐变背景
itemBuilder: (_, i) {
return Container(
margin: EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(...),
),
child: ...,
);
},
)
在 demo 里"自定义过渡"区域,卡片用 LayoutBuilder 获取自身布局信息,但 Flutter 的 PageView 默认没有暴露当前页面偏移量的百分比。如果要实现类似 iOS 的「缩放上一页」效果,需要配合 TransformationController 或自定义 Flow。
一个常见的自定义过渡是"当前页放大,相邻页缩小",可以用 PageView.builder + MediaQuery + Transform.scale 实现:
PageView.builder(
itemCount: _pages.length,
itemBuilder: (_, i) {
return Transform.scale(
scale: 0.9, // 所有页面 0.9 倍
child: _PageCard(page: _pages[i]),
);
},
)
更精确的过渡需要知道每页的 scroll offset,可以用 PageController 的 position 属性获取,然后在 addListener 里计算。
viewportFraction 控制每页占视口的比例。默认 1.0(全屏),小于 1.0 时可以看到相邻页的边缘,适合卡片式翻页:
PageController(
viewportFraction: 0.85, // 每页占屏幕 85%
)
设 0.85 后,当前页左右两侧各露出 7.5% 的相邻页边缘,给人一种「后面还有内容」的视觉暗示。这在电商推荐卡片、文章列表翻页中很常见。
配合 Padding 使用效果更好——不要用 EdgeInsets.all 给 PageView 加内边距,而是给每一页加 margin:
Container(
margin: EdgeInsets.symmetric(horizontal: 8),
child: _PageCard(...),
)
这样相邻页露出的边缘不会被裁剪掉。
PageView 和 TabBar 是经典组合——TabBar 做标签,PageView 做内容,两者通过同一个 TabController 同步:
TabController _tabCtrl;
TabBar(
controller: _tabCtrl,
tabs: [Tab(text: '推荐'), Tab(text: '热门')],
)
PageView(
controller: PageController(),
// 需要通过监听 _tabCtrl 来同步
)
不过更常用的方式是 TabBarView,它是 PageView 的上层封装,自动跟 TabBar 同步,不需要手写 PageController。
默认情况下,PageView 只构建当前页和左右相邻页(共 3 页)。如果每页的内容比较重(网络图片、复杂列表),滑动到下一页时可能会出现短暂的空白。
allowImplicitScrolling 参数控制是否允许预渲染更多页:
PageView.builder(
allowImplicitScrolling: true,
...
)
设为 true 后,PageView 会在用户滑动过程中预渲染更多页面,减少滑到下一页时的构建延迟。代价是内存占用增加。
如果需要更精细的缓存控制,用 PageView.custom + SliverChildBuilderDelegate 的 addAutomaticKeepAlives 参数。
PageView 也可以跟底部导航联动,实现类似"首页/发现/消息/我的"的左右滑动切换:
PageView(
controller: _pageCtrl,
onPageChanged: (i) => setState(() => _navIndex = i),
children: [
HomePage(),
ExplorePage(),
MessagesPage(),
ProfilePage(),
],
)
BottomNavigationBar(
currentIndex: _navIndex,
onTap: (i) => _pageCtrl.animateToPage(i, ...),
items: [...],
)
这种方式跟 IndexedStack 的区别在于:PageView 有滑动手势,用户可以用手指左右滑动切换 tab;IndexedStack 只能通过点击底部导航切换。用哪种取决于产品设计——需要滑动手势用 PageView,保持状态用 IndexedStack。
| 参数 | 用途 | 示例 |
|---|---|---|
initialPage | 初始页面 | 0 |
keepPage | 是否保持页面状态 | true |
viewportFraction | 每页占比(<1 时可见相邻页) | 0.9 |
page | 当前页(只读) | — |
| 参数 | 用途 | 示例 |
|---|---|---|
controller | 页面控制器 | PageController |
scrollDirection | 滑动方向 | Axis.horizontal |
onPageChanged | 页面切换回调 | (int) => void |
itemCount | 页面数量 | 5 |
itemBuilder | 构建每页 | (context, index) => Widget |
physics | 滚动物理 | PageScrollPhysics() |
pageSnapping | 是否吸附对齐 | true |
allowImplicitScrolling | 允许显式滚动 | true |
| 特性 | Flutter | ArkTS |
|---|---|---|
| 组件 | PageView | Swiper |
| 自动轮播 | 需 Timer 手写 | autoPlay: true + interval |
| 指示器 | 手写或第三方包 | indicator 参数内置 |
| 无限循环 | 需手写伪无限 | loop: true |
| 纵向 | scrollDirection | vertical: true |
| 自定义过渡 | PageController + Transform | customInterval + displayCount |
| 每页占比 | viewportFraction | displayCount |
| 页面缓存 | allowImplicitScrolling | 默认缓存 |
ArkTS 的 Swiper 是开箱即用的轮播组件,自带自动播放、指示器、循环模式。Flutter 的 PageView 更底层的,自动轮播和指示器需要自己组合。
Flutter 的优势在于灵活性——你可以在 PageView 里放任意复杂的 Widget 树,而不只是图片。ArkTS 的 Swiper 偏图片轮播场景。
PageController 的 initialPage 在页面重建后丢失。PageController 是 StatefulWidget 里的资源,页面重建后重新初始化,initialPage 不会持久化。如果需要在用户切走再回来时保持页面,要用 AutomaticKeepAliveClientMixin。
PageView 在 Column 里没有高度。PageView 需要父级给高度,放在 Column 里要用 Expanded 或固定高度包着。放在 ListView 或 SingleChildScrollView 里也会有问题,因为 PageView 的可滚动区域跟 ListView 冲突。
Timer 的 mounted 检查。自动轮播的 Timer.periodic 在组件销毁后仍会触发 setState,引起 Flutter 异常。必须在回调里检查 mounted,并在 dispose 时取消 Timer。
AnimatedContainer 的圆点宽度动画。如果 PageView 滑动很快,圆点宽度切换的动画可能跟不上。AnimatedContainer 的 duration 是 200ms,如果 PageView 在 400ms 内切换多页,圆点会有跳跃感。
viewportFraction 导致页面不是全宽。设 viewportFraction: 0.85 后,PageView 的每页只占屏幕 85% 宽度,可以看到上下页的边缘。这在列表/卡片翻页时好看,但不适合全屏轮播图。
PageView 在 build 时 animateToPage 没效果。animateToPage 在 Widget 树构建阶段不生效,需要用 WidgetsBinding.instance.addPostFrameCallback 延迟调用。
Flutter 的 PageView 比 ArkTS 的 Swiper 需要多写一些代码——自动轮播、指示器都要自己拼。但好处是灵活,PageView 不限于图片轮播,你可以放任何一个 Widget 页面。
花时间最多的是圆点指示器的动画,AnimatedContainer 的选中/未选中宽度切换效果出乎意料地好,比静态圆点看着舒服很多。这个模式可以复用到其他需要指示器的场景。
自动轮播的 Timer 和 mounted 检查是个常规套路——任何异步操作在组件销毁后都不应该触发更新。这个模式在 Slider 加载更多、网络请求等场景里也通用。
自动轮播的间隔。3-4 秒是常见的自动轮播间隔,太短(< 2 秒)用户看不清内容,太长(> 6 秒)用户会觉得卡住了。demo 里用的 3 秒。
用户触摸时暂停轮播。自动轮播在用户手动滑动时应该暂停,松开后等待几秒再继续。用 GestureDetector 检测触摸事件,在 onTapDown 时取消 Timer,在 onTapUp 或 onPageChanged 后重启。
指示器跟页面数量匹配。如果页面数量动态变化(比如网络数据返回后才知道有几页),指示器也要跟着更新。List.generate 在 setState 后会自动重建。
不要用 PageView 做长列表。PageView 的每页通常是全屏或接近全屏的内容,适合做翻页、轮播、引导。如果内容是滚动列表,应该用 ListView 或 CustomScrollView,而不是在 PageView 里嵌套 ListView。
下篇来写动画相关的内容,把隐式动画和显式动画的基础说清楚。
原文作者:;如有侵权,请联系管理员删除。