轮播图是 Flutter 应用高频组件(首页 Banner、商品推荐、广告展示),原生无现成轮播组件,手动实现需处理自动播放、滑动手势、指示器联动、生命周期管理等复杂逻辑。本文封装的BannerSlider,整合 "自动轮播 + 手动滑动 + 指示器 + 点击回调 + 样式自定义" 五大核心能力,一行代码即可搭建功能完整、体验流畅的轮播图,适配 90%+ 展示场景!
一、核心需求拆解
✅ 自动轮播:支持自定义轮播时长、自动暂停(滑动时 / 页面隐藏时)✅ 手动交互:支持左右滑动切换,滑动时暂停自动轮播,滑动结束恢复✅ 指示器联动:内置圆点 / 数字指示器,支持自定义位置、颜色、大小✅ 内容灵活:支持网络图片、本地图片、自定义 Widget 作为轮播项✅ 样式定制:支持轮播图高度、圆角、边距、阴影等样式配置✅ 交互反馈:支持轮播项点击回调,返回当前索引✅ 生命周期安全:页面隐藏时暂停轮播,显示时恢复,避免内存泄漏
二、完整代码实现(可直接复制使用)
dart
import 'package:flutter/material.dart';
import 'dart:async';
/// 轮播图组件(支持自动轮播、指示器、自定义样式)
class BannerSlider extends StatefulWidget {
// 必选参数
final List<Widget> items; // 轮播项列表(支持任意Widget)
final Function(int index) onTap; // 轮播项点击回调
// 可选参数:轮播配置
final int initialIndex; // 初始选中索引(默认0)
final Duration autoPlayDuration; // 自动轮播时长(默认3秒)
final bool autoPlay; // 是否自动轮播(默认true)
final bool loop; // 是否循环播放(默认true)
final ScrollPhysics physics; // 滚动物理效果(默认禁止回弹)
// 可选参数:样式配置
final double height; // 轮播图高度(默认200)
final double borderRadius; // 圆角半径(默认0)
final EdgeInsetsGeometry padding; // 内边距(默认无)
final BoxShadow? shadow; // 阴影(默认无)
// 可选参数:指示器配置
final bool showIndicator; // 是否显示指示器(默认true)
final IndicatorType indicatorType; // 指示器类型(默认圆点)
final Color indicatorActiveColor; // 选中指示器颜色(默认蓝色)
final Color indicatorNormalColor; // 未选中指示器颜色(默认灰色)
final double indicatorSize; // 指示器大小(默认8)
final double indicatorSpacing; // 指示器间距(默认8)
final IndicatorAlignment indicatorAlignment; // 指示器位置(默认底部居中)
final EdgeInsetsGeometry indicatorMargin; // 指示器边距(默认底部16)
const BannerSlider({
super.key,
required this.items,
required this.onTap,
this.initialIndex = 0,
this.autoPlayDuration = const Duration(seconds: 3),
this.autoPlay = true,
this.loop = true,
this.physics = const ClampingScrollPhysics(),
this.height = 200.0,
this.borderRadius = 0.0,
this.padding = EdgeInsets.zero,
this.shadow,
this.showIndicator = true,
this.indicatorType = IndicatorType.dot,
this.indicatorActiveColor = Colors.blue,
this.indicatorNormalColor = Colors.grey,
this.indicatorSize = 8.0,
this.indicatorSpacing = 8.0,
this.indicatorAlignment = IndicatorAlignment.bottomCenter,
this.indicatorMargin = const EdgeInsets.only(bottom: 16.0),
}) : assert(items.isNotEmpty, "轮播项列表不能为空");
@override
State<BannerSlider> createState() => _BannerSliderState();
}
// 指示器类型枚举
enum IndicatorType { dot, number }
// 指示器位置枚举
enum IndicatorAlignment { topCenter, bottomCenter, topRight, bottomRight }
class _BannerSliderState extends State<BannerSlider> with WidgetsBindingObserver {
late PageController _pageController;
late int _currentIndex;
Timer? _autoPlayTimer;
bool _isPageVisible = true; // 页面是否可见
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: _currentIndex);
_startAutoPlay(); // 启动自动轮播
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_pageController.dispose();
_autoPlayTimer?.cancel();
super.dispose();
}
/// 页面可见性监听:隐藏时暂停轮播,显示时恢复
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
_isPageVisible = state == AppLifecycleState.resumed;
if (_isPageVisible && widget.autoPlay) {
_startAutoPlay();
} else {
_stopAutoPlay();
}
}
/// 启动自动轮播
void _startAutoPlay() {
if (!widget.autoPlay || widget.items.length <= 1) return;
_stopAutoPlay(); // 先停止现有定时器
_autoPlayTimer = Timer.periodic(widget.autoPlayDuration, (timer) {
if (!_isPageVisible) return;
if (widget.loop) {
// 循环播放:自动滚动到下一页,最后一页跳回第一页
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
// 非循环播放:滚动到最后一页后停止
if (_currentIndex < widget.items.length - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
});
}
/// 停止自动轮播
void _stopAutoPlay() {
_autoPlayTimer?.cancel();
_autoPlayTimer = null;
}
/// 页面切换回调:更新当前索引
void _onPageChanged(int index) {
setState(() {
_currentIndex = index % widget.items.length; // 处理循环播放时的索引
});
}
/// 构建轮播项(包装点击事件)
Widget _buildBannerItem(Widget item, int index) {
return GestureDetector(
onTap: () => widget.onTap(_currentIndex),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: item,
),
);
}
/// 构建指示器
Widget _buildIndicator() {
if (!widget.showIndicator || widget.items.length <= 1) return const SizedBox.shrink();
final int itemCount = widget.items.length;
return Padding(
padding: widget.indicatorMargin,
child: Align(
alignment: _getIndicatorAlignment(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(itemCount, (index) {
final isActive = index == _currentIndex;
return Padding(
padding: EdgeInsets.symmetric(horizontal: widget.indicatorSpacing / 2),
child: widget.indicatorType == IndicatorType.dot
? _buildDotIndicator(isActive)
: _buildNumberIndicator(isActive, index + 1, itemCount),
);
}),
),
),
);
}
/// 获取指示器对齐方式
Alignment _getIndicatorAlignment() {
switch (widget.indicatorAlignment) {
case IndicatorAlignment.topCenter:
return Alignment.topCenter;
case IndicatorAlignment.bottomRight:
return Alignment.bottomRight;
case IndicatorAlignment.topRight:
return Alignment.topRight;
default:
return Alignment.bottomCenter;
}
}
/// 构建圆点指示器
Widget _buildDotIndicator(bool isActive) {
return Container(
width: isActive ? widget.indicatorSize * 2 : widget.indicatorSize,
height: widget.indicatorSize,
decoration: BoxDecoration(
color: isActive ? widget.indicatorActiveColor : widget.indicatorNormalColor,
borderRadius: BorderRadius.circular(widget.indicatorSize / 2),
),
);
}
/// 构建数字指示器
Widget _buildNumberIndicator(bool isActive, int current, int total) {
return Text(
"$current/$total",
style: TextStyle(
color: isActive ? widget.indicatorActiveColor : widget.indicatorNormalColor,
fontSize: widget.indicatorSize * 1.2,
fontWeight: isActive ? FontWeight.w500 : FontWeight.normal,
),
);
}
@override
Widget build(BuildContext context) {
// 循环播放时,在列表首尾添加额外项,实现无缝滚动
final List<Widget> displayItems = widget.loop && widget.items.length > 1
? [widget.items.last, ...widget.items, widget.items.first]
: widget.items;
return Container(
height: widget.height,
padding: widget.padding,
decoration: BoxDecoration(
boxShadow: widget.shadow != null ? [widget.shadow!] : null,
),
child: Stack(
children: [
PageView(
controller: _pageController,
physics: widget.physics,
itemCount: displayItems.length,
onPageChanged: _onPageChanged,
// 滑动时停止自动轮播,滑动结束恢复
onPageScroll: (scrollPosition) {
if (widget.autoPlay) {
_stopAutoPlay();
}
},
onPageScrollEnd: (scrollPosition) {
if (widget.autoPlay && _isPageVisible) {
_startAutoPlay();
}
// 处理循环播放的边界跳转
if (widget.loop) {
if (_pageController.page == 0) {
_pageController.jumpToPage(widget.items.length);
} else if (_pageController.page == displayItems.length - 1) {
_pageController.jumpToPage(1);
}
}
},
children: displayItems.map((item) => _buildBannerItem(item, displayItems.indexOf(item))).toList(),
),
// 指示器(叠在轮播图上方)
_buildIndicator(),
],
),
);
}
}
三、实战使用示例(覆盖 3 大高频场景)
场景 1:基础网络图片轮播(首页 Banner)
dart
BannerSlider(
items: [
// 网络图片轮播项
Image.network(
"https://example.com/banner1.jpg",
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
Image.network(
"https://example.com/banner2.jpg",
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
Image.network(
"https://example.com/banner3.jpg",
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
],
onTap: (index) {
debugPrint('点击轮播图索引:$index');
// 实际场景:跳转对应页面
},
height: 220,
borderRadius: 12,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shadow: BoxShadow(
color: Colors.grey.withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 2),
),
indicatorType: IndicatorType.dot,
indicatorActiveColor: Colors.white,
indicatorNormalColor: Colors.white.withOpacity(0.5),
indicatorSize: 6,
indicatorMargin: const EdgeInsets.only(bottom: 20),
),
场景 2:自定义 Widget 轮播(商品推荐)
dart
BannerSlider(
items: [
// 自定义商品轮播项
Container(
color: const Color(0xFFF5F5F5),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.network("https://example.com/product1.jpg", width: 120, height: 120),
const SizedBox(height: 8),
const Text("2025新款夏季连衣裙", style: TextStyle(fontSize: 14)),
const SizedBox(height: 4),
const Text("¥199", style: TextStyle(color: Colors.red, fontSize: 16)),
],
),
),
Container(
color: const Color(0xFFF5F5F5),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.network("https://example.com/product2.jpg", width: 120, height: 120),
const SizedBox(height: 8),
const Text("无线蓝牙耳机", style: TextStyle(fontSize: 14)),
const SizedBox(height: 4),
const Text("¥299", style: TextStyle(color: Colors.red, fontSize: 16)),
],
),
),
],
onTap: (index) => debugPrint('点击推荐商品:$index'),
autoPlay: true,
autoPlayDuration: const Duration(seconds: 4),
loop: true,
height: 280,
borderRadius: 8,
padding: const EdgeInsets.symmetric(horizontal: 16),
showIndicator: true,
indicatorType: IndicatorType.number,
indicatorActiveColor: Colors.blueAccent,
indicatorNormalColor: Colors.grey,
indicatorAlignment: IndicatorAlignment.bottomRight,
indicatorMargin: const EdgeInsets.only(bottom: 16, right: 16),
),
场景 3:本地图片轮播(引导页)
dart
BannerSlider(
items: [
Image.asset(
"assets/guide1.png",
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
Image.asset(
"assets/guide2.png",
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
Image.asset(
"assets/guide3.png",
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
],
onTap: (index) {},
autoPlay: false, // 引导页禁用自动轮播
loop: false, // 禁止循环
height: double.infinity, // 占满屏幕高度
borderRadius: 0,
showIndicator: true,
indicatorSize: 10,
indicatorSpacing: 12,
indicatorActiveColor: Colors.white,
indicatorNormalColor: Colors.white.withOpacity(0.6),
indicatorMargin: const EdgeInsets.only(bottom: 40),
),
四、核心封装技巧
- 无缝循环实现:在轮播列表首尾添加额外项,滑动到边界时自动跳转,实现视觉上的无缝循环。
- 生命周期管理 :通过
WidgetsBindingObserver监听页面可见性,隐藏时暂停轮播,避免后台消耗资源。 - 交互联动:滑动时停止自动轮播,滑动结束恢复;点击轮播项返回当前索引,符合用户操作习惯。
- 指示器多样化:支持圆点和数字两种指示器,位置、颜色、大小均可自定义,适配不同设计风格。
- 内容灵活性:支持任意 Widget 作为轮播项,不仅限于图片,可扩展为商品卡片、广告组件等。
五、避坑指南
- 轮播项数量:至少传入 1 个轮播项,循环播放需至少 2 个项,否则自动轮播和循环功能失效。
- 图片适配 :轮播图高度建议固定(如 200-250px),图片设置
fit: BoxFit.cover,避免拉伸变形。 - 性能优化:网络图片建议提前缓存,轮播项过多(超过 5 个)时,可考虑懒加载,避免初始化卡顿。
- 指示器位置 :避免指示器与轮播内容重叠,可通过
indicatorMargin调整边距,确保可见性。 - 循环逻辑:非循环模式下,轮播到最后一页后会停止自动轮播,需提前告知用户。