Flutter自定義Banner的實現

縱馬天下發表於2021-05-28

在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);),我們也應該在這裡呼叫

  _onPageChanged(int page) async {
    //比如後設資料為012則構造完的資料為20120
    if (page == addedImgs.length - 1) {
      //當前選中的是倒數第一個位置,自動選中第二個索引
      _currentIndex = 1;
      await Future.delayed(Duration(milliseconds: 50));
      _pageController.jumpToPage(_currentIndex);
      realPos = 0;
    } else if (page == 0) {
      //當前選中的是第一個位置,自動選中倒數第二個位置
      _currentIndex = addedImgs.length - 2;
      await Future.delayed(Duration(milliseconds: 50));
      _pageController.jumpToPage(_currentIndex);
      realPos = _currentIndex - 1;
    } else {
      _currentIndex = page;
      realPos = _currentIndex - 1;
      if (realPos < 0) realPos = 0;
    }

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

複製程式碼
  1. 比如圖中顯示的預設指示器為選中為黃色RRect,而未選中時為灰色。(如果開源或者給其他人用的話,對於這個indicator的的位置,選中及未選中的顏色等都應該抽取出相應的變數,這裡就偷個懶直接寫死)實現方案則採取類似於Android中的自定義view,只不過這裡採用的是CustomPainter。其實原理很簡單就是利用paint在canvas上畫圓及圓角矩形
    class BannerSliderIndicator extends CustomPainter {
      ///總數
      int count;
      ///當前選中位置
      int currentIndex;
      ///未選中顏色
      Color normalColor;
      ///選中的顏色
      Color selectColor;
      ///畫筆
      Paint mPaint;
      ///未選中的半徑
      double normalCircleRadius;
      ///間隔
      double space;
      ///選中時指示器寬度
      double rectangleWidth;  
      ///選中時指示器高度度
      double rectangleHeight;
      ///選中是指示器圓角
      Radius rectangleCorner;
      double preDelta;
      RRect rect;

      BannerSliderIndicator({int count, int currentIndex}) {
        this.count = count;
        this.currentIndex = currentIndex;

        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;
      }

      void setCountAndPos(int count, int pos) {
        this.count = count;
        this.currentIndex = pos;
      }

      @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 ? selectColor : normalColor;
          if (i == currentIndex) {
            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;
      }
    }

複製程式碼

這裡我們直接將指示器寫在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如下

typedef OnBannerPageChanged = void Function(int index);
typedef ItemBuilder = Widget Function(
    BuildContext context, String url, int realPos);

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;
  int realPos = 0;
  List<String> addedImgs = [];
  bool isEnd = false;
  bool isUserGesture = false;

  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 = 0;
    } else if (page == 0) {
      //當前選中的是第一個位置,自動選中倒數第二個位置
      _currentIndex = addedImgs.length - 2;
      await Future.delayed(Duration(milliseconds: 50));
      _pageController.jumpToPage(_currentIndex);
      realPos = _currentIndex - 1;
    } else {
      _currentIndex = page;
      realPos = _currentIndex - 1;
      if (realPos < 0) realPos = 0;
    }

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

  _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),
      ));
    }
    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,
            )
複製程式碼

大功告成,歡迎大家批評指正。

相關文章