Last active
September 29, 2024 12:32
-
-
Save Amzd/2eb5b941865e8c5cccf149e6e07c8810 to your computer and use it in GitHub Desktop.
UIKitTabView. SwiftUI tab bar view that respects navigation stacks when tabs are switched (unlike the TabView implementation)
This file contains 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
/// An iOS style TabView that doesn't reset it's childrens navigation stacks when tabs are switched. | |
public struct UIKitTabView: View { | |
private var viewControllers: [UIHostingController<AnyView>] | |
private var selectedIndex: Binding<Int>? | |
@State private var fallbackSelectedIndex: Int = 0 | |
public init(selectedIndex: Binding<Int>? = nil, @TabBuilder _ views: () -> [Tab]) { | |
self.viewControllers = views().map { | |
let host = UIHostingController(rootView: $0.view) | |
host.tabBarItem = $0.barItem | |
return host | |
} | |
self.selectedIndex = selectedIndex | |
} | |
public var body: some View { | |
TabBarController(controllers: viewControllers, selectedIndex: selectedIndex ?? $fallbackSelectedIndex) | |
.edgesIgnoringSafeArea(.all) | |
} | |
public struct Tab { | |
var view: AnyView | |
var barItem: UITabBarItem | |
} | |
} | |
@_functionBuilder | |
public struct TabBuilder { | |
public static func buildBlock(_ items: UIKitTabView.Tab...) -> [UIKitTabView.Tab] { | |
items | |
} | |
} | |
extension View { | |
public func tab(title: String, image: String? = nil, selectedImage: String? = nil, badgeValue: String? = nil) -> UIKitTabView.Tab { | |
func imageOrSystemImage(named: String?) -> UIImage? { | |
guard let name = named else { return nil } | |
return UIImage(named: name) ?? UIImage(systemName: name) | |
} | |
let image = imageOrSystemImage(named: image) | |
let selectedImage = imageOrSystemImage(named: selectedImage) | |
let barItem = UITabBarItem(title: title, image: image, selectedImage: selectedImage) | |
barItem.badgeValue = badgeValue | |
return UIKitTabView.Tab(view: AnyView(self), barItem: barItem) | |
} | |
} | |
fileprivate struct TabBarController: UIViewControllerRepresentable { | |
var controllers: [UIViewController] | |
@Binding var selectedIndex: Int | |
func makeUIViewController(context: Context) -> UITabBarController { | |
let tabBarController = UITabBarController() | |
tabBarController.viewControllers = controllers | |
tabBarController.delegate = context.coordinator | |
tabBarController.selectedIndex = 0 | |
return tabBarController | |
} | |
func updateUIViewController(_ tabBarController: UITabBarController, context: Context) { | |
tabBarController.selectedIndex = selectedIndex | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(self) | |
} | |
class Coordinator: NSObject, UITabBarControllerDelegate { | |
var parent: TabBarController | |
init(_ tabBarController: TabBarController) { | |
self.parent = tabBarController | |
} | |
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { | |
if parent.selectedIndex == tabBarController.selectedIndex { | |
popToRootOrScrollUp(on: viewController) | |
} | |
parent.selectedIndex = tabBarController.selectedIndex | |
} | |
private func popToRootOrScrollUp(on viewController: UIViewController) { | |
let nvc = navigationController(for: viewController) | |
let popped = nvc?.popToRootViewController(animated: true) | |
if (popped ?? []).isEmpty { | |
let rootViewController = nvc?.viewControllers.first ?? viewController | |
if let scrollView = firstScrollView(in: rootViewController.view ?? UIView()) { | |
let preservedX = scrollView.contentOffset.x | |
let y = -scrollView.adjustedContentInset.top | |
scrollView.setContentOffset(CGPoint(x: preservedX, y: y), animated: true) | |
} | |
} | |
} | |
private func navigationController(for viewController: UIViewController) -> UINavigationController? { | |
for child in viewController.children { | |
if let nvc = viewController as? UINavigationController { | |
return nvc | |
} else if let nvc = navigationController(for: child) { | |
return nvc | |
} | |
} | |
return nil | |
} | |
public func firstScrollView(in view: UIView) -> UIScrollView? { | |
for subview in view.subviews { | |
if let scrollView = view as? UIScrollView { | |
return scrollView | |
} else if let scrollView = firstScrollView(in: subview) { | |
return scrollView | |
} | |
} | |
return nil | |
} | |
} | |
} |
This file contains 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
struct ExampleView: View { | |
@State var text: String = "" | |
var body: some View { | |
UIKitTabView { | |
NavView().tab(title: "First", badgeValue: "3") | |
Text("Second View").tab(title: "Second") | |
} | |
} | |
} | |
struct NavView: View { | |
var body: some View { | |
NavigationView { | |
VStack { | |
NavigationLink(destination: Text("This page stays when you switch back and forth between tabs (as expected on iOS)")) { | |
Text("Go to detail") | |
} | |
} | |
} | |
} | |
} |
@pabloecab because the NavigatinView does not work together with the UITabBarController. Jusr hide it manually when something is pushed.
@Amzd Could you please help out? How exactly would you hide it manually? Thanks!
@pakenas Add new @Binding
property on TabBarController and in updateView do: tabBarController.tabBar.hidden = newProperty
If you want custom animations and stuff I would advice just use a SwiftUI view instead and keep the tabBar hidden at all times.
Even better: don't ever use SwiftUI's NavigationView or .sheet and use a custom Coordinator system that handles all navigation using UINavigationControllers which then embed the SwiftUI views in UIHostingController and forward the necessary environment objects
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@pabloecab because the NavigatinView does not work together with the UITabBarController. Jusr hide it manually when something is pushed.