前言
我今天做了一个很典型的底部导航:
- 左右两侧是标准模块
- 中间是一个悬浮按钮
- 视觉上不是系统默认 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 打架
如果你也在做类似需求,我推荐直接用下面这套思路:
- 不要硬改旧 TabBar,单独新建一套
- 系统负责页面管理,自定义 View 负责视觉
- 中间按钮尽量按模块处理
- safe area 认真处理,home indicator 不要 fake
- 全屏 overlay 优先挂到
window
一句话总结:
自定义 TabBar 真正难的,不是样式,而是角色边界和层级关系。