Flutter自定义Banner的实现

2,548 阅读5分钟

在Android或者iOS开发中banner是必不可少的组件。在flutter中同样也需要该组件。比如实现下面这种banner,

image.png

一般会包括以下几部分内容。

  1. 用于展示的数据源及轮播控件的选择。
  2. 用于标识当前位置的指示器(默认指示器)。
  3. 是否自动轮播以及轮播的时间。
  4. banner的宽高。
  5. banner是否有圆角。
  6. banner图片的展示方式。
  7. 当前选中位置的回调(如果不用默认的indicator,需要自己实现indicator时需要知道当前选中位置)。

既然确定了需要的元素,我们就看看每一部分的实现。

  1. 展示的数据源及轮播控件的选择:banner的数据源一般是一个String数组,但是要实现无限滑动的话,这里采用的是补位的方式(就是在源数据的首位添加一个源数据的最后一个数据,则源数据的末尾添加源数据的首个数据)。也就是说如果源数据为012的话,0的位置向右滑动应该出现2,在2的位置向左滑动应该出现0,所以补充完的数据应该是20120。而这种分页控件则选用PageView。当初始化时我们选中位置1(这是源数据的第0个位置),随着滑动,如果选中了倒数第一个数据(补位的源数据的第一条,比如20120的最后一个0),我们让PageView切换到第一个位置(真正的第一个数据);如果选中了第一个位置(补位的源数据的最后一条比如20120的第一个2),我们让PageView选中倒数第一个位置(真正的最后一条数据)。这样就会保证无论在那一条数据上左右滑动,他的左右两侧均有相应的数据。所以源数据定义如下final List<String>dataList。在statefullWidget的initState方法中我们构造出自己需要的目标数据:

    if (widget.dataList != null && widget.dataList.length > 0) { addedImgs ..add(widget.dataList.last) ..addAll(widget.dataList) ..add(widget.dataList.first); }

    对于在第一个位置和最后一个位置时应该自动切换到对应的原始位置,我们则是在PageView的onPageChange方法中对其进行操作,当然对于当前选中位置的回调(我们采用的是自定义函数 typedef OnBannerPageChanged = void Function(int index);),我们也应该在这里调用。如果细心的话可以看到我们把setState给注掉了,之所以不用setState是因为我们在这里其实只是想更新indicator的currentIndex,如果采用这种方式那么整个Widget都要重新build。所以我们将当前选中的位置realPos利用ValueNotifier来定义(ValueNotifier<int> realPos = new ValueNotifier(0);),这样当realPos的值发生变化时会自动触发重绘indicator。

  _onPageChanged(int page) async {
    //比如元数据为012则构造完的数据为20120
    if (page == addedImgs.length - 1) {
      //当前选中的是倒数第一个位置,自动选中第二个索引
      _currentIndex = 1;
      await Future.delayed(Duration(milliseconds: 50));
      _pageController.jumpToPage(_currentIndex);
      realPos.value = 0;
    } else if (page == 0) {
      //当前选中的是第一个位置,自动选中倒数第二个位置
      _currentIndex = addedImgs.length - 2;
      await Future.delayed(Duration(milliseconds: 50));
      _pageController.jumpToPage(_currentIndex);
      realPos.value = _currentIndex - 1;
    } else {
      _currentIndex = page;
      realPos.value = _currentIndex - 1;
      if (realPos.value < 0) realPos.value = 0;
    }

    if (widget.onBannerPageChanged != null) {
      widget.onBannerPageChanged(realPos.value);
    }
    //setState(() {});
  }

  1. 比如图中显示的默认指示器为选中为黄色RRect,而未选中时为灰色。(如果开源或者给其他人用的话,对于这个indicator的的位置,选中及未选中的颜色等都应该抽取出相应的变量,这里就偷个懒直接写死)实现方案则采取类似于Android中的自定义view,只不过这里采用的是CustomPainter。其实原理很简单就是利用paint在canvas上画圆及圆角矩形
  class BannerSliderIndicator extends CustomPainter {
  ///总数
  int count;

  ///当前选中位置
  ValueNotifier<int> currentIndex;

  ///未选中颜色
  Color normalColor;

  ///选中的颜色
  Color selectColor;

  ///画笔
  Paint mPaint;

  ///未选中的半径
  double normalCircleRadius;

  ///间隔
  double space;

  ///选中时指示器宽度
  double rectangleWidth;

  ///选中时指示器高度度
  double rectangleHeight;

  ///选中是指示器圆角
  Radius rectangleCorner;
  double preDelta;
  RRect rect;

  BannerSliderIndicator({this. count,this.currentIndex}):super(repaint: currentIndex) {
    this.count = count;

    mPaint = Paint();
    mPaint
      ..isAntiAlias = true
      ..style = PaintingStyle.fill;
    normalColor = Color(0xffdcdcdc);
    selectColor = Color(0xfffdc133);
    normalCircleRadius = 3.w;
    space = 9.w;

    rectangleWidth = 16.w;
    rectangleHeight = 6.w;
    rectangleCorner = Radius.circular(3.w);
    preDelta = 5.w;
  }


  @override
  void paint(Canvas canvas, Size size) {
    if (count < 1) return;
    double indicatorWidth =
        normalCircleRadius * 2 * count + space * (count - 1) + preDelta * 2;
    double left = (size.width - indicatorWidth) / 2.0;
    for (int i = 0; i < count; i++) {
      mPaint..color = i == currentIndex.value ? selectColor : normalColor;
      if (i == currentIndex.value) {
        rect = RRect.fromLTRBAndCorners(
            left,
            size.height / 2 - normalCircleRadius,
            left + rectangleWidth,
            size.height / 2 - normalCircleRadius + rectangleHeight,
            topLeft: rectangleCorner,
            topRight: rectangleCorner,
            bottomLeft: rectangleCorner,
            bottomRight: rectangleCorner);

        left += rectangleWidth + space;
        canvas.drawRRect(rect, mPaint);
      } else {
        canvas.drawCircle(Offset(left + normalCircleRadius, size.height / 2),
            normalCircleRadius, mPaint);
        left += 2 * normalCircleRadius + space;
      }
    }
  }

  @override
  bool shouldRepaint(BannerSliderIndicator oldDelegate) {
    return oldDelegate.currentIndex != currentIndex;
  }
}


上面的指示器中判断当前选中位置的currentIndex参数,我们使用的是ValueNotifier,这样当这个值变化时,如果shouldRepaint返回true,那么就会触发重绘,至于具体的原理将在下一篇中详述。这里我们直接将指示器写在banner下面,我们直接采用column来包裹banner和indicator(当然,如果提供指示器位置的配置的话,就得根据不同的配置方式选择不同的容器)

 child: !widget.showIndicator
                ? _buildPager()
                : Column(
                    children: [
                      _buildPager(),
                      Container(
                        height: 30.h,
                        child: Center(
                          child: CustomPaint(
                            size: Size(ScreenUtil().screenWidth, 30.h),
                            painter: BannerSliderIndicator(
                                count: widget.dataList == null
                                    ? 0
                                    : widget.dataList.length,
                                currentIndex: realPos),
                          ),
                        ),
                      ),
                    ],
                  )
  1. 如果开启自动轮播的话,其实就是配合Timer的使用,如果自动轮播,则需要在build之后开启定时器。在StatefullWidget中我们可以添加FrameCallback来监听构建完成,每次构建完成都会触发这个回调,回调使用方式如下
 @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      _startTimer();
    });

    super.initState();
  }
  
    void _startTimer() {
    if (widget.dataList != null && widget.dataList.length > 1 && widget.isAuto)
      _timer = Timer.periodic(
          Duration(seconds: widget.intervalTime), (timer) => _scrollToPage());
  }

在startTimer中我们判断如果是没有图或者是一张图或者是不轮播我们都不开启轮播,如果开启轮播了我们就配置他的轮播间隔intervalTime,如果不设置的话我们的默认值为2秒。

  1. banner的宽高则主要用于定义显示banner的container的大小,这个参数的使用在之后的完整版代码中会看到。
  2. 从上面的需要元素可以看到,我们已经把内容的显示方式也就是元素6,交给调用方自己实现了,这里之所以是需要这个参数,是因为如果内容设置了圆角,PageView的每一页不设置的话,在滑动的过程中,通过手滑可以看到两页交界的地方是没有圆角的。所以我们构建PageView的child时候需要如下
ClipRRect(
        borderRadius: BorderRadius.circular(widget.bannerRadius),
        child: widget.itemBuilder(context, url, realPos),
      )
  1. banner图片的展示方式:这里通过自定义函数的方式(typedef ItemBuilder = Widget Function( BuildContext context, String url, int realPos);)来实现图片显示widget的构建。之所以采用这种方式,其一:展示图片的方式可能是Image.asset也可能是Image.network,也可能是三方库比如ExtendedImage.network;其二可对显示图片的Widget进行单击双击长按事件等的自定义处理(只需在Image外套一层GestureDetector即可)。

  2. 当前选中位置的回调。主要是对当前选中位置进行分发给调用者(如果调用者需要自定义indicator的话需要知道当前位置),比如实现下面这种效果

image.png

这种效果需要我们自定义一个Widget来展示当前的位置和总的数量,在banner切换的时候更新当前的indicator,当然这里为了不整体重建整个页面,我们可以采用 final ValueNotifier<int> new_counter = ValueNotifier(1);来代替currentIndex。在onPageChanged方法中更新这个变量

  onBannerPageChanged: (index) {
     print("currentIndex=$index")
     new_counter.value = index + 1;},

在构建这个自定义indicator时通过ValueListenableBuilder来构建,这样就不用每次在onBannerPageChanged方法中调用setState来构建整个页面

ValueListenableBuilder(
         valueListenable: new_counter,
         builder: _builderWithValue)
         
Widget _builderWithValue(BuildContext context, int value, Widget child) {
    return Container(
      constraints: BoxConstraints(minHeight: 26.w, minWidth: 75.w),
      decoration: CommonWidgets.bdRadius13LeftC000000T50(),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Image.asset(
            "assets/images/xx_sjxq_tp.png",
            width: 23.w,
            height: 23.w,
            fit: BoxFit.fill,
          ),
          CommonWidgets.text(
              "$value/${resDto?.data?.merchantDto?.imgList?.length ?? 0}",
              size: 15.sp,
              color: MyConstant.instance.colorBase.colorFDC133)
        ],
      ),
    );
  }

综上每一部分我们都实现了。所以完整的banner如下

class CustomBannerWidget extends StatefulWidget {
  ///源数据
  final List<String> dataList;
  final OnBannerPageChanged onBannerPageChanged;
  final bool showIndicator;
  final ItemBuilder itemBuilder;
  final double bannerWidth;
  final double bannerHeight;
  final double bannerRadius;
  final int intervalTime;
  final bool isAuto;


  CustomBannerWidget(this.itemBuilder,
      {this.onBannerPageChanged,
      this.dataList,
      this.showIndicator = true,
      this.bannerWidth,
      this.bannerHeight,
      this.bannerRadius = 0.0,
      this.intervalTime = 2,
      this.isAuto = true});

  @override
  State<StatefulWidget> createState() {
    return _CustomBannerWidgetState();
  }
}

class _CustomBannerWidgetState extends State<CustomBannerWidget> {
  PageController _pageController = PageController(initialPage: 1);
  int _currentIndex = 1;
  List<String> addedImgs = [];
  bool isEnd = false;
  bool isUserGesture = false;
  ValueNotifier<int> realPos = new ValueNotifier(0);
  Timer _timer;

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      _startTimer();
    });

    super.initState();
    addedImgs.clear();
    if (widget.dataList != null && widget.dataList.length > 0) {
      addedImgs
        ..add(widget.dataList.last)
        ..addAll(widget.dataList)
        ..add(widget.dataList.first);
    }
  }

  @override
  void dispose() {
    _stopTimer();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    print(
        "bannerWidth = ${widget.bannerWidth}  bannerHeight = ${widget.bannerHeight}");

    return widget.dataList == null || widget.dataList.length <= 0
        ? Container(
            width: widget.bannerWidth,
            height: widget.bannerHeight,
          )
        : NotificationListener(
            onNotification: (ScrollNotification notification) =>
                _onNotification(notification),
            child: !widget.showIndicator
                ? _buildPager()
                : Column(
                    children: [
                      _buildPager(),
                      Container(
                        height: 30.h,
                        child: Center(
                          child: CustomPaint(
                            size: Size(ScreenUtil().screenWidth, 30.h),
                            painter: BannerSliderIndicator(
                                count: widget.dataList == null
                                    ? 0
                                    : widget.dataList.length,
                                currentIndex: realPos),
                          ),
                        ),
                      ),
                    ],
                  ));
  }

  _onPageChanged(int page) async {
    //比如元数据为012则构造完的数据为20120
    if (page == addedImgs.length - 1) {
      //当前选中的是倒数第一个位置,自动选中第二个索引
      _currentIndex = 1;
      await Future.delayed(Duration(milliseconds: 50));
      _pageController.jumpToPage(_currentIndex);
      realPos.value = 0;
    } else if (page == 0) {
      //当前选中的是第一个位置,自动选中倒数第二个位置
      _currentIndex = addedImgs.length - 2;
      await Future.delayed(Duration(milliseconds: 50));
      _pageController.jumpToPage(_currentIndex);
      realPos.value = _currentIndex - 1;
    } else {
      _currentIndex = page;
      realPos.value = _currentIndex - 1;
      if (realPos.value < 0) realPos.value = 0;
    }

    if (widget.onBannerPageChanged != null) {
      widget.onBannerPageChanged(realPos.value);
    }

  }

  _onNotification(ScrollNotification notification) {
    if (notification.depth == 0 && notification is ScrollStartNotification) {
      if (notification.dragDetails != null) {
        _stopTimer();
      }
    } else if (notification is ScrollEndNotification) {
      _stopTimer();
      _startTimer();
    }
  }

  void _startTimer() {
    if (widget.dataList != null && widget.dataList.length > 1 && widget.isAuto)
      _timer = Timer.periodic(
          Duration(seconds: widget.intervalTime), (timer) => _scrollToPage());
  }

  void _scrollToPage() {
    ++_currentIndex;
    var next = _currentIndex % addedImgs.length;
    _pageController.animateToPage(next,
        duration: Duration(milliseconds: 50), curve: Curves.ease);
  }

  void _stopTimer() {
    if (_timer != null) {
      _timer.cancel();
    }
  }

  List<Widget> _buildChildren(BuildContext context) {
    List<Widget> childWidgets = [];
    for (var url in addedImgs) {
      childWidgets.add(ClipRRect(
        borderRadius: BorderRadius.circular(widget.bannerRadius),
        child: widget.itemBuilder(context, url, realPos.value),
      ));
    }
    return childWidgets;
  }

  Widget _buildPager() {
    return Container(
        width: widget.bannerWidth,
        height: widget.bannerHeight,
        child: PageView(
          onPageChanged: (index) => _onPageChanged(index),
          controller: _pageController,
          children: _buildChildren(context),
        ));
  }
}

使用时,只需在需要的位置构建这个Widget即可,比如

CustomBannerWidget(
              (context, url, int index) {
                print("bannerIndex = $index");

                return GestureDetector(
                  onTap: (){
                    print("current url is $url");

                  },
                  child: ExtendedImage.network(
                  url,
                  fit: BoxFit.cover,
                  width: 367.w,
                  height: 143.h,
                ),);
              },
              dataList: newList,
              bannerHeight: 143.h,
              bannerWidth: 367.w,
              bannerRadius: 8.w,
            )

大功告成,欢迎大家批评指正。