Skip to content

Instantly share code, notes, and snippets.

@rolandpeelen
Last active September 27, 2025 11:36
Show Gist options
  • Select an option

  • Save rolandpeelen/56992811e974b37cbf27546559aec6e3 to your computer and use it in GitHub Desktop.

Select an option

Save rolandpeelen/56992811e974b37cbf27546559aec6e3 to your computer and use it in GitHub Desktop.
import SwiftUI
/// A UIViewControllerRepresentable that observes tab bar tap events and intercepts them
/// to provide custom handling before tab selection occurs.
///
/// This component injects itself into the UITabBarController delegate chain to intercept
/// tap events, allowing you to handle special tabs (like modal presentations) without
/// actually switching to them.
///
/// - Parameter Tag: A CaseIterable enum representing all possible tab identifiers
///
/// ## Example Usage
/// ```swift
/// enum TabID: CaseIterable {
/// case home
/// case add
/// case settings
/// }
///
/// struct ContentView: View {
/// @State private var selectedTab: TabID = .home
/// @State private var presentSheet = false
///
/// var body: some View {
/// TabView(
/// selection: Binding(
/// get: { selectedTab },
/// set: { _ in () } // Disable the setter - we'll handle it manually
/// )
/// ) {
/// Tab(value: TabID.home) {
/// HomeView()
/// } label: {
/// Label("Home", systemImage: "house")
/// }
///
/// Tab(value: TabID.settings) {
/// SettingsView()
/// } label: {
/// Label("Settings", systemImage: "gear")
/// }
///
/// Tab(value: TabID.add, role: .search) {
/// EmptyView() // Never shown - we intercept this tab
/// } label: {
/// Label("Add", systemImage: "plus")
/// }
/// }
/// .overlay(
/// TabBarTapObserver<TabID> { tabID in
/// if tabID == .add {
/// // Special handling for add tab - show sheet instead
/// presentSheet = true
/// } else {
/// // Normal tab switching
/// selectedTab = tabID
/// }
/// }
/// .allowsHitTesting(false) // Pass touches through
/// )
/// .sheet(isPresented: $presentSheet) {
/// AddItemView()
/// }
/// }
/// }
/// ```
///
/// This example shows how to:
/// - Intercept all tab taps
/// - Handle special tabs (like "Add") that present modals instead of switching views
/// - Manually control tab selection
/// - Prevent the default tab switching behavior for certain tabs
struct TabBarTapObserver<Tag: CaseIterable>: UIViewControllerRepresentable {
/// Closure called when any tab bar item is tapped
let onTap: (Tag) -> Void
func makeUIViewController(context: Context) -> InjectorViewController {
InjectorViewController(onTap: onTap)
}
func updateUIViewController(_ viewController: InjectorViewController, context: Context) {
viewController.onTap = onTap
}
/// A view controller that injects itself into the view hierarchy to find and
/// intercept the UITabBarController's delegate methods
final class InjectorViewController: UIViewController, UITabBarControllerDelegate {
/// Closure to execute when a tab is tapped
var onTap: (Tag) -> Void
/// Strong reference to the delegate proxy to prevent deallocation
private var delegateProxy: TabBarDelegateProxy<Tag>?
init(onTap: @escaping (Tag) -> Void) {
self.onTap = onTap
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
attachDelegate()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
attachDelegate()
}
/// Attempts to find the UITabBarController in the view hierarchy and attach
/// our custom delegate proxy to intercept tab selections
private func attachDelegate() {
guard delegateProxy == nil,
let rootViewController = view.window?.rootViewController,
let tabBarController = findTabBarController(from: rootViewController)
else { return }
let proxy = TabBarDelegateProxy<Tag>(
onTap: { [weak self] tag in self?.onTap(tag) }
)
tabBarController.delegate = proxy
delegateProxy = proxy // keep strong reference to prevent deallocation
}
/// Recursively searches the view controller hierarchy to find a UITabBarController
///
/// - Parameter viewController: The view controller to start searching from
/// - Returns: The first UITabBarController found, or nil if none exists
private func findTabBarController(from viewController: UIViewController)
-> UITabBarController?
{
// Check if current view controller is a tab bar controller
if let tabBarController = viewController as? UITabBarController {
return tabBarController
}
// Search through child view controllers
for childViewController in viewController.children {
if let tabBarController = findTabBarController(from: childViewController) {
return tabBarController
}
}
// Check presented view controllers (modals)
if let presentedViewController = viewController.presentedViewController {
if let tabBarController = findTabBarController(from: presentedViewController) {
return tabBarController
}
}
return nil
}
}
}
/// A delegate proxy that intercepts UITabBarController delegate calls to provide
/// custom tab selection behavior
///
/// This proxy allows us to intercept tab selections and handle them with custom logic,
/// such as presenting modals instead of switching tabs, or conditionally preventing
/// tab switches.
final class TabBarDelegateProxy<Tag: CaseIterable>: NSObject, UITabBarControllerDelegate {
/// Closure called when any tab is tapped, before the selection occurs
let onTap: (Tag) -> Void
init(onTap: @escaping (Tag) -> Void) {
self.onTap = onTap
}
/// Called before a tab selection occurs, allowing us to intercept and
/// potentially prevent the selection
///
/// - Parameters:
/// - tabBarController: The tab bar controller whose tab was tapped
/// - viewController: The view controller that would be selected
/// - Returns: `false` to prevent the tab selection (we handle it ourselves),
/// `true` to allow normal selection
func tabBarController(
_ tabBarController: UITabBarController,
shouldSelect viewController: UIViewController
) -> Bool {
// Map the tapped view controller to its corresponding Tag enum case
if let viewControllers = tabBarController.viewControllers,
let index = viewControllers.firstIndex(of: viewController),
index < Tag.allCases.count
{
let tag = Array(Tag.allCases)[index]
onTap(tag)
}
// Always return false to prevent automatic tab selection
// The onTap closure should handle the actual navigation logic
return false
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment