Flutter 通用轮播图组件 BannerWidget:自动播放 + 指示器 + 全场景适配

在 Flutter 开发中,轮播图(Banner)是首页广告、商品推荐、活动展示的核心组件。原生 PageView 需手动实现自动播放、指示器联动、图片加载等逻辑,重复开发易导致体验不一致。本文封装的 BannerWidget 整合 "自动播放 + 循环滚动 + 指示器自定义 + 图片加载优化" 四大核心能力,适配本地 / 网络图片、支持手势控制,一行代码即可集成。

一、核心优势(精准解决开发痛点)

  1. 自动播放内置:默认开启自动轮播,支持自定义时长、暂停 / 恢复逻辑,滑动时自动暂停,贴合用户操作习惯
  2. 指示器全自定义:支持圆点 / 数字 / 进度条三种指示器样式,颜色、大小、间距均可配置,位置可自由切换(底部 / 顶部)
  3. 图片加载优化:支持本地 / 网络图片,自动处理加载占位、失败降级,支持圆角裁剪与缩放模式配置
  4. 交互体验流畅:支持循环滚动、左右滑动手势,点击事件回调,滑动动画曲线可自定义
  5. 高适配强鲁棒:自动适配屏幕宽度,支持深色模式,参数断言校验避免非法配置,无内存泄漏

二、核心配置速览(关键参数一目了然)

配置分类 核心参数 核心作用
必选配置 images: List<String>onTap: Function(int) 图片列表(本地路径 / 网络 URL)、点击回调(返回索引)
播放配置 autoPlayautoPlayDurationloopanimationCurve 自动播放、播放时长、循环滚动、动画曲线
指示器配置 indicatorTypeindicatorColorindicatorSelectedColorindicatorPosition 指示器类型、默认颜色、选中颜色、显示位置
图片样式配置 imageRadiusimageFitplaceholdererrorWidget 图片圆角、缩放模式、占位组件、失败组件
适配配置 adaptDarkModeheightpadding 深色模式适配、轮播图高度、内边距

三、生产级完整代码(可直接复制,开箱即用)

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,
);

五、核心封装技巧(复用成熟设计思路)

  1. 循环滚动实现 :通过在数据源前后添加 "哨兵元素",配合 PageController.jumpToPage 实现无缝循环,避免原生 PageView 边界卡顿
  2. 自动播放控制 :使用 Timer.periodic 实现自动播放,触摸时暂停、松开恢复,提升交互体验
  3. 指示器多类型支持:通过枚举封装三种常用指示器,样式参数独立配置,兼顾通用性与个性化
  4. 图片加载优化:区分本地 / 网络图片,支持占位与失败降级,避免网络波动导致的 UI 异常
  5. 状态安全管理:组件销毁时取消计时器与控制器,避免内存泄漏;页面更新时重新初始化播放逻辑

六、避坑指南(解决 90% 开发痛点)

  1. 图片路径规范 :本地图片需以 "asset://" 开头,且在 pubspec.yaml 中配置资源路径;网络图片需确保 URL 有效,建议配置 placeholder
  2. 循环模式注意 :循环模式下 _actualImages 长度比原列表多 2(前后哨兵),PageController 初始页码需设为 1,避免首次加载显示哨兵元素
  3. 自动播放时长:建议自动播放时长设置为 3-5 秒,过短易导致用户来不及查看,过长影响交互体验
  4. 轮播图高度:轮播图高度建议根据场景固定(如首页广告 200-250px),避免自适应高度导致布局抖动
  5. 深色模式兼容 :自定义指示器颜色时需通过 _adaptDarkMode 方法适配,避免深色模式下颜色冲突
  6. 性能优化:图片数量建议控制在 3-5 张,过多会增加内存占用;网络图片建议开启缓存,减少重复请求

https://openharmonycrossplatform.csdn.net/content

相关推荐
ujainu小3 小时前
Flutter 结合 shared_preferences 2.5.4 实现本地轻量级数据存储
flutter
双河子思3 小时前
Windows API 积累
windows
走在路上的菜鸟4 小时前
Android学Dart学习笔记第十六节 类-构造方法
android·笔记·学习·flutter
张人玉5 小时前
C# 与西门子 PLC 通信:地址相关核心知识点
开发语言·microsoft·c#·plc
OliverH-yishuihan5 小时前
在 Windows 上安装 Linux
linux·运维·windows
淼淼7635 小时前
工厂方法模式
开发语言·c++·windows·qt·工厂方法模式
ForteScarlet6 小时前
如何解决 Kotlin/Native 在 Windows 下 main 函数的 args 乱码?
开发语言·windows·kotlin
WTCLLB7 小时前
Windows命令和工具名称
windows
YCOSA20257 小时前
雨晨 Windows 11 企业版 26H1 轻装版 28020.1362
windows