-
-
Save Amzd/2eb5b941865e8c5cccf149e6e07c8810 to your computer and use it in GitHub Desktop.
/// 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 | |
} | |
} | |
} |
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") | |
} | |
} | |
} | |
} | |
} |
@basememara Thanks! It works perfectly. The only thing I am missing is to hide the tabbar when a new view is pushed (with a navigation. link with SwiftUI)
Do you know if that is possible? I've tried to set
tabBarController.hidesBottomBarWhenPushed = true
But it's not working for me.
@pabloecab because the NavigatinView does not work together with the UITabBarController. Jusr hide it manually when something is pushed.
@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
Noticed a caveat that will cause a crash.. inject environment variables and objects don't work so well when doing it through a UIKit entry point. Instead of doing this:
In some scenarios, this caused a crash:
Thread 1: Fatal error: No observable object of type ..Model.Type found. A View.environmentObject(_:) for ..Model.Type may be missing as an ancestor of this view.
So I ended up having to inject the environment instances into each tab instead which resolved the problem:
Injecting it from a native SwiftUI tab also worked properly, so something to be aware of something isn't right when injecting environments from UIKit it seems.