
“项目里要加底部导航首页、分类、购物车、我的四个 Tab图标加文字点击切换。”这几乎是 Android 新手接到的第一个“正经需求”。我自己最开始是用 RadioGroup 硬撸后来被点击态、背景颜色不切换、Fragment 重影这些问题折磨了一晚上才换成 Material 库里的 BottomNavigationView。这篇文章就把 BottomNavigationView 从依赖引入、Menu 配置、Java 绑定到常见的 title 必填报错、图标着色、Badge 角标这些坑完整捋一遍适合刚接触 Android 的 Java 开发者直接当参考手册用。1. 为什么是 BottomNavigationView 而不是自己画1.1 官方组件的原生优势BottomNavigationView 是 Material Design 体系里标准的底部导航组件完整路径是 com.google.android.material.bottomnavigation.BottomNavigationView。它底层继承自 NavigationBarView把底部导航要做的“脏活”基本全包了每个 item 的图标和文字怎么排、选中态怎么切换、点击水波纹、文字缩放动画这些如果全用手写至少得一两百行 XML 加 Java还得单独处理不同分辨率下的间距和样式。用 BottomNavigationView 只需要一份 menu 资源和少量布局属性运行时基本不用关心 item 内部怎么画。比如你设置 app:itemIconTint 后图标会自动跟着选中态变色不需要自己监听点击去改 ImageView 的图片。对于大多数工具类、商城类 App这种“开箱即用”的控件能省下大量联调时间而且它属于 AndroidX/Material 体系后续跟着版本升级风险很小。提示新项目如果用了 Material3 的主题官方其实更推荐 NavigationBar不过 BottomNavigationView 在 Material3 主题下一样能跑。两者 API 高度重合下面讲的代码逻辑直接搬过去也成立。1.2 和三方库、自绘方案的取舍网上能搜到不少“BottomNavigationViewEx”或者各种自定义 TabLayout 的方案。第三方库最大问题是有时维护不及时Android 升级或者 androidx 版本一变可能就出现莫名其妙的兼容报错。自定义方案灵活但需求一旦多了代码交接成本很高后来的人看到一堆自定义 View 逻辑很难快速上手。方案上手成本维护成本可控性BottomNavigationView低官方文档齐全低跟随 material 版本升级即可中支持 Badge、itemIconTint 等扩展自绘 RadioGroup ImageView中需要自己控制选中逻辑中踩坑概率高高任何形状都能做第三方加强版底部导航低拿过来就能用高停更或兼容问题多中受限于作者封装就我个人经验90% 的常规 App 场景直接用官方组件就够了需要做“中间凸起”“菱形按钮”这种魔改时再考虑在它基础上去继承或者自定义。新手阶段尤其别一上来就整复杂方案先把官方组件的脾气摸清楚后面改起来也有底。2. 环境准备与依赖引入2.1 编译环境的要求Java 版的开发环境其实就是 Android Studio 加 JDK。很多人以为系统里装了 JDK 8 就能直接跑实际上新版 Android Studio 自带 JBRJetBrains Runtime你在 IDE 里点运行按钮不需要额外配 JDK但如果要在命令行执行 gradlew最好把 JDK 17 装好并写进 PATH。这一步和 BottomNavigationView 本身没有直接关系但环境不对会带来各种奇怪的 build 报错排查起来很浪费时间。对 BottomNavigationView 来说硬性要求是 compileSdkVersion 不能太低。material 1.9.0 之后基本要求 compileSdk 33 以上建议直接用 compileSdk 34 并配合官方最新稳定版 material这样组件渲染和新 API 都有保障。如果你的项目还在用 ext 老式写法也没关系只要 gradle 里依赖能解析成功就行。2.2 build.gradle 里加依赖在模块的 build.gradle 里添加dependencies { implementation com.google.android.material:material:1.9.0 }加完同步工程。这里有个小细节Activity 的主题不能太“素”。如果你用的是 Theme.AppCompat 或者未声明 Material 组件的主题BottomNavigationView 可能会渲染成奇怪的配色水波纹和默认文字颜色也可能不对劲。最稳妥的做法是让主题继承 Theme.MaterialComponents 或 Theme.Material3哪怕你只是做个 Demo 也建议改一下少踩一个视觉上的坑。依赖版本方面不要一上来就追最新 alpha/beta稳定版优先。我遇到过用预览版 material 导致的 item 点击动画崩溃排了半天最后发现是版本问题换成稳定版立刻正常。3. 从零搭建一个底部导航3.1 布局文件中加入 BottomNavigationView假设页面结构就是“顶部内容区 底部导航”最简单的 Layout 方案是 LinearLayout。内容区用 weight 占满剩余空间导航栏放在底部。activity_main.xml 大概是这样LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical FrameLayout android:idid/fragment_container android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / com.google.android.material.bottomnavigation.BottomNavigationView android:idid/bottom_nav android:layout_widthmatch_parent android:layout_heightwrap_content app:menumenu/menu_bottom_nav / /LinearLayoutFramentContainer 就是页面内容要替换的区域BottomNavigationView 底部固定。如果想要“页面内容延伸到导航下面”的效果再换 CoordinatorLayout 方案也不迟新手阶段先用这个清晰的结构。3.2 编写 Menu 资源MainActivity 里写逻辑之前先把 Tab 的定义写好。res/menu/menu_bottom_nav.xmlmenu xmlns:androidhttp://schemas.android.com/apk/res/android item android:idid/nav_home android:icondrawable/ic_home android:title首页 / item android:idid/nav_category android:icondrawable/ic_category android:title分类 / item android:idid/nav_cart android:icondrawable/ic_cart android:title购物车 / item android:idid/nav_mine android:icondrawable/ic_mine android:title我的 / /menu这里的 title 是必填项原因后面第 4 节详细讲。图标建议用 VectorDrawableAndroid Studio 自带矢量图库右键 drawable 目录选 New - Vector Asset 就能挑。矢量图在缩放上比 PNG 稳妥省去一堆 mipmap 适配的麻烦。Menu 资源的核心是 id、icon、title 三件套。id 必须全局唯一后面 Java 里通过 findItem 拿到的就是它。3.3 Java 侧绑定与 Fragment 切换MainActivity 代码public class MainActivity extends AppCompatActivity { private final ListFragment fragmentList new ArrayList(); private Fragment currentFragment; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); BottomNavigationView bottomNav findViewById(R.id.bottom_nav); bottomNav.setOnItemSelectedListener(new NavigationBarView.OnItemSelectedListener() { Override public boolean onNavigationItemSelected(NonNull MenuItem item) { int itemId item.getItemId(); if (itemId R.id.nav_home) { switchFragment(new HomeFragment()); return true; } else if (itemId R.id.nav_category) { switchFragment(new CategoryFragment()); return true; } else if (itemId R.id.nav_cart) { switchFragment(new CartFragment()); return true; } else if (itemId R.id.nav_mine) { switchFragment(new MineFragment()); return true; } return false; } }); // 底部导航在 UI 上默认选中第一个 item但不会主动回调监听器 switchFragment(new HomeFragment()); } private void switchFragment(Fragment target) { FragmentTransaction tx getSupportFragmentManager().beginTransaction(); if (!target.isAdded()) { tx.add(R.id.fragment_container, target); } if (currentFragment ! null currentFragment.isAdded() !currentFragment.isHidden()) { tx.hide(currentFragment); } tx.show(target); tx.commitAllowingStateLoss(); currentFragment target; } }这里有两个容易忽略的点。第一setOnItemSelectedListener 是新版写法material 库旧一点的写法是 setOnNavigationItemSelectedListener类在 1.8.0 之后标记了 deprecated功能完全一样。第二监听器的返回值必须为 true表示“接受这个选中项”返回 false 的话点击之后 UI 不会停留在新 item。Fragment 切换我强烈建议用 show/hide 而不是 replace否则每次切 Tab 都会把 Fragment 销毁重建首页列表的滚动位置、搜索框输入内容全没。show/hide 虽然持有多个 Fragment 实例会占一点内存但带来的状态保留体验很值这也是大多数正规 App 的实际做法。4. 新手最容易踩的 5 个细节4.1 menu 的 title 到底能不能省略网上专门就这个问题讨论过BottomNavigationView 的 item 能不能只写 icon 不写 title答案是不能而且这不是“规范要求”层面的不能是运行时会直接报错。NavigationBarMenu menu ((BottomNavigationView) view).getMenu(); // 遍历每个 item如果 title 为 null 就抛异常 if (item.getTitle() null) { throw new IllegalArgumentException(Item item should have a title); }看源码就会发现BottomNavigationView 在把菜单绑定到自身时会强制检查每个 item 的 title。所以正确的做法是 title 必须写在 menu 里哪怕你想让界面“只显示图标”也不要去动 title。控制文字显示的是 labelVisibilityMode 属性app:labelVisibilityModeunlabeledlabelVisibilityMode 有三种取值值行为labeled所有 Tab 都显示图标和文字selected只有选中的 Tab 显示文字其他只显示图标unlabeled所有 Tab 只显示图标不显示文字另外 title 建议写成 string 资源引用不要硬编码字符串后面做多语言时会非常感谢自己这个习惯。4.2 图标大小和排列怎么调Material 库对 item 内部图标设置了默认尺寸一般是 24dp。当你从设计稿拿到一张 48dp 的图标时直接放进去会被压缩视觉上挤在文字上面。调整方式有两种在布局里用 app:itemIconSize26dp 统一控制在 vector drawable 根节点设置 android:width 和 android:height 指定图标本身尺寸。这两种方法如果同时存在itemIconSize 优先级更高。我实际踩过的坑是给某些 vector 设置了宽高但 viewportWidth 和 viewportHeight 没保持比例导致图标在 item 里偏上或偏下怎么看都不居中。建议在 drawable 里保持 viewport 和实际图形比例一致细节问题用布局预览功能多看一眼。图标和文字之间的距离由 app:itemPadding 控制一般默认 16dp 左右。如果让 UI 设计师看了一眼说“太挤了”可以直接调这个属性试试不一定要回设计稿重新切图。4.3 选中态颜色为什么调不出来很多新手在 item 里直接给 icon 设置颜色或者写在 drawable 里结果发现无论选不选中颜色都一样甚至图标整体变灰。原因是 BottomNavigationView 默认会给 item 图标套一层颜色过滤器由 app:itemIconTint 控制。要想让选中态变色正确做法是在 res/color 目录下建一个 selectorselector xmlns:androidhttp://schemas.android.com/apk/res/android item android:colorcolor/primary android:state_checkedtrue / item android:colorcolor/gray_normal / /selector布局里同时设置app:itemIconTintcolor/bottom_nav_color app:itemTextColorcolor/bottom_nav_color注意 selector 里的状态是 state_checked不是 state_selected。这个坑我见到过很多次写成 state_selected 后一点效果都没有因为 BottomNavigationView 内部用的是 Checkable 机制。如果你希望图标保留原始的多彩颜色不被统一着色可以给 app:itemIconTint 设置为 null。但要注意着色功能关闭后选中态就只能靠文字颜色和背景变化来体现了。绝大多数纯色图标场景我建议保持默认着色逻辑。4.4 item 超过 5 个会怎样BottomNavigationView 在设计上对 item 数量有硬限制最大 5 个。如果你在 menu 里塞了 6 个 item运行时会直接抛出 IllegalArgumentException日志里能看到类似Maximum number of items supported by BottomNavigationView is 5.这是因为 Material 设计规范对底部导航的可点击区域、文字可读性做了约束。想验证当前控件阈值可以调 getMaxItemCount() 方法。超过 5 个 Tab 的常规解法是合并入口比如把“消息”“设置”都收进一个“我的”页面内容确实多的话可以考虑横向滚动的自定义导航或者改用 NavigationRail侧边导航栏等不同交互形态。不要硬塞进去除非你愿意自己写一套不依赖组件的导航栏。4.5 动态改图标、标题和角标业务中经常遇到购物车角标、消息红点的需求。BottomNavigationView 支持运行时操作菜单项MenuItem cartItem bottomNav.getMenu().findItem(R.id.nav_cart); cartItem.setTitle(购物车); cartItem.setIcon(R.drawable.ic_cart_new);数字角标用 BadgeDrawable 更省心BadgeDrawable badge bottomNav.getOrCreateBadge(R.id.nav_cart); badge.setNumber(12); badge.setVisible(true); // 清除角标 badge.clear();getOrCreateBadge 拿到的是同一个 BadgeDrawable 实例重复调用不会叠加数量而是覆盖。很多红点数量“翻倍”的 bug 就是因为反复 new BadgeDrawable 或者重复 setNumber 导致的。另外 setVisible(false) 和 clear() 都能隐藏角标区别是 clear() 会把角标状态重置下次再 getOrCreateBadge 会重新创建干净的对象。5. 常见问题排查与体验优化5.1 问题速查表只要把 BottomNavigationView 用熟大部分问题翻来覆去就那几类。整理成一张表方便排查现象可能原因解决办法点击 item 页面不切换监听器返回了 false在 onNavigationItemSelected 或 OnItemSelectedListener 里 return true图标不显示或全黑itemIconTint 被设置成不可见颜色 / drawable 资源路径不对检查 drawable 引用确认 itemIconTint 没有误设透明文字颜色不随选中变化selector 写了 state_selected换成 state_checked设了 unlabeled 还是显示文字在 menu 里设置 labelVisibility没在布局里设置把 app:labelVisibilityMode 写在布局的 BottomNavigationView 上menu 里超过 5 项直接崩溃组件硬限制最大 item 数合并 Tab或改用其他导航形式列表状态每次切换就丢每次都是 replace Fragment改成 add hide/show 模式底部导航被输入法顶起来软键盘 adjustResize 导致布局整体被压缩按页面需求调整 windowSoftInputMode或自行控制导航显示隐藏主题是 AppCompat 时样式怪异Activity 主题不是 Material 派生主题换成 Theme.MaterialComponents 或 Theme.Material3遇到问题第一反应先看 logcat很多 BottomNavigationView 的崩溃都是 IllegalArgumentException消息里会直接告诉你是 title 缺失还是 item 数量超标。这比自己瞎试快得多。5.2 提升体验的小技巧第一重复点击当前 Tab 时很多 App 有“列表滚回顶部”的交互。可以加一个 setOnItemReselectedListenerbottomNav.setOnItemReselectedListener(new NavigationBarView.OnItemReselectedListener() { Override public void onItemReselected(NonNull MenuItem item) { if (item.getItemId() R.id.nav_home) { // 通知 HomeFragment 滚动到顶部 scrollHomeToTop(); } } });第二处理返回键。大多数 App 的逻辑是当前不在首页按返回键先回首页再按一次才退出应用。可以在 Activity 里重写 onBackPressed 或 onKeyDown判断 bottomNav 当前选中项是不是首页不是就 setSelectedItemId 切回去。第三主题切换时注意颜色资源不要写死。很多项目切到深色模式后selector 里的灰色在黑暗背景下看不清就是因为硬编码了灰度值。建议把默认色定义在 colors.xml 里配合主题引用或者在 selector 中使用 ?attr/colorPrimary 这类主题属性让系统自动跟随主题切换。第四购物车角标别轮询刷新。服务端推送 / 本地事件总线通知比 N 秒轮询优雅得多角标数量变化时再调用 getOrCreateBadge 更新能省不少不必要的绘制。第五如果你项目已经切到 Material3强烈建议试一下 NavigationBar。它是 BottomNavigationView 的“新皮肤”继承关系一样、 API 兼容但内部按 Material3 规范调整了 item 高度、间距和选中态动画。迁移成本很低视觉上却现代很多。最后分享几句个人经验我在实际项目里从 BottomNavigationView 切到 NavigationBar 已经两年了但 title 必填、最多 5 个 item、tint 使用 selector 这些规则完全通用所以老项目的经验一样能拿来用。对新项目我的建议是第一版先用 BottomNavigationView 跑通业务UI 定稿之后再决定要不要换 NavigationBar功能不受影响。别为了“用新组件”而在早期加入不熟悉的依赖项目一忙很容易被一堆小问题拖住。最后再留一个小技巧当你发现 BottomNavigationView 在某些页面出现奇怪的白条或线条时先检查一下根布局的 background 是不是透明再检查主题里的 colorSurface。90% 的显示异常都出在这两个地方而不是组件本身的问题。