-
-
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") | |
} | |
} | |
} | |
} | |
} |
Hello! How i can change badgeValue on tab from any view?
Hello! How i can change badgeValue on tab from any view?
I’d suggest using either a preference key or an environment object
For future commenters; This is not stackoverflow
Hello! How i can change badgeValue on tab from any view?
I’d suggest using either a preference key or an environment object
Thanks, but simple example does not working, badgeValue does not change
struct ExampleView: View {
@State var text: String = ""
@State var test: Int = 0
var body: some View {
Button(action : {
self.test += 1
}){
Text(self.test)
}
UIKitTabView {
NavView().tab(title: "First", badgeValue: self.test.description)
Text("Second View").tab(title: "Second")
}
}
}
Hey @sesencheg, can you move your question to stackoverflow as suggested to avoid polluting this gist please? Your question is way out of topic.
Actually after his second message I think this might be a bug report rather than just a SwiftUI question.
I think to fix it we'd have to replace the tabbaritems on the tabcontroller because they don't update when the hostingcontrollers tabbaritem updates, ugh.
I think your best bet is to use StatefulTabView which builds on this gist and it looks like they fixed that tabbaritem issue https://github.com/NicholasBellucci/StatefulTabView
This is still a problem in iOS 14.5.. I'm hoping WWDC 2021 fixes the broken TabView
. Screens drop state in simple scenarios which is a major problem since the tab view houses the entire app. This workaround works really well, thank you!!
I'm adding my variation below too but cuts out some options for my needs so less maintenance for the temp fix, and made the API a little more similar to native so it can be seamlessly swapped out to the SwiftUI version when finally fixed.
Usage:
UITabView(selection: $selectedTab) {
NavigationView {
ContentView1()
}
.tabItem("Tab 1", image: UIImage(named: "blah1"))
NavigationView {
ContentView2()
}
.tabItem("Tab 2", image: UIImage(named: "blah2"))
NavigationView {
ContentView3()
}
.tabItem("Tab 3", image: UIImage(named: "blah3"))
}
Code:
private struct UITabView: View {
private let viewControllers: [UIHostingController<AnyView>]
private let tabBarItems: [TabBarItem]
@Binding private var selectedIndex: Int
init(selection: Binding<Int>, @TabBuilder _ content: () -> [TabBarItem]) {
_selectedIndex = selection
(viewControllers, tabBarItems) = content().reduce(into: ([], [])) { result, next in
let tabController = UIHostingController(rootView: next.view)
tabController.tabBarItem = next.barItem
result.0.append(tabController)
result.1.append(next)
}
}
var body: some View {
TabBarController(
controllers: viewControllers,
tabBarItems: tabBarItems,
selectedIndex: $selectedIndex
)
.ignoresSafeArea()
}
}
private extension UITabView {
struct TabBarItem {
let view: AnyView
let barItem: UITabBarItem
let badgeValue: String?
init<T>(
title: String,
image: UIImage?,
selectedImage: UIImage? = nil,
badgeValue: String? = nil,
content: T
) where T: View {
self.view = AnyView(content)
self.barItem = UITabBarItem(title: title, image: image, selectedImage: selectedImage)
self.badgeValue = badgeValue
}
}
struct TabBarController: UIViewControllerRepresentable {
let controllers: [UIViewController]
let tabBarItems: [TabBarItem]
@Binding var selectedIndex: Int
func makeUIViewController(context: Context) -> UITabBarController {
let tabBarController = UITabBarController()
tabBarController.viewControllers = controllers
tabBarController.delegate = context.coordinator
tabBarController.selectedIndex = selectedIndex
return tabBarController
}
func updateUIViewController(_ tabBarController: UITabBarController, context: Context) {
tabBarController.selectedIndex = selectedIndex
tabBarItems.forEach { tab in
guard let index = tabBarItems.firstIndex(where: { $0.barItem == tab.barItem }),
let controllers = tabBarController.viewControllers
else {
return
}
guard controllers.indices.contains(index) else { return }
controllers[index].tabBarItem.badgeValue = tab.badgeValue
}
}
func makeCoordinator() -> TabBarCoordinator {
TabBarCoordinator(self)
}
}
class TabBarCoordinator: NSObject, UITabBarControllerDelegate {
private static let inlineTitleRect = CGRect(x: 0, y: 0, width: 1, height: 1)
private var parent: TabBarController
init(_ tabBarController: TabBarController) {
self.parent = tabBarController
}
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard parent.selectedIndex == tabBarController.selectedIndex else {
parent.selectedIndex = tabBarController.selectedIndex
return
}
guard let navigationController = navigationController(in: viewController) else {
scrollToTop(in: viewController)
return
}
guard navigationController.visibleViewController == navigationController.viewControllers.first else {
navigationController.popToRootViewController(animated: true)
return
}
scrollToTop(in: navigationController, selectedIndex: tabBarController.selectedIndex)
}
func scrollToTop(in navigationController: UINavigationController, selectedIndex: Int) {
let views = navigationController.viewControllers
.map(\.view.subviews)
.reduce([], +) // swiftlint:disable:this reduce_into
guard let scrollView = scrollView(in: views) else { return }
scrollView.scrollRectToVisible(Self.inlineTitleRect, animated: true)
}
func scrollToTop(in viewController: UIViewController) {
let views = viewController.view.subviews
guard let scrollView = scrollView(in: views) else { return }
scrollView.scrollRectToVisible(Self.inlineTitleRect, animated: true)
}
func scrollView(in views: [UIView]) -> UIScrollView? {
var view: UIScrollView?
views.forEach {
guard let scrollView = $0 as? UIScrollView else {
view = scrollView(in: $0.subviews)
return
}
view = scrollView
}
return view
}
func navigationController(in viewController: UIViewController) -> UINavigationController? {
var controller: UINavigationController?
if let navigationController = viewController as? UINavigationController {
return navigationController
}
viewController.children.forEach {
guard let navigationController = $0 as? UINavigationController else {
controller = navigationController(in: $0)
return
}
controller = navigationController
}
return controller
}
}
}
private extension View {
func tabItem(
_ title: String,
image: UIImage?,
selectedImage: UIImage? = nil,
badgeValue: String? = nil
) -> UITabView.TabBarItem {
UITabView.TabBarItem(
title: title,
image: image,
selectedImage: selectedImage,
badgeValue: badgeValue,
content: self
)
}
}
@resultBuilder
private struct TabBuilder {
static func buildBlock(_ elements: UITabView.TabBarItem...) -> [UITabView.TabBarItem] {
elements
}
}
Thank you to @Amzd for the inspiration and taking the charge, and to @NicholasBellucci for evolving it. cc @bitwit @gkye
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:
UITabView(selection: $selectedTab) {
NavigationView {
ContentView1()
}
.tabItem("Tab 1", image: UIImage(named: "blah1"))
NavigationView {
ContentView2()
}
.tabItem("Tab 2", image: UIImage(named: "blah2"))
NavigationView {
ContentView3()
}
.tabItem("Tab 3", image: UIImage(named: "blah3"))
}
.environmentObject(someModel)
.environment(\.someThing, someThing)
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:
UITabView(selection: $selectedTab) {
NavigationView {
ContentView1()
}
.environmentObject(someModel)
.environment(\.someThing, someThing)
.tabItem("Tab 1", image: UIImage(named: "blah1"))
NavigationView {
ContentView2()
}
.environmentObject(someModel)
.environment(\.someThing, someThing)
.tabItem("Tab 2", image: UIImage(named: "blah2"))
NavigationView {
ContentView3()
}
.environmentObject(someModel)
.environment(\.someThing, someThing)
.tabItem("Tab 3", image: UIImage(named: "blah3"))
}
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.
@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
@Amzd Thank you for your reply.