Last active
September 27, 2025 11:36
-
-
Save rolandpeelen/56992811e974b37cbf27546559aec6e3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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