civil-and-structural-engineering
Creating a Custom Tab Bar Controller for Better Navigation in Ios
Table of Contents
Understanding the Default Tab Bar Controller
Navigation is the backbone of any iOS application, and the UITabBarController is one of the most common tools for providing quick, top-level navigation between distinct app sections. Apple’s stock implementation handles view controller switching, tab selection highlighting, and basic animations out of the box. However, customization is limited to tint colors, bar tint, background images, and a few edge cases. For apps that demand unique branding, complex animations, custom shapes, or interactive transitions, the default controller becomes a bottleneck.
Building a custom tab bar controller gives you complete control over the UI, animations, and user interactions. This article walks you through a production-ready approach, covering architecture, design patterns, animation techniques, accessibility, and performance considerations. By the end, you’ll have a reusable solution that can be adapted to any iOS project.
Why Go Custom? Key Trade-Offs
Before diving into code, it’s important to weigh the benefits against the additional engineering effort. A custom tab bar offers:
- Unlimited visual customization – Any shape, gradient, custom icons, badge shapes, or even a curved notch.
- Advanced animations – Spring-loaded tab switches, custom transition curves, or motion effects tied to scroll positions.
- Complex interactions – Long-press for secondary actions, swipe across tabs, or dynamic reordering.
- Deep integration – Tie tab appearance to in-app events (e.g., unread counts that pulse, or tabs that change color based on time of day).
On the flip side, you must manually manage view controller lifecycle, handle edge cases like safe areas and orientation changes, and implement accessibility features that the default controller provides automatically. The effort is justified for apps with distinctive design systems or those targeting a premium user experience.
Architecture Overview: The Container Pattern
The canonical approach is to create a container view controller that houses a custom UIView tab bar and manages child view controllers. This follows Apple’s recommended pattern for composing view controllers. Your container:
- Owns an array of child view controllers (one per tab).
- Manages a custom tab bar view (placed at the bottom of the screen, respecting safe areas).
- Transitions between child view controllers using
addChild,removeFromParent, anddidMove(toParent:). - Conforms to a delegate protocol (
CustomTabBarDelegate) to respond to tab taps.
This separation keeps the tab bar view reusable and the container focused on orchestration. It also makes unit testing easier—you can test the container logic without the tab bar’s visual complexity.
Step-by-Step Implementation
1. Designing the Tab Bar View
Create a subclass of UIView called CustomTabBar. This view will render a horizontal row of tab buttons. Each button can be a UIButton or a custom UIControl subclass, depending on your needs. For production, you’ll often need to support images, text, badges, and adaptive layouts (e.g., iPhone vs. iPad).
class CustomTabBar: UIView {
enum TabPosition {
case left, right, center
}
struct TabItem {
let title: String?
let image: UIImage?
let selectedImage: UIImage? // optional
let badgeValue: String?
}
private var stackView: UIStackView!
private var tabButtons: [UIButton] = []
private (set) var selectedIndex: Int = 0
weak var delegate: CustomTabBarDelegate?
// MARK: - Setup
func configure(with items: [TabItem]) {
// Clear existing
tabButtons.forEach { $0.removeFromSuperview() }
tabButtons.removeAll()
// Create a horizontal stack
stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .fillEqually
stackView.alignment = .fill
stackView.spacing = 0
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
for (index, item) in items.enumerated() {
let button = UIButton(type: .system)
button.tintColor = .gray
button.setImage(item.image, for: .normal)
button.setTitle(item.title, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 10)
button.alignImageAndTitleVertically()
button.tag = index
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
button.accessibilityLabel = item.title ?? "Tab \(index + 1)"
button.accessibilityTraits = [.button]
stackView.addArrangedSubview(button)
tabButtons.append(button)
// Optional badge
if let badge = item.badgeValue, !badge.isEmpty {
addBadge(to: button, value: badge)
}
}
// Preselect first tab
selectTab(at: 0)
}
// MARK: - Selection
func selectTab(at index: Int, animated: Bool = true) {
guard index >= 0 && index < tabButtons.count else { return }
selectedIndex = index
for (i, button) in tabButtons.enumerated() {
let isSelected = i == index
button.isSelected = isSelected
button.tintColor = isSelected ? .systemBlue : .gray
UIView.animate(withDuration: animated ? 0.2 : 0) {
button.transform = isSelected ? CGAffineTransform(scaleX: 1.1, y: 1.1) : .identity
}
}
}
// MARK: - Actions
@objc private func tabTapped(_ sender: UIButton) {
let index = sender.tag
if index != selectedIndex {
selectTab(at: index)
delegate?.customTabBar(self, didSelectTabAt: index)
}
}
// MARK: - Badge support
private func addBadge(to button: UIButton, value: String) {
let badge = UILabel()
badge.text = value
badge.font = UIFont.systemFont(ofSize: 10, weight: .bold)
badge.textColor = .white
badge.backgroundColor = .systemRed
badge.textAlignment = .center
badge.layer.cornerRadius = 8
badge.clipsToBounds = true
badge.translatesAutoresizingMaskIntoConstraints = false
button.addSubview(badge)
NSLayoutConstraint.activate([
badge.topAnchor.constraint(equalTo: button.topAnchor, constant: 2),
badge.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -2),
badge.widthAnchor.constraint(greaterThanOrEqualToConstant: 16),
badge.heightAnchor.constraint(equalToConstant: 16)
])
}
}
// Helper extension for vertical alignment
extension UIButton {
func alignImageAndTitleVertically(padding: CGFloat = 4) {
guard let imageView = imageView, let titleLabel = titleLabel else { return }
let imageSize = imageView.frame.size
let titleSize = titleLabel.sizeThatFits(CGSize(width: frame.width, height: .greatestFiniteMagnitude))
imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: titleSize.height + padding, right: -titleSize.width)
titleEdgeInsets = UIEdgeInsets(top: imageSize.height + padding, left: -imageSize.width, bottom: 0, right: 0)
}
}
This implementation includes badge support, vertical alignment of image and title, a simple scale animation on selection, and accessibility labels. The CustomTabBarDelegate protocol should have a method like customTabBar(_:didSelectTabAt:). In production, you’ll likely want to extract badge management into a separate view or use a more robust layout.
2. Building the Container View Controller
The MainContainerViewController owns both the custom tab bar and an array of child view controllers. It implements the delegate to handle tab switching. A critical detail: only one child’s view should be visible at a time. You can either add all children’s views and toggle isHidden, or use addChild/removeFromParent to keep only the selected child in the view hierarchy. The latter saves memory but may introduce a slight delay when switching back (if the child’s view hasn’t been loaded). For most apps, keeping all children loaded but hidden is acceptable and feels snappier.
class MainContainerViewController: UIViewController, CustomTabBarDelegate {
private let customTabBar = CustomTabBar()
private let contentView = UIView() // occupies the area above the tab bar
var viewControllers: [UIViewController] = []
private var currentChild: UIViewController?
override func viewDidLoad() {
super.viewDidLoad()
setupHierarchy()
setupLayout()
setupTabBar()
// Load initial child
if !viewControllers.isEmpty {
switchToChild(at: 0)
}
}
private func setupHierarchy() {
view.addSubview(contentView)
view.addSubview(customTabBar)
contentView.translatesAutoresizingMaskIntoConstraints = false
customTabBar.translatesAutoresizingMaskIntoConstraints = false
}
private func setupLayout() {
let safeArea = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: view.topAnchor),
contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
customTabBar.topAnchor.constraint(equalTo: contentView.bottomAnchor),
customTabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
customTabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
customTabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
customTabBar.heightAnchor.constraint(equalToConstant: 49).priority(.defaultHigh)
])
}
private func setupTabBar() {
customTabBar.delegate = self
let items = [
CustomTabBar.TabItem(title: "Home", image: UIImage(systemName: "house"), selectedImage: nil, badgeValue: nil),
CustomTabBar.TabItem(title: "Search", image: UIImage(systemName: "magnifyingglass"), selectedImage: nil, badgeValue: nil),
CustomTabBar.TabItem(title: "Profile", image: UIImage(systemName: "person"), selectedImage: nil, badgeValue: "3")
]
customTabBar.configure(with: items)
}
// MARK: - CustomTabBarDelegate
func customTabBar(_ tabBar: CustomTabBar, didSelectTabAt index: Int) {
switchToChild(at: index)
}
// MARK: - Child Management
private func switchToChild(at index: Int) {
guard index >= 0 && index < viewControllers.count else { return }
// Remove current child
currentChild?.willMove(toParent: nil)
currentChild?.view.removeFromSuperview()
currentChild?.removeFromParent()
// Add new child
let newChild = viewControllers[index]
addChild(newChild)
newChild.view.frame = contentView.bounds
newChild.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentView.addSubview(newChild.view)
newChild.didMove(toParent: self)
currentChild = newChild
}
}
This container uses manual child management to ensure only one child’s view occupies the content area. You can enhance this with animated transitions (e.g., slide or crossfade) by wrapping the removal and addition in a UIView.transition block.
Advanced Customizations
Animated Transition Between Tabs
Adding a slide or fade transition makes the switch feel polished. Modify switchToChild(at:) to use UIView.animate with a custom container snapshot. A robust approach is to take a snapshot of the current view before removing it, place it on top, animate it out, and then remove it. For simplicity, you can wrap both the removal and addition in a UIView.transition(with: contentView, duration: 0.3, options: .transitionCrossDissolve, animations: { ... }).
Custom Shapes and Notches
If your design calls for a tab bar with a raised center button (like the Instagram or TikTok style), you can override CustomTabBar's draw(_:) or add a CAShapeLayer with a custom path. For example, create a bezier path that lifts the middle section. Then position the center button’s frame accordingly. Handle touch events carefully to prevent the underlying shape layer from intercepting touches.
Dynamic Type and Right-to-Left Layout
Supporting Dynamic Type ensures your tab bar respects the user’s preferred text size. Use UIFontMetrics to scale titles. For right-to-left languages, the stack view’s semanticContentAttribute should be set to .forceRightToLeft. Test on both locales.
Accessibility Considerations
A custom tab bar must match or exceed the accessibility of the default controller. Key points:
- Tab roles: Set
accessibilityTraitsto.buttonandaccessibilityLabelto the tab title. For the selected tab, also add.selected. - Badge announcements: When a badge value changes, post an accessibility notification so VoiceOver users hear the update.
- Keyboard navigation: If your app supports keyboard input (iPad + external keyboard), ensure the tab bar responds to option-tab or arrow keys. Add a
UIFocusGuideor implementaccessibilityCustomActions. - Reduce motion: When
UIAccessibility.isReduceMotionEnabledis true, skip animations and jump directly to the selected tab.
Apple’s UIAccessibility documentation is the definitive reference.
Performance and Memory Management
If your app has many tabs (e.g., 5-7), consider lazy-loading child view controllers. Initialize them only when first selected. For the memory-conscious, you can also release view controllers that are not currently visible and not in the navigation stack—but this requires careful restoration state handling.
Another performance tip: avoid adding heavy views or animations inside the tab bar during scrolling. Profile with Instruments to detect dropped frames. The custom tab bar itself should have a simple draw pipeline—prefer “layer-backed” views over drawRect.
Edge Cases and Testing
- Safe area insets: The tab bar must sit inside the safe area bottom (home indicator clearance). Use
additionalSafeAreaInsetson the container to push the content view above the tab bar, or pin the tab bar to the bottom safe area. - Orientation changes: The tab bar height might need to adjust (e.g., 49pt portrait, 40pt landscape). Override
viewWillTransition(to:with:)and update constraints. - Split view / Slide Over (iPad): When the app runs in a compact size class, the tab bar should behave normally. In regular width, you might hide the tab bar or re-layout differently.
- VoiceOver focus: After switching tabs, move the VoiceOver cursor to the new content area. Use
UIAccessibility.post(notification: .screenChanged, argument: newChild.view).
Reusable Framework or Open-Source
Building your own custom tab bar from scratch is educational, but you may want to evaluate existing open-source libraries like SAMTabBarController or AZTabBar to save time. However, rolling your own gives you total control and avoids dependency risks. For a production app, consider extracting your custom tab bar container into a separate framework so you can unit test it in isolation.
Conclusion
Creating a custom tab bar controller in iOS is a rewarding exercise that dramatically improves your ability to craft a unique navigation experience. By following the container pattern, handling lifecycle correctly, and prioritizing accessibility and performance, you can build a solution that feels as polished as Apple’s own—but precisely tailored to your app’s design vision. Start with a simple implementation, then iteratively add animations, badging, and custom shapes as your needs grow. The result is an interface that delights users and sets your app apart.