ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity UGUI 从零实现底部导航栏 TabBar 完整实践指南

Unity UGUI 从零实现底部导航栏 TabBar 完整实践指南

1. 动手前先拆需求:Tablebar 在项目里到底管什么

做 Unity 客户端,只要项目里超过两三个界面,基本逃不掉 UGUI 的 TabBar,也就是大家常说的底部导航栏。标题里写 Tablebar,其实就是同一个东西的不同叫法,我在工程和代码里统一用 TabBar 来命名。这条看似简单的主界面骨架,实际做细了能牵扯出事件注册、状态管理、分辨率适配、动画手感、红点刷新一大堆问题。这篇文章就按我最近一个工具类 App 的壳子项目来梳理完整流程,代码可以直接抄,同时会把每个关键决策背后的原因讲清楚。适合刚接触 UGUI、准备搭建主界面框架的开发者,也适合被 TabBar 各种奇怪 Bug 困扰的同学对照排查。

1.1 为什么我坚持自己做导航栏,而不是直接引插件

很多人第一反应是去资源商店找现成的底部导航组件,我也试过几次,但最后都放弃了。插件做得再好,它也是一个封闭的整体,你往往要按它的页面容器、消息机制、动画配置来迁就你的业务代码。可实际情况是,每个项目的主界面都有自己的生命周期:有的页面要提前初始化数据,有的页面需要退出前保存草稿,有些页面还牵扯到深度链接跳转和角标同步。插件给的通用方案在这些需求面前,要么改不动,要么越改越歪。

自己实现一个 TabBar 并没有想象中复杂。UGUI 的 Button 组件本身提供了点击事件,RectTransform 提供了灵活的对齐方式,再加上一层简单的状态管理就够了。整个核心代码控制在几百行以内,没有第三方依赖,后面接新需求、换图标、加红点,都是自己说了算。团队里接手的人也能快速看懂,不用去啃插件的封装层。

1.2 把导航栏拆成四个核心模块

动手写之前,我会先把 TabBar 拆成四个相对独立的部分,这样后面每一步都有明确目标。

第一是可视结构。底栏背景、按钮、图标、文字、角标,这些 UI 元素怎么摆、怎么对齐、怎么适配不同屏幕,属于最基础的部分。第二是选中态管理。当前选中哪个按钮,图标和文字颜色怎么变化,是否需要动画过渡,这是用户感知最明显的地方。第三是页面容器与切换。每个按钮对应一个页面根节点,点击按钮时对应的页面显示出来,其他页面隐藏,同时要保证页面内部状态不丢。第四是事件与状态同步。包括按钮点击事件、红点角标刷新、外部主动切换页面、Android 返回键处理等,这部分最容易被忽略,也是最容易出 Bug 的地方。

拆完之后你会发现,TabBar 的本质就是一个简单的状态机:当前选中项是唯一状态,所有按钮和页面的表现都由这个状态推导出来。

1.3 合适的 Canvas 与事件系统配置

在搭建界面之前,先确认 Canvas 和 EventSystem 的配置,否则后面会踩很多看不见的坑。

我一般使用 Screen Space - Overlay 模式的 Canvas,因为它在屏幕适配和 UI 层级上最省心,不需要处理相机和 UI 平面的映射关系。Canvas 上的 Canvas Scaler 设置为 Scale With Screen Size,参考分辨率我用 1080x1920,Screen Match Mode 选 0.5。意思是宽高两个方向各取一半权重来缩放,屏幕比例变化时 UI 整体不会出现一边被拉伸过度的现象。如果你确定 App 只做固定竖屏,可以把匹配值调到 1.0,也就是完全按高度适配,宽度不足时留边。但 0.5 是更通用的选择。

EventSystem 是 UGUI 所有点击事件的中枢,新建场景时 Unity 会自动生成,但如果你是从空场景开始做,需要手动添加。场景里只要有一个 EventSystem,并且带 Standalone Input Module,Button 的点击才能正常工作。这个检查放在第一步,能省掉后面排查点击无效的大量时间。

提示:如果你的项目里需要处理 UI 在不同平台上的表现,Canvas 的 Sort Order 也要统一约定。TabBar 常规情况下放在最上层,Sort Order 可以设成 10,避免被其他弹窗或特效遮挡。

2. 三步搭好界面层次:从空场景到一套能看的底栏

界面结构看起来简单,但节点层级和锚点设置决定了后续适配和代码的复杂程度。我习惯在空场景里徒手搭一遍,确认层级没问题再做成 Prefab。

2.1 节点结构怎么摆,决定了后面要不要返工

底栏的节点层级我一般这样组织:

  • Canvas
    • MainTabBar(RectTransform,底部拉伸)
      • Background(Image,半透明底)
      • TabItem_Home(Button)
        • Icon_Normal(Image)
        • Icon_Selected(Image)
        • Label(Text)
        • Badge(Image + Text)
      • TabItem_Shop(Button)
      • TabItem_Message(Button)
      • TabItem_Mine(Button)

MainTabBar 本身只负责承载整个底栏的 RectTransform 和 CanvasGroup,背景单独放一个子节点,方便调整颜色和圆角。每个按钮节点上挂 Button 组件和 MainTabItem 数据脚本,图标、文字、角标都作为它的子节点。

有一点要特别强调:按钮下面的图标 Image、文字 Text 如果不需要接收点击,第一时间给它把 Raycast Target 属性去掉。这可能是新手最容易踩的坑,很多点击失灵不是代码问题,而是子节点的 Image 把射线拦截了,Button 反而收不到事件。

2.2 锚点、间距与不同分辨率下的表现

底栏的整体锚点设置为底部拉伸,也就是 Anchor Min 为 (0, 0),Anchor Max 为 (1, 0),Pivot 为 (0.5, 0),这样它的宽度始终等于 Canvas 宽度,高度保留一个固定值。通常手机端底部导航高度在 100 到 130 像素之间,按 1080 宽度的参考分辨率,我一般取 120。

按钮的分隔方式有两种。一种是给 MainTabBar 挂 HorizontalLayoutGroup,把每个按钮的 LayoutElement 宽度设为 equal 或者用 ChildForceExpand 做等分,简单省事。另一种是直接手动设置每个按钮的锚点:第一个按钮靠左,最后一个靠右,中间按钮等分。两者都可行,但如果你对间距有特殊要求,手动锚点更可控。

更值得提的是安全区适配。现在很多手机底部有 Home Indicator 或者手势条,如果底栏不做安全区处理,内容就会被系统手势区域挡住。比较简单的方式是在代码里读取 Screen.safeArea,把底栏的底部撑开一块空间。

using UnityEngine; public static class SafeAreaHelper { public static void ApplyBottomPadding(RectTransform target, float bottomHeight) { Rect safeArea = Screen.safeArea; // 如果底部安全区高度不为0,说明有手势条遮挡 float bottomPadding = Mathf.Max(0f, Screen.height - (safeArea.y + safeArea.height)); if (bottomPadding > 0f) { target.offsetMin = new Vector2(target.offsetMin.x, bottomHeight + bottomPadding); } } }

这段代码的意图是:先算出系统手势区占了多高,再把这个高度加到底栏原本的高度之外,这样背景会延伸到安全区底部,按钮仍留在安全区范围内。你可以在 MainTabBar 初始化时调用它,比把所有按钮挨个做安全区偏移要省事得多。

2.3 按钮内部的结构:图标、文字与选中态

每个 TabItem 内部我坚持用两个独立图标节点,一个是灰色常态 Icon_Normal,一个是高亮态 Icon_Selected,两个节点在切换时只做 SetActive(true/false),而不是去修改同一个 Image 的 Sprite 属性。原因很简单:切换 Sprite 会触发 Canvas 的图集重建,频繁切换时性能会有可见开销;而两个节点只是显隐切换,被隐藏的节点不会参与渲染,看起来反而更干净。

文字部分用唯一的 Text 或 TextMeshProUGUI,选中和非选中状态通过代码改颜色。按钮自身挂一个背景 Image 作为 Button 的 Target Graphic,这样 Button 的 ColorTint 才有地方生效。Icon 和 Text 都把 Raycast Target 关掉,不影响点击事件。

布局方面,我习惯把 Icon 放在按钮上方,文字放在下方,Icon 的锚点固定在按钮顶部居中,Text 固定在底部居中,中间留 8 到 12 像素。如果后续产品要求改布局,只需调锚点,不需要动代码。

3. 核心代码:让点击、切换、状态刷新一次到位

界面搭好之后,接下来是整个 TabBar 的灵魂:控制逻辑。我先说结论:不要用一堆散落的 if else 去判断按钮索引,而是用枚举标识页面类型,用一个统一的控制器管理所有状态。

3.1 用枚举管理页面,不硬编码数组下标

页面有四个,以后可能变成五个、六个,如果代码里到处是 0、1、2、3,过一个月你自己都分不清 2 到底代表消息页还是商城页。所以第一步定义一个枚举:

public enum MainTabType { Home = 0, Shop = 1, Message = 2, Mine = 3 }

再定义一个可序列化的 Tab 数据类,通过 Inspector 拖拽绑定,不需要程序去代码里找路径:

using System; using UnityEngine; using UnityEngine.UI; [Serializable] public class MainTabItem { public MainTabType tabType; public Button button; public GameObject iconNormal; public GameObject iconSelected; public Text label; public GameObject badge; public Text badgeText; public GameObject pageRoot; }

这里我把每个按钮对应的页面根节点也放在同一个数据类里,点击按钮时直接通过它切换页面。这样做的好处是,按钮、页面、角标三者之间的对应关系在 Inspector 面板上一目了然,新同事接手时不需要翻代码对着查找。

3.2 注册点击事件与防重入处理

控制器本身挂在一个空物体或者 MainTabBar 上都可以。Start 里遍历所有 Tab 项,逐个注册监听。这里有一个必须要提醒的坑:for 循环里注册匿名函数时,一定要用局部变量保存循环索引,不要直接使用循环变量。否则闭包捕获的是同一个变量,最后所有按钮都会指向最后一个 Tab。

using System.Collections.Generic; using UnityEngine; public class MainTabBarController : MonoBehaviour { [SerializeField] private List<MainTabItem> tabItems; [SerializeField] private Color normalColor = Color.gray; [SerializeField] private Color selectedColor = Color.white; [SerializeField] private MainTabType defaultTabType = MainTabType.Home; private MainTabType currentTabType; private bool isSwitching; private void Start() { InitTabs(); SetSelected(defaultTabType); } private void InitTabs() { for (int i = 0; i < tabItems.Count; i++) { MainTabItem item = tabItems[i]; item.button.onClick.AddListener(() => OnTabButtonClicked(item.tabType)); } } private void OnTabButtonClicked(MainTabType tabType) { if (isSwitching) return; if (tabType == currentTabType) return; SetSelected(tabType); } public void SetSelected(MainTabType tabType) { if (isSwitching) return; isSwitching = true; for (int i = 0; i < tabItems.Count; i++) { MainTabItem item = tabItems[i]; bool selected = item.tabType == tabType; item.iconNormal.SetActive(!selected); item.iconSelected.SetActive(selected); item.label.color = selected ? selectedColor : normalColor; item.pageRoot.SetActive(selected); } currentTabType = tabType; isSwitching = false; } public MainTabType GetCurrentTabType() { return currentTabType; } }

SetSelected 里先置 isSwitching,是为了避免连续两帧点击导致页面切换被重复执行。虽然大部分点击不会在同一帧发生,但遇到拖拽手势触发按钮点击时,这个保护能避免状态错乱。如果你在页面切换里加入了加载数据的异步操作,建议把 isSwitching 的还原放到异步完成之后,而不是当帧还原。

页面切换这里直接用了 SetActive,逻辑直白,适合页面数量少、首帧加载不敏感的场景。如果你有几个页面切换非常频繁,并且希望页面内部不丢状态,可以保留页面常驻,用 CanvasGroup 来控制显隐,后面章节会展开说。

3.3 页面切换与状态刷新

直接 SetActive 的页面在切换时会被销毁和重建吗?UI 对象不会被销毁,但如果页面内部的 ListView 组件在 OnEnable 时强制刷新数据,那每次切换都会重新拉一次数据,给人感觉比较笨重。另一种做法是页面常驻,用 CanvasGroup 控制 alpha 和交互。

using UnityEngine; public static class UIGroupHelper { public static void SetVisible(GameObject root, bool visible) { CanvasGroup group = root.GetComponent<CanvasGroup>(); if (group == null) { group = root.AddComponent<CanvasGroup>(); } group.alpha = visible ? 1f : 0f; group.interactable = visible; group.blocksRaycasts = visible; } }

这个方案在页面切换时会让隐藏页面继续执行 Update、协程和动画,所以不能简单套用到所有场景。我个人的实践是:如果页面内部有动画和纯展示内容,用 SetActive 更省性能;如果页面有滚动手势、输入框、正在播放的视频、或者需要保持表单数据,那就用 CanvasGroup。选择哪种取决于你的业务,没有绝对答案。

状态刷新最关键的一点是:切页时不要每次都从头构造页面数据,而是让页面监听一个统一的 Tab 切换事件,自己在合适的时机刷新。例如消息页在 OnEnable 时判断一下是否有新数据,没有就直接展示缓存,而不是强制请求接口。

3.4 红点角标与图标状态同步

红点在一个 TabBar 里是高频需求,而且往往比你想的更复杂:有些角标只显示一个小红点,有些要显示数字,有些数字超过 99 还要显示 99+。我习惯在控制器里提供一个公共方法,让业务层去调用:

public void ShowBadge(MainTabType tabType, int count) { MainTabItem item = FindItem(tabType); if (item == null) return; if (count <= 0) { item.badge.SetActive(false); return; } item.badge.SetActive(true); item.badgeText.text = count > 99 ? "99+" : count.ToString(); } public void ClearAllBadges() { for (int i = 0; i < tabItems.Count; i++) { tabItems[i].badge.SetActive(false); } } private MainTabItem FindItem(MainTabType tabType) { for (int i = 0; i < tabItems.Count; i++) { if (tabItems[i].tabType == tabType) { return tabItems[i]; } } return null; }

业务侧在拉到新消息、订单状态变更、任务完成时调用 ShowBadge 就行。一定要走事件驱动,不要在 Update 里每帧轮询红点数量,那样不仅浪费性能,还会引起角标闪烁。

4. 选中态动画与细节手感优化

UI 做得好不好看,很多时候不在功能,而在那几十毫秒的过渡反馈。TabBar 的点击不能只是瞬间变个颜色,按下到松手需要有一点自然的弹性或者缩放反馈。

4.1 用 Animator 做一个小而稳的切换过渡

如果项目里已经有 Animator 使用习惯,第一种方案就是给每个 TabItem 的根节点挂一个 Animator,用 Controller 里的 Bool 参数控制选中状态。比如叫 Selected,选中时为 true,未选中为 false。动画可以做一个 100ms 的缩放弹跳,或者图标从下方淡入。配置方式不复杂,但有一个问题:如果后续按钮变多,每个 TabItem 的动画都要重复配置,改起来很烦。

我的偏好是让代码控制简单的变化,Animator 只留给复杂效果。比如图标切换只需要高亮色和淡入,代码几行就搞定;如果产品要求做“凹底按钮”“悬浮动画”“压按粒子特效”,再引入 Animator 也不迟。两种方案没有优劣之分,关键是别把简单事情复杂化。

4.2 零依赖的插值缩放方案

如果不希望引入第三方补间插件,完全可以用协程做插值。点击时把 Icon 从正常大小缩放一下,松手后回弹,体感会好很多。下面这段代码是 EaseOutQuad 缓动的核心实现:

using System.Collections; using UnityEngine; public class TabItemAnimation : MonoBehaviour { private Coroutine scaleRoutine; public void PlayPressEffect(RectTransform target) { StopScaleRoutine(); scaleRoutine = StartCoroutine(AnimateScale(target, 1.15f, 1f, 0.15f)); } private void StopScaleRoutine() { if (scaleRoutine != null) { StopCoroutine(scaleRoutine); scaleRoutine = null; } } private IEnumerator AnimateScale(RectTransform target, float from, float to, float duration) { float time = 0f; while (time < duration) { time += Time.unscaledDeltaTime; float t = Mathf.Clamp01(time / duration); // easeOutQuad 曲线:先快后慢,视觉上更像物理回弹 float eased = 1f - (1f - t) * (1f - t); target.localScale = Vector3.one * Mathf.LerpUnclamped(from, to, eased); yield return null; } target.localScale = Vector3.one * to; } }

这里用 Time.unscaledDeltaTime 而不是 Time.deltaTime,是为了防止游戏在暂停状态或者时间缩放为 0 时,UI 动画跟着卡住。在实际项目中,你可以在 TabItemAnimation 的按钮事件里调用 PlayPressEffect,用 EventTrigger 的 PointerDown 和 PointerUp 分别触发按下和回弹。

4.3 容易被忽略的交互细节

手感除了动画,还包括一些边界情况。我在项目里吃过几次亏,整理下来有这么几条:

第一,按钮事件重复触发。用户快速连点同一个 Tab,如果页面切换有异步加载,会出现两次进入同一个页面的情况。除了用 isSwitching 锁,还有一层保护是在页面切换的异步请求里判断请求是否已经过期。

第二,点击音效要防止爆音。有些人会在按钮点击事件里直接 Play 音效,连点时声音会叠加,听起来很毛躁。简单方案是限制同一个 AudioSource 的播放间隔,比如 50ms 内不重复播放。

第三,隐藏页面要防点击穿透。如果隐藏页面里还有半透明的全屏按钮,切页时即使页面被 SetActive(false),也不会有问题,但如果用 CanvasGroup 隐藏,就必须同时把 blocksRaycasts 置为 false,否则用户在这个区域点击,事件会透到下面的页面,触发莫名其妙的跳转。

第四,Android 返回键的处理。如果用户当前不在第一个 Tab,按系统返回键时应该先切回首页,而不是直接退出应用。这个逻辑通常放在一个独立的 Input Handler 里,判断 currentTabType 后再决定是执行返回还是退出。

5. 上线前必查的坑:定位问题与排查方法

每一个做过 TabBar 的人,都绕不开几个经典问题:按钮点了没反应、切个页面闪一下、红点刷新总是慢半拍。这些问题听起来小,一旦在线上出现,用户会直接感觉 App 卡顿或失灵。我把常见的现象、原因和处理思路整理如下。

5.1 点击没反应?先怀疑的不是代码

点击无反应,大概率不是 C# 逻辑问题,而是事件链路断了。第一步检查场景里有没有 EventSystem,没有的话所有 Button 都是死按钮。第二步检查底栏上方的 UI 元素,是不是有一个透明的全屏 Image 把 TabBar 挡住了。很多新手会把某个根节点上的 Image 的 Raycast Target 开着,看起来界面上什么都没有,实际上它像一块透明玻璃,盖住了整个屏幕,点击事件全部被它吃掉。

诊断方法也不难,打开 Game 视图,点一下 TabBar 区域,如果 Inspector 上不会亮起 EventSystem 的当前选中对象,基本就是射线被上层物体拦截。也可以在 Update 里临时调用 Debug.Log(EventSystem.current.currentSelectedGameObject) 来辅助定位。

第三个隐藏原因是 Canvas 的 Sorting Order。有时候你开了好几个 Canvas,TabBar 所在的 Canvas Sorting Order 比弹窗低,弹窗 Canvas 虽然透明,但射线优先打到了它。此时把不需要参与点击的 Canvas 在 Event Camera 或者 Raycast 上做一下限制,或者调整 Sort Order 顺序。

5.2 换机型后位置跑偏的适配解法

位置跑偏的大多数原因都在 Canvas Scaler 和锚点设置上。如果你把 MainTabBar 的宽度固定成某个数值,比如 1080,那就等于放弃了适配,小屏手机下按钮会挤到屏幕外。正确做法是使用拉伸锚点,让宽度随着 Canvas 宽度走。

如果 App 支持屏幕旋转,情况会复杂一些。横屏切换后,底部导航栏的高度和间距可能需要重新计算。最简单的处理是锁定界面方向,但如果产品一定要支持横竖屏,那就要在 RectTransform 的 OnRectTransformDimensionsChange 回调里重新布局。我个人的建议是尽量不让 TabBar 参与这种复杂变换,把横屏场景的主导航放到侧边栏,这是设计层面更合理的方案。

还有一个经常忽略的点:真机安全区。模拟器上没有手势条,你真机一跑,底部按钮就被 Home Indicator 挡住了。SafeAreaHelper 那个方法不是可选项,而是必选项,上线前一定要在带刘海和手势屏的机器上过一遍。

5.3 切换页面时闪一下或者旧状态残留

页面闪烁通常是生命周期事件触发了多余的逻辑。比如页面每次 SetActive(true) 时,ScrollRect 的 OnEnable 会强制刷新一次,如果你的列表数据加载慢,就会出现一帧空白再显示内容。

处理方式有两种。一种是调整初始化时机,把页面内容的首帧数据缓存下来,OnEnable 时先显示缓存,再做增量刷新。另一种是页面节点常驻,切页时用 CanvasGroup 的 alpha 做淡入淡出,从视觉上抹掉“闪一下”的感觉。两种方法可以结合。

旧状态残留往往出现在点击同一个 Tab 时不做任何处理,但页面内部某些高亮、选中态、输入框内容还保留着。要不要每次进入都重置页面,取决于产品需求。如果你希望保留浏览位置,那就不要整页重置;如果你希望每次回到首页都刷新列表,那就在 OnEnable 里对列表做 Refresh。把这条规则在代码注释里写清楚,不然下一个人很容易改成另一套逻辑。

5.4 红点角标刷新不及时的排查

红点不刷新,先看有没有调用 ShowBadge。我在项目里遇到过一次,消息接口返回后,UI 没有收到通知,原因是消息模块和 TabBar 不在同一个场景里,调用时找不到控制器。后来我改成了全局单例或者事件总线,问题才彻底解决。

另一个问题是 SetActive(false) 之后再调用 GetComponent 相关逻辑,会碰到“对象已被隐藏但引用还在”的情况。只要对象没有被销毁,引用不会变 null,但红点显示条件依赖外部数据,外部数据如果没更新,自然就刷新不了。排插思路也很简单:在 ShowBadge 入口打一个日志,看业务层到底有没有调用、数值是否正确,然后把问题范围快速缩小到数据层还是 UI 层。

6. 项目里的几个提效习惯

这部分算是我个人沉淀下来的一些做法,不一定适合所有团队,但如果你正在搭新的主界面框架,可以参考。

6.1 把 TabBar 做成 Prefab 加 ScriptableObject 配置

我最终会把 TabBar 整套结构存成 Prefab,按钮数量、图标、页面根节点、默认选中项全部通过 Inspector 配置。再用一个 ScriptableObject 保存颜色、字号、间距、动画时长这些参数。这样后续做换肤或者改交互细节时,不需要打开代码,只要调整配置文件就行。项目里不同的 App 壳子还可以共用同一套 TabBar 逻辑,只是数据配置不同。

6.2 平台返回键与生命周期联动

Android 返回键的处理不要写在每个页面里,而是由统一的界面管理器接管。按下返回键先判断当前是不是首页 Tab,不是则切回首页,是则再进行二次返回确认。这块逻辑如果散落在各个页面,会导致状态不可控。我习惯把返回键监听放在一个全局单例里,和 TabBar 控制器通过委托或者事件通信,而不是直接互相引用。

6.3 后续扩展方向

如果你觉得现在的设计还不够灵活,可以再往前一步,把 TabBar 的页面切换从“控制器直接操作各个页面”改成“发布一个 TabChanged 事件,页面自己决定响应”。这个事件总线做出来以后,页面之间的耦合会降低,新增一个 Tab 页时,只需要添加枚举、配置项和页面预制体,控制器代码不用动。

我在实际项目里把这一套沉淀成了通用壳子,后来接新业务时,半小时就能搭出一套带 TabBar 的主界面。这个项目最值的部分不是代码量,而是把导航栏相关的坑提前踩明白了。希望这篇流程记录能帮你少走几步弯路,把更多时间留给真正有挑战的功能上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进