Flutter完全自定义底部导航

倒叙手法,先上效果图:


自定义底部导航

最初的想法是使用系统自带的BottomNavigationBar来实现,做到一半发现完全无法满足这奇葩合理的需求:

  • 背景模糊效果
  • 凸起按钮
  • 非标准高度
  • indicator 动画

起初也曾想到过想要封装一个相对通用的组件,然而需求太过非标,不可避免地需要重复造车轮子,只能将其中一些思路整理出来。

框架结构

@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        alignment: AlignmentDirectional.bottomStart,
        children: [
                    // 页面
          Container(
            child: PageView(),
          ),
                    // 底部导航
          LATabBar(),
          // indicator动画
          Transform.translate(),
        ],
      ),
    );
  }

我们使用Stack 来放置子视图,简单粗暴,从底部开始层叠:

  • Container:子页面,使用PageView 进行页面切换
  • LATabBar:自定义底部导航
  • Transform.translate():indicator动画组件

出于尽可能解耦的原因,这里把indicator 放到底部导外。

接下来,我们将重点关注底部导航的构建。

全局常量

定义全局常量 ,以针对不同设备进行适配。

Flutter定义全局常量

底部导航

考虑到这里有一个模糊效果,使用Stack来层叠backgrounditem ,首先来定义相关属性。

class LATabBar extends StatefulWidget {
    final double height;
  final Color backgroundColor;
  final Widget background;
  final List<LATabItem> items;

    ...
}
  • height:底部导航视图高度
  • backgroundColor:背景颜色(考虑后续组件化设计)
  • background:自定义背景组件(模糊+凸起效果)
  • items:自定义NavigationItem

自定义背景

先前说过,此方法很难封装成一个通用的组件。因为根据需求,需要自定义的东西太多,标准组件所需默认值也多。因此,只能将其中的思路记录下来。

绘制不规则图形

在iOS开发中,此类不规则背景,只需使用一个UIImageView 加载带透明度图片,随后使用view.mask 即可实现不规则背景。

而在Flutter 中,并无相应API来渲染(我太蔡了),因此,需要使用到自定义裁切ClipPath 来绘制相应不规则图形。

首先使用贝塞尔曲线,自定义一个clipper:

class RaisedClipper extends CustomClipper<Path> {

  @override
  Path getClip(Size size) {
    /**凸起高度*/
    double raisedHeight = 18;
    /**凸起宽度*/
    double raisedWidth = 80;
    /**凸起右边距*/
    double paddingTrailing = 25;

    
    Path path = Path();
    path.lineTo(0, raisedHeight);
    double cubicStartX = size.width - paddingTrailing - raisedWidth - 10;
    // 贝塞尔曲线起始位置
    path.lineTo(cubicStartX, raisedHeight);
    // 绘制曲线
    path.cubicTo(
        cubicStartX + raisedWidth * 0.1, raisedHeight,
        cubicStartX + raisedWidth * 0.25, 0,
        cubicStartX + raisedWidth * 0.5, 0);
    path.cubicTo(
        cubicStartX + raisedWidth * 0.75, 0,
        cubicStartX + raisedWidth * 0.85, raisedHeight,
        size.width - paddingTrailing, raisedHeight);

    path.lineTo(size.width, raisedHeight);
    path.lineTo(size.width, size.height);
    path.lineTo(0, size.height);

    return path;
  }

关于贝塞尔曲线,这里不再赘述,分享一个在线预览工具,方便调试。

模糊效果

Flutter提供了BackdropFilter 来作为高斯模糊的组件,配合使用ImageFilter 来制作模糊效果。

class BlurBackground extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ClipPath(
      clipper: RaisedClipper(),
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
        child: Container(
          color: Colors.white.withOpacity(0.5),
        ),
      ),
    );
  }
}

这样就完成了自定义底部导航背景视图的编码。

导航切换按钮

先上代码:

enum LATabItemStyle {
  normal,
  titleOnly,
  iconOnly,
}

class LATabItem extends StatefulWidget {
  final double width;
  final String iconNormal;
  final String iconSelected;
  final EdgeInsets iconInsets;
  final Alignment iconAlignment;
  final Color titleNormalColor;
  final Color titleSelectedColor;
  final EdgeInsets titleInsets;
  final Alignment titleAlignment;
  final String title;
  final GestureTapCallback onTap;
  final bool selected;
  final LATabItemStyle style;

  const LATabItem({
    Key key,
    this.width,
    this.iconNormal,
    this.iconSelected,
    this.iconInsets = EdgeInsets.zero,
    this.iconAlignment = Alignment.center,
    this.titleNormalColor,
    this.titleSelectedColor,
    this.titleInsets = EdgeInsets.zero,
    this.titleAlignment = Alignment.center,
    this.title,
    this.onTap,
    this.selected = false,
    this.style = LATabItemStyle.normal}) : super(key: key);

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

class LATabItemState extends State<LATabItem> {
  @override
  Widget build(BuildContext context) {
    Widget content;
    switch (this.widget.style) {
      case LATabItemStyle.titleOnly:

        break;

      case LATabItemStyle.iconOnly:
        content = Container(
          width: this.widget.width,
          alignment: this.widget.iconAlignment,
          padding: this.widget.iconInsets,
          child: Image.asset(this.widget.selected ? this.widget.iconSelected : this.widget.iconNormal),
        );
        break;

      default:

        break;
    }

    if (this.widget.width == null || this.widget.width <= 0) {
      return Expanded(
        child: GestureDetector(
          onTap: this.widget.onTap,
          child: content,
          behavior: HitTestBehavior.opaque,
        ),
      );
    } else {
      return GestureDetector(
        onTap: this.widget.onTap,
        child: content,
      );
    }
  }
}

导航按钮完全可以使用自定义的widget 而不必拘泥于形式,可以是自定义的文字、图片、绘制的图形各种。只要将其作为GestureDetectorchild 即可响应点击事件。

需要注意的是,如果使用自适应大小的Expanded ,则需要指定GestureDetectorbehavior: HitTestBehavior.opaque

indicator动画

简单动画,直接使用AnimationControllerTween 来制作动画。

  • 初始化:
_animationController = AnimationController(duration: Duration(milliseconds: 100), vsync: this);
_tween = Tween(begin: leading, end: leading);
_animation = _tween.animate(_animationController)
  ..addListener(() {
    setState(() {});
  });
  • Translation容器:
Transform.translate(
        offset: Offset(_animation.value, 0),
        child: Container(
        margin: EdgeInsets.fromLTRB(0, 0, 0, Global.tabBarHeight + Global.paddingBottom - Global.tabBarRaisedHeight - 4),
        width: 20,
        height: 4,
        decoration: BoxDecoration(
        borderRadius: BorderRadius.all(Radius.circular(2)),
        color: Color(0xFFB60005),
     ),
   ),
 ),
  • 切换视图时,执行动画:
void setIndicator(int index) {
    _tween.begin = _tween.end;
    _animationController.reset();
    _tween.end = unitWidth * index + leading;
    _animationController.forward();
}

到这里,自定义底部导航完成。

最后放上demo

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,634评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,951评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,427评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,770评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,835评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,799评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,768评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,544评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,979评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,271评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,427评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,121评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,756评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,375评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,579评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,410评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,315评论 2 352