Flutter 30: 图解自定义底部状态栏 ACEBottomNavigationBar (一)

      小菜刚接触 Flutter 时接触到底部状态栏 BottomNavigationBar 方便快捷,但随着使用过程发现依然有一些限制,包括图片选择/样式凸出/固定 NavigationItem 位等。小菜不才,准备照葫芦画瓢,自定义一个底部状态栏,并尝试封装成一个 Pub 插件。

      小菜首先了解了一下 BottomNavigationBar,主要由整体填充布局与子NavigationItem,小菜也是这样设计的,但 BottomNavigationBar 设计的配置部分主要是在 BottomNavigationBar 中完成的,而 BottomNavigationBarItem 可以看作只是一个单纯的实体类,小菜认为这样设计的好处就是统一管理,减少冗余配置等;而小菜为了配置项更多更灵活选择在 NavigationItem 中进行配置判断,这样实现的缺点就是冗余项较多,小菜也会不断学习完善。

设计尝试

一:类型确定

      小菜尝试用枚举类型确定不同的样式,明确且方便,延展性也较好;

enum ACEBottomNavigationBarType {
  normal,  // 普通类型,选中变色,样式不变
  zoom,    // 图片或icon变大,此时隐藏文字,支持变色
  zoomout, // 图片或icon变大,并凸出显示,文字显示,支持变色
  zoomoutonlypic,  // 图片或icon变大,并凸出显示,文字隐藏
}

Flutter 30: 图解自定义底部状态栏 ACEBottomNavigationBar (一)

二:NavigationItem 搭建

      对于 NavigationItem 因为计划有凸出效果展示,整体用了 Stack 来搭建,配合 AnimatedAlign 等具体的组件来共同搭建,因为 Item 中各种状态均可根据用户定义的样式进行传参,故所有字段前均需 @required

class NavigationItem extends StatelessWidget {
  final UniqueKey uniqueKey;
  final textStr;
  final textUnSelectedColor;
  final textSelectedColor;
  final icon;
  final iconUnSelectedColor;
  final iconSelectedColor;
  final image;
  final imageSelected;
  final selected;
  final ACEBottomNavigationBarType type;
  final Function(UniqueKey uniqueKey) callbackFunction;

  NavigationItem(
      {@required this.uniqueKey,
      @required this.selected,
      @required this.textStr,
      @required this.textSelectedColor,
      @required this.textUnSelectedColor,
      @required this.icon,
      @required this.iconSelectedColor,
      @required this.iconUnSelectedColor,
      @required this.image,
      @required this.imageSelected,
      @required this.callbackFunction,
      @required this.type});

  @override
  Widget build(BuildContext context) {
    return Expanded(
        child: Stack(children: <Widget>[
      Container(
          alignment: Alignment.bottomCenter,
          child: Opacity(
              opacity: textOption(),
              child: Padding(
                  padding: const EdgeInsets.all(6.0),
                  child: Text(textStr,
                      overflow: TextOverflow.ellipsis,
                      maxLines: 1,
                      style: TextStyle(
                          fontWeight: FontWeight.w600,
                          color: selected
                              ? textSelectedColor
                              : textUnSelectedColor))))),
      Container(
          child: AnimatedAlign(
              duration: Duration(milliseconds: 0),
              alignment: picZoomAlignment(),
              child: childWid()))
    ]));
  }

  double picSize() {
    var size;
    if (type == ACEBottomNavigationBarType.normal) {
      size = 30.0;
    } else {
      size = selected ? 50.0 : 30.0;
    }
    return size;
  }

  double textOption() {
    var option;
    if (type == ACEBottomNavigationBarType.zoom ||
        type == ACEBottomNavigationBarType.zoomoutonlypic) {
      option = selected ? 0.0 : 1.0;
    } else if (type == ACEBottomNavigationBarType.zoomout) {
      option = 1.0;
    } else {
      option = 1.0;
    }
    return option;
  }

  EdgeInsetsGeometry imagePadding() {
    EdgeInsetsGeometry edge;
    if (type == ACEBottomNavigationBarType.zoom) {
      edge = selected
          ? EdgeInsets.only(top: 6.0, bottom: 6.0)
          : EdgeInsets.only(bottom: 20.0);
    } else if (type == ACEBottomNavigationBarType.zoomout ||
        type == ACEBottomNavigationBarType.zoomoutonlypic) {
      edge = selected
          ? EdgeInsets.only(bottom: 0.0)
          : EdgeInsets.only(bottom: 20.0);
    } else if (type == ACEBottomNavigationBarType.normal) {
      edge = EdgeInsets.only(bottom: 20.0);
    } else {
      edge = EdgeInsets.only(bottom: 0.0);
    }
    return edge;
  }

  Widget childWid() {
    Widget widget;
    if (image != null) {
      widget = GestureDetector(
          child: Padding(
              padding: imagePadding(),
              child: Image(
                  image: (selected && imageSelected != null)
                      ? imageSelected
                      : image,
                  width: picSize(),
                  height: picSize())),
          onTap: () {
            callbackFunction(uniqueKey);
          });
    } else {
      widget = IconButton(
          highlightColor: Colors.transparent,
          splashColor: Colors.transparent,
          padding: EdgeInsets.only(bottom: 24.0),
          alignment: Alignment(0, 0),
          icon: Icon(icon,
              size: picSize(),
              color: selected ? iconSelectedColor : iconUnSelectedColor),
          onPressed: () {
            callbackFunction(uniqueKey);
          });
    }
    return widget;
  }
}

Flutter 30: 图解自定义底部状态栏 ACEBottomNavigationBar (一)

三:ACEBottomNavigationBar 框架搭建

      小菜自定义 ACEBottomNavigationBar 用来装载 Item 框架,若不设置单独 Item 时使用 ACEBottomNavigationBar 配置项,为公共效果,若两者同时设置,优先使用 NavigationItem 效果。

      为了实现切换时可以对应相应的 Tab 页,需要设置 item key

class ACEBottomNavigationBar extends StatefulWidget {
  final Key key;
  final List<NavigationItemBean> items;
  final initSelectedIndex;
  final bgColor;
  final bgImage;
  final Function(int position) onTabChangedListener;
  final textStr;
  final textUnSelectedColor;
  final textSelectedColor;
  final icon;
  final iconUnSelectedColor;
  final iconSelectedColor;
  final image;
  final imageSelected;
  final ACEBottomNavigationBarType type;

  ACEBottomNavigationBar(
      {@required this.items,
      @required this.onTabChangedListener,
      ACEBottomNavigationBarType type,
      this.key,
      this.initSelectedIndex = 0,
      this.textStr,
      this.textSelectedColor,
      this.textUnSelectedColor,
      this.icon,
      this.iconSelectedColor,
      this.iconUnSelectedColor,
      this.image,
      this.imageSelected,
      this.bgColor,
      this.bgImage})
      : assert(onTabChangedListener != null),
        assert(items != null),
        assert(items.length >= 1 && items.length <= 5),
        type = type;

  @override
  _ACEBottomNavigationBar createState() => _ACEBottomNavigationBar();
}

class _ACEBottomNavigationBar extends State<ACEBottomNavigationBar>
    with TickerProviderStateMixin, RouteAware {
  var curSelectedIndex = 0;
  var textSelectedColor;
  var textUnSelectedColor;
  var iconSelectedColor;
  var iconUnSelectedColor;

  @override
  void initState() {
    super.initState();
    _setSelected(widget.items[widget.initSelectedIndex].key);
  }

  _setSelected(UniqueKey key) {
    if (mounted) {
      setState(() {
        curSelectedIndex =
            widget.items.indexWhere((tabData) => tabData.key == key);
      });
    }
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    textUnSelectedColor = (widget.textUnSelectedColor == null)
        ? (Theme.of(context).brightness == Brightness.dark)
            ? Colors.white
            : Colors.black54
        : widget.textUnSelectedColor;
    textSelectedColor = (widget.textSelectedColor == null)
        ? (Theme.of(context).brightness == Brightness.dark)
            ? Colors.white
            : Colors.black87
        : widget.textSelectedColor;
    iconUnSelectedColor = (widget.iconUnSelectedColor == null)
        ? (Theme.of(context).brightness == Brightness.dark)
            ? Colors.white
            : Colors.black54
        : widget.iconUnSelectedColor;
    iconSelectedColor = (widget.iconSelectedColor == null)
        ? (Theme.of(context).brightness == Brightness.dark)
            ? Colors.white
            : Colors.black87
        : widget.iconSelectedColor;
  }

  @override
  Widget build(BuildContext context) {
    return Stack(alignment: Alignment.bottomCenter, children: <Widget>[
      Container(
          height: 60.0,
          decoration: navigationBarBg(),
          child: Row(
              mainAxisSize: MainAxisSize.max,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: widget.items
                  .map((item) => NavigationItem(
                      uniqueKey: item.key,
                      selected: item.key == widget.items[curSelectedIndex].key,
                      icon: item.icon,
                      textStr: item.textStr,
                      textSelectedColor: (item.textSelectedColor == null)
                          ? this.textSelectedColor
                          : item.textSelectedColor,
                      textUnSelectedColor: (item.textUnSelectedColor == null)
                          ? this.textUnSelectedColor
                          : item.textUnSelectedColor,
                      iconSelectedColor: (item.iconSelectedColor == null)
                          ? this.iconSelectedColor
                          : item.iconSelectedColor,
                      iconUnSelectedColor: (item.iconUnSelectedColor == null)
                          ? this.iconUnSelectedColor
                          : item.iconUnSelectedColor,
                      type: widget.type != null
                          ? widget.type
                          : ACEBottomNavigationBarType.normal,
                      image: item.image,
                      imageSelected: item.imageSelected,
                      callbackFunction: (uniqueKey) {
                        int selected = widget.items
                            .indexWhere((tabData) => tabData.key == uniqueKey);
                        widget.onTabChangedListener(selected);
                        _setSelected(uniqueKey);
                      }))
                  .toList()))
    ]);
  }

  BoxDecoration navigationBarBg() {
    return widget.bgImage != null
        ? BoxDecoration(boxShadow: [
            BoxShadow(
                color: Colors.black12, offset: Offset(0, -1), blurRadius: 8)
          ], image: DecorationImage(fit: BoxFit.cover, image: widget.bgImage))
        : BoxDecoration(
            color: widget.bgColor != null ? widget.bgColor : Colors.white,
            boxShadow: [
                BoxShadow(
                    color: Colors.black12, offset: Offset(0, -1), blurRadius: 8)
              ]);
  }
}

Flutter 30: 图解自定义底部状态栏 ACEBottomNavigationBar (一)

注意事项

  1. ACEBottomNavigationBarType 为状态栏样式,默认为 nomal 类型,支持文字和图片/icon 颜色切换;
  2. 小菜尝试时对图片设置成图片和 icon 两种,icon 类型支持颜色绘制,而图片支持选中和未选中两张图切换;同时如果设置图片和 icon 两种,优先使用图片样式;同时用户对于两张图样式时可以只设置一张未选中状态图;同时支持图片和 icon 两种方式共存;
  3. 小菜设计 NavigationItem 中传递 image 图片,是为了支持本地图/网络图/内存图等多种图片格式;
  4. ACEBottomNavigationBar 中可以设置背景图或背景色,优先使用背景图效果,且背景图支持本地图或网络图。

      小菜尝试过程中还有很多欠缺,下一步计划添加固定凸出 Item 位样式,并尝试发不成 Pub 插件,有不对的地方敬请指点!

      小菜对细节地方介绍较少,希望各位朋友优先尝试效果。

上一篇:直播回顾|TGIP-CN 032:Apache Pulsar 快速上手实战


下一篇:打卡第10天