在 Flutter 开发中,轮播图(Banner)是首页广告、商品推荐、活动展示的核心组件。原生 PageView 需手动实现自动播放、指示器联动、图片加载等逻辑,重复开发易导致体验不一致。本文封装的 BannerWidget 整合 "自动播放 + 循环滚动 + 指示器自定义 + 图片加载优化" 四大核心能力,适配本地 / 网络图片、支持手势控制,一行代码即可集成。
一、核心优势(精准解决开发痛点)
- 自动播放内置:默认开启自动轮播,支持自定义时长、暂停 / 恢复逻辑,滑动时自动暂停,贴合用户操作习惯
- 指示器全自定义:支持圆点 / 数字 / 进度条三种指示器样式,颜色、大小、间距均可配置,位置可自由切换(底部 / 顶部)
- 图片加载优化:支持本地 / 网络图片,自动处理加载占位、失败降级,支持圆角裁剪与缩放模式配置
- 交互体验流畅:支持循环滚动、左右滑动手势,点击事件回调,滑动动画曲线可自定义
- 高适配强鲁棒:自动适配屏幕宽度,支持深色模式,参数断言校验避免非法配置,无内存泄漏
二、核心配置速览(关键参数一目了然)
| 配置分类 | 核心参数 | 核心作用 |
|---|---|---|
| 必选配置 | images: List<String>、onTap: Function(int) |
图片列表(本地路径 / 网络 URL)、点击回调(返回索引) |
| 播放配置 | autoPlay、autoPlayDuration、loop、animationCurve |
自动播放、播放时长、循环滚动、动画曲线 |
| 指示器配置 | indicatorType、indicatorColor、indicatorSelectedColor、indicatorPosition |
指示器类型、默认颜色、选中颜色、显示位置 |
| 图片样式配置 | imageRadius、imageFit、placeholder、errorWidget |
图片圆角、缩放模式、占位组件、失败组件 |
| 适配配置 | adaptDarkMode、height、padding |
深色模式适配、轮播图高度、内边距 |
三、生产级完整代码(可直接复制,开箱即用)
dart
import 'package:flutter/material.dart';
import 'dart:async';
/// 指示器类型枚举
enum BannerIndicatorType {
dot, // 圆点指示器(默认)
number, // 数字指示器(如"1/5")
progress, // 进度条指示器
}
/// 指示器位置枚举
enum BannerIndicatorPosition {
bottom, // 底部(默认)
top, // 顶部
}
/// 通用轮播图组件
class BannerWidget extends StatefulWidget {
// 必选参数
final List<String> images; // 图片列表(本地路径以"asset://"开头,否则为网络图片)
final Function(int) onTap; // 图片点击回调(参数:当前图片索引)
// 播放配置
final bool autoPlay; // 是否自动播放(默认true)
final Duration autoPlayDuration; // 自动播放时长(默认3秒)
final bool loop; // 是否循环滚动(默认true)
final Curve animationCurve; // 滑动动画曲线(默认线性)
final bool pauseOnTouch; // 触摸时暂停播放(默认true)
// 指示器配置
final bool showIndicator; // 是否显示指示器(默认true)
final BannerIndicatorType indicatorType; // 指示器类型
final Color indicatorColor; // 指示器默认颜色
final Color indicatorSelectedColor; // 指示器选中颜色
final double indicatorSize; // 指示器大小(圆点直径/数字字号/进度条高度)
final double indicatorSpacing; // 指示器间距(仅圆点类型)
final BannerIndicatorPosition indicatorPosition; // 指示器位置
final EdgeInsetsGeometry indicatorPadding; // 指示器内边距
// 图片样式配置
final double height; // 轮播图高度(默认200px)
final double imageRadius; // 图片圆角(默认0)
final BoxFit imageFit; // 图片缩放模式(默认cover)
final Widget? placeholder; // 图片加载占位组件
final Widget? errorWidget; // 图片加载失败组件
// 适配配置
final bool adaptDarkMode; // 适配深色模式(默认true)
final EdgeInsetsGeometry padding; // 轮播图内边距(默认无)
const BannerWidget({
super.key,
required this.images,
required this.onTap,
// 播放配置
this.autoPlay = true,
this.autoPlayDuration = const Duration(seconds: 3),
this.loop = true,
this.animationCurve = Curves.linear,
this.pauseOnTouch = true,
// 指示器配置
this.showIndicator = true,
this.indicatorType = BannerIndicatorType.dot,
this.indicatorColor = Colors.white38,
this.indicatorSelectedColor = Colors.white,
this.indicatorSize = 8.0,
this.indicatorSpacing = 6.0,
this.indicatorPosition = BannerIndicatorPosition.bottom,
this.indicatorPadding = const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
// 图片样式配置
this.height = 200.0,
this.imageRadius = 0.0,
this.imageFit = BoxFit.cover,
this.placeholder,
this.errorWidget,
// 适配配置
this.adaptDarkMode = true,
this.padding = EdgeInsets.zero,
}) : assert(images.isNotEmpty, "图片列表不可为空"),
assert(autoPlayDuration.inMilliseconds > 500, "自动播放时长需大于500ms");
@override
State<BannerWidget> createState() => _BannerWidgetState();
}
class _BannerWidgetState extends State<BannerWidget> {
late PageController _pageController;
late Timer? _autoPlayTimer;
int _currentIndex = 0;
bool _isTouching = false;
// 实际数据源(循环模式下前后添加哨兵元素)
List<String> get _actualImages => widget.loop
? [...widget.images, widget.images.first]
: widget.images;
@override
void initState() {
super.initState();
_pageController = PageController(
initialPage: widget.loop ? 1 : 0,
viewportFraction: 1.0,
);
_initAutoPlayTimer();
}
@override
void dispose() {
_autoPlayTimer?.cancel();
_pageController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant BannerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.images != oldWidget.images ||
widget.autoPlay != oldWidget.autoPlay ||
widget.autoPlayDuration != oldWidget.autoPlayDuration) {
_autoPlayTimer?.cancel();
_initAutoPlayTimer();
}
}
/// 初始化自动播放计时器
void _initAutoPlayTimer() {
if (!widget.autoPlay) return;
_autoPlayTimer = Timer.periodic(widget.autoPlayDuration, (_) {
if (_isTouching) return;
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: widget.animationCurve,
);
});
}
/// 处理页面滚动回调
void _onPageChanged(int index) {
if (!widget.loop) {
setState(() => _currentIndex = index);
return;
}
// 循环模式下处理边界
if (index == 0) {
// 滚动到最左侧哨兵元素,切换到最后一张
_currentIndex = widget.images.length - 1;
_pageController.jumpToPage(widget.images.length);
} else if (index == widget.images.length + 1) {
// 滚动到最右侧哨兵元素,切换到第一张
_currentIndex = 0;
_pageController.jumpToPage(1);
} else {
setState(() => _currentIndex = index - 1);
}
}
/// 深色模式颜色适配
Color _adaptDarkMode(Color lightColor, Color darkColor) {
if (!widget.adaptDarkMode) return lightColor;
return MediaQuery.platformBrightnessOf(context) == Brightness.dark
? darkColor
: lightColor;
}
/// 构建图片组件(支持本地/网络图片)
Widget _buildImage(String imageUrl, int index) {
final isAsset = imageUrl.startsWith("asset://");
final actualUrl = isAsset ? imageUrl.replaceFirst("asset://", "") : imageUrl;
// 占位组件
final placeholderWidget = widget.placeholder ?? Container(
color: _adaptDarkMode(const Color(0xFFF5F5F5), const Color(0xFF3D3D3D)),
child: const Center(child: Icon(Icons.image_outlined, size: 40, color: Colors.grey)),
);
// 失败组件
final errorWidget = widget.errorWidget ?? Container(
color: _adaptDarkMode(const Color(0xFFF5F5F5), const Color(0xFF3D3D3D)),
child: const Center(child: Icon(Icons.error_outlined, size: 40, color: Colors.redAccent)),
);
// 实际图片组件
Widget imageWidget;
if (isAsset) {
imageWidget = Image.asset(
actualUrl,
fit: widget.imageFit,
errorBuilder: (_, __, ___) => errorWidget,
);
} else {
imageWidget = Image.network(
actualUrl,
fit: widget.imageFit,
placeholder: (_, __) => placeholderWidget,
errorBuilder: (_, __, ___) => errorWidget,
);
}
// 图片容器(添加圆角、点击事件)
return GestureDetector(
onTap: () => widget.onTap(_currentIndex),
onTapDown: (_) => _isTouching = widget.pauseOnTouch,
onTapUp: (_) => _isTouching = false,
onTapCancel: () => _isTouching = false,
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.imageRadius),
child: imageWidget,
),
);
}
/// 构建指示器组件
Widget _buildIndicator() {
if (!widget.showIndicator || widget.images.length <= 1) return const SizedBox.shrink();
final adaptedIndicatorColor = _adaptDarkMode(
widget.indicatorColor,
const Color(0xFF666666),
);
final adaptedSelectedColor = _adaptDarkMode(
widget.indicatorSelectedColor,
Colors.white70,
);
switch (widget.indicatorType) {
case BannerIndicatorType.dot:
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.images.length, (index) {
final isSelected = index == _currentIndex;
return Container(
width: isSelected ? widget.indicatorSize * 2 : widget.indicatorSize,
height: widget.indicatorSize,
margin: EdgeInsets.symmetric(horizontal: widget.indicatorSpacing / 2),
decoration: BoxDecoration(
color: isSelected ? adaptedSelectedColor : adaptedIndicatorColor,
borderRadius: BorderRadius.circular(widget.indicatorSize / 2),
),
);
}),
);
case BannerIndicatorType.number:
return Text(
"${_currentIndex + 1}/${widget.images.length}",
style: TextStyle(
color: adaptedSelectedColor,
fontSize: widget.indicatorSize,
fontWeight: FontWeight.w500,
),
);
case BannerIndicatorType.progress:
return Container(
height: widget.indicatorSize,
width: 80,
decoration: BoxDecoration(
color: adaptedIndicatorColor,
borderRadius: BorderRadius.circular(widget.indicatorSize / 2),
),
child: FractionallySizedBox(
widthFactor: (_currentIndex + 1) / widget.images.length,
child: Container(
color: adaptedSelectedColor,
borderRadius: BorderRadius.circular(widget.indicatorSize / 2),
),
),
);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: widget.padding,
child: Container(
height: widget.height,
width: double.infinity,
child: Stack(
alignment: widget.indicatorPosition == BannerIndicatorPosition.bottom
? Alignment.bottomCenter
: Alignment.topCenter,
children: [
// 轮播图主体
PageView.builder(
controller: _pageController,
itemCount: _actualImages.length,
onPageChanged: _onPageChanged,
physics: const BouncingScrollPhysics(),
itemBuilder: (context, index) => _buildImage(_actualImages[index], index),
),
// 指示器(带背景遮罩)
Padding(
padding: widget.indicatorPadding,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _adaptDarkMode(Colors.black26, Colors.black45),
borderRadius: BorderRadius.circular(12),
),
child: _buildIndicator(),
),
),
],
),
),
);
}
}
四、三大高频场景落地示例(直接复制到项目可用)
场景 1:首页广告轮播(网络图片 + 圆点指示器)
适用场景:APP 首页广告、活动宣传、Banner 图展示
dart
// 首页顶部广告轮播
BannerWidget(
images: [
"https://example.com/banner1.jpg",
"https://example.com/banner2.jpg",
"https://example.com/banner3.jpg",
],
onTap: (index) {
debugPrint("点击第${index+1}张广告");
// 跳转至广告详情页
Navigator.push(
context,
MaterialPageRoute(builder: (context) => BannerDetailPage(index: index)),
);
},
height: 220,
imageRadius: 12,
autoPlayDuration: const Duration(seconds: 4),
indicatorColor: Colors.white54,
indicatorSelectedColor: Colors.white,
indicatorSize: 6,
indicatorSpacing: 8,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
adaptDarkMode: true,
);
场景 2:商品推荐轮播(本地图片 + 数字指示器)
适用场景:商品详情页推荐、本地资源轮播展示
dart
// 商品详情页推荐轮播
BannerWidget(
images: [
"asset://assets/images/product1.jpg",
"asset://assets/images/product2.jpg",
"asset://assets/images/product3.jpg",
],
onTap: (index) {
debugPrint("点击第${index+1}个推荐商品");
// 切换商品详情图
setState(() => currentProductImageIndex = index);
},
height: 180,
imageFit: BoxFit.contain,
autoPlay: false, // 手动滑动,不自动播放
loop: false, // 不循环
showIndicator: true,
indicatorType: BannerIndicatorType.number,
indicatorSize: 14,
indicatorPosition: BannerIndicatorPosition.top,
indicatorPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
placeholder: Container(color: const Color(0xFFF8F8F8)),
);
场景 3:进度条指示器轮播(活动倒计时 + 进度条)
适用场景:限时活动展示、带有进度提示的轮播场景
dart
// 限时活动轮播
BannerWidget(
images: [
"https://example.com/event1.jpg",
"https://example.com/event2.jpg",
],
onTap: (index) {
debugPrint("点击第${index+1}个活动");
Navigator.push(context, MaterialPageRoute(builder: (context) => EventPage(index: index)));
},
height: 160,
autoPlayDuration: const Duration(seconds: 5),
showIndicator: true,
indicatorType: BannerIndicatorType.progress,
indicatorSize: 3,
indicatorColor: Colors.white30,
indicatorSelectedColor: Colors.orangeAccent,
imageRadius: 8,
padding: const EdgeInsets.symmetric(horizontal: 16),
adaptDarkMode: true,
);
五、核心封装技巧(复用成熟设计思路)
- 循环滚动实现 :通过在数据源前后添加 "哨兵元素",配合
PageController.jumpToPage实现无缝循环,避免原生PageView边界卡顿 - 自动播放控制 :使用
Timer.periodic实现自动播放,触摸时暂停、松开恢复,提升交互体验 - 指示器多类型支持:通过枚举封装三种常用指示器,样式参数独立配置,兼顾通用性与个性化
- 图片加载优化:区分本地 / 网络图片,支持占位与失败降级,避免网络波动导致的 UI 异常
- 状态安全管理:组件销毁时取消计时器与控制器,避免内存泄漏;页面更新时重新初始化播放逻辑
六、避坑指南(解决 90% 开发痛点)
- 图片路径规范 :本地图片需以 "asset://" 开头,且在
pubspec.yaml中配置资源路径;网络图片需确保 URL 有效,建议配置placeholder - 循环模式注意 :循环模式下
_actualImages长度比原列表多 2(前后哨兵),PageController初始页码需设为 1,避免首次加载显示哨兵元素 - 自动播放时长:建议自动播放时长设置为 3-5 秒,过短易导致用户来不及查看,过长影响交互体验
- 轮播图高度:轮播图高度建议根据场景固定(如首页广告 200-250px),避免自适应高度导致布局抖动
- 深色模式兼容 :自定义指示器颜色时需通过
_adaptDarkMode方法适配,避免深色模式下颜色冲突 - 性能优化:图片数量建议控制在 3-5 张,过多会增加内存占用;网络图片建议开启缓存,减少重复请求