iOS 自定义 TabBar 最容易乱的,不是样式,而是中间悬浮按钮的角色

5 阅读4分钟

前言

我今天做了一个很典型的底部导航:

  • 左右两侧是标准模块
  • 中间是一个悬浮按钮
  • 视觉上不是系统默认 TabBar
  • 交互上又不能只是“加个按钮糊上去”

这种设计如果处理不好,后面会出现一堆问题:

  • 选中态混乱
  • 返回逻辑奇怪
  • 页面层级不清楚
  • 弹窗盖不住自定义 tabbar

这篇文章就讲一下,我最后为什么没有魔改旧 TabBar,而是单独新建一套。

为什么不建议直接在旧 TabBar 上硬改

很多老项目里的 TabBar,本质上是标准的 4 项横排:

  • 每个 item 大小一致
  • 布局规则一致
  • 选中态逻辑一致

但新的设计稿往往不是这样。它通常会引入:

  • 中间悬浮按钮
  • 图标和标题上下排布
  • 中间按钮视觉突出
  • safe area 重新适配

这时候继续在旧实现上修,结果通常是“旧逻辑一半、新逻辑一半”,最后代码越来越难维护。

更稳的做法:系统管页面,自定义 View 管视觉

我更推荐下面这种结构:

final class CustomTabBarController: UITabBarController {
    private let customBarView = CustomTabBarView()

    override func viewDidLoad() {
        super.viewDidLoad()
        tabBar.isHidden = true
        view.addSubview(customBarView)
        bindActions()
    }

    private func bindActions() {
        customBarView.onItemSelected = { [weak self] index in
            self?.selectedIndex = index
        }
    }
}

也就是说:

  • UITabBarController 继续负责 child controller 管理
  • 自定义底部 View 只负责画设计稿里的样子

这样做的好处是:

  • 页面切换逻辑仍然稳定
  • 视觉层可以完全按设计来做
  • 不需要和系统 UITabBarButton 死磕

中间按钮到底算不算一个模块

这是自定义 TabBar 里最容易搞错的地方。

很多人的第一反应是:
中间按钮点一下,直接 push 一个页面。

这个方案看起来简单,但问题很多:

  • 返回时容易回到奇怪的位置
  • 选中态不清晰
  • 视觉上像一个 tab,行为上又不是 tab

更稳的方案是:
把中间按钮也当成一个模块。

也就是:

  • 左边若干模块
  • 中间是独立模块
  • 右边若干模块

它只是视觉上更突出,但交互上仍然是“切模块”,不是临时 push

示例:

enum TabItem: Int, CaseIterable {
    case measure
    case scanHome
    case scanEntry
    case info
    case profile
}

final class CustomTabBarView: UIView {
    var onItemSelected: ((Int) -> Void)?

    private let centerButton = UIButton(type: .custom)

    override init(frame: CGRect) {
        super.init(frame: frame)

        centerButton.setImage(UIImage(named: "tab_center_scan"), for: .normal)
        centerButton.adjustsImageWhenHighlighted = false
        centerButton.addTarget(self, action: #selector(centerTapped), for: .touchUpInside)
        addSubview(centerButton)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @objc
    private func centerTapped() {
        onItemSelected?(TabItem.scanEntry.rawValue)
    }
}

这样做以后:

  • 中间按钮不再是“额外按钮”
  • 它就是模块体系的一部分
  • 选中态和页面切换逻辑都会更清楚

Safe Area 和 Home Indicator 怎么处理

这里也很容易误解。

系统的 home indicator 本身不需要你 fake 一条黑线出来。你真正要处理的是:

  • 自定义底部导航背景要延伸到底部 safe area
  • 底部内容要避开系统手势区域
  • 中间悬浮按钮的位置要基于 safeAreaInsets.bottom 调整

例如:

override func layoutSubviews() {
    super.layoutSubviews()

    let bottomInset = safeAreaInsets.bottom
    let barHeight: CGFloat = 62
    let totalHeight = barHeight + bottomInset

    contentView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: totalHeight)
    centerButton.center = CGPoint(
        x: bounds.midX,
        y: 22
    )
}

核心原则只有一句:

home indicator 不要 fake,但 safe area 一定要认真算。

自定义 tabbar 的一个常见坑:弹窗盖不住它

如果你有一个自定义底部导航,并且它经常在 controller 里被 bringSubviewToFront,那普通 overlay 很容易出现这种情况:

  • 页面变暗了
  • 弹窗出来了
  • 但底部 tabbar 还露在外面

这个问题我最后用一个非常简单的方法解决:

func presentOverlay(_ overlay: UIView, from hostView: UIView) {
    if let window = hostView.window {
        window.addSubview(overlay)
        overlay.frame = window.bounds
    } else {
        hostView.addSubview(overlay)
        overlay.frame = hostView.bounds
    }
}

对于需要全屏遮罩的自定义弹窗,直接挂到当前场景里的 window 往往更稳。

这里顺手补一句边界:

  • 不要去找全局单例 window 硬塞
  • 优先从当前页面的 view.window 往上挂

这样在多 scene 场景下也更安全。

总结

自定义 TabBar 最容易做错的,并不是圆角、阴影这些视觉细节,而是:

  • 视觉结构和页面结构没对齐
  • 中间按钮到底是什么角色没想清楚
  • safe area 没处理干净
  • overlay 层级和 tabbar 打架

如果你也在做类似需求,我推荐直接用下面这套思路:

  1. 不要硬改旧 TabBar,单独新建一套
  2. 系统负责页面管理,自定义 View 负责视觉
  3. 中间按钮尽量按模块处理
  4. safe area 认真处理,home indicator 不要 fake
  5. 全屏 overlay 优先挂到 window

一句话总结:

自定义 TabBar 真正难的,不是样式,而是角色边界和层级关系。