Skip to content

Instantly share code, notes, and snippets.

@Amzd
Last active September 29, 2024 12:32
Show Gist options
  • Save Amzd/2eb5b941865e8c5cccf149e6e07c8810 to your computer and use it in GitHub Desktop.
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)
/// 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")
}
}
}
}
}
@Amzd
Copy link
Author

Amzd commented Nov 16, 2020

This is leaving the scope of this gist so if you'd like to discuss more message me on discord Amzd#8444

@malhal
Copy link

malhal commented Dec 26, 2020

The native TabView cannot be used yet on iOS 14 in our app because of the following:

https://feedbackassistant.apple.com/feedback/8750702

Basic Information

Please provide a descriptive title for your feedback:

TabView onAppear is broken in iOS 14 which causes performance issues

Which area are you seeing an issue with?

SwiftUI Framework

What type of feedback are you reporting?

Incorrect/Unexpected Behavior

Description

Please describe the issue and what steps we can take to reproduce it:

SwiftUI calls onAppear for the wrong tab. It is called for a tab that is not visible, which is incorrect. This causes performance issues in our app, because content is loaded for multiple tabs at the same time, while those tabs are not even visible to the user. I attached an Xcode project which contains code to reproduce the problem. See the attached screen recording.
Xcode Version 12.0 (12A7209)
Steps to reproduce the problem:

  1. Open the attached Xcode 12 project. Create a new SwiftUI project with the code at the bottom (could not attach zip on GitHub).
  2. Run the Xcode project on either a real device or simulator with iOS 14.
  3. The app opens. In the logs you will see "ChildView 1 onAppear", which is correct, as the first tab content is visible.
  4. Now open the second tab. In the logs you will see "ChildView 1 onDisappear", which is correct, because the first tab content is no longer visible.
  5. Now open the third tab. In the logs you will see "ChildView 1 onAppear", which is incorrect. This is the bug, the first tab is NOT visible but onAppear is called.

You can find the code to reproduce the issue in the attached Xcode project. There is a screen recording which shows the issue on a simulator. I included the code below as well:
----- ContentView.swift -----

import SwiftUI

final class NavigationController: ObservableObject  {
    @Published var selectedTab = 1
}

struct ContentView: View {
    @EnvironmentObject var nav: NavigationController
    
    var body: some View {
        TabView(selection: $nav.selectedTab) {
            ChildView(id: 1)
                .tabItem { Text("Tab Label 1") }
                .tag(1)
            ChildView(id: 2)
                .tabItem { Text("Tab Label 2") }
                .tag(2)
            ChildView(id: 3)
                .tabItem { Text("Tab Label 3") }
                .tag(3)
        }
    }
}

struct ChildView: View {
    var id: Int
    @EnvironmentObject var nav: NavigationController
    
    var body: some View {
        Text("Tab Content \(id)")
            .onAppear {
                print("ChildView \(id) onAppear")
            }
            .onDisappear {
                print("ChildView \(id) onDisappear")
            }
    }
}

----- TabViewBugIos14App.swift -----

import SwiftUI

@main
struct TabViewBugIos14App: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(NavigationController())
        }
    }
}

If someone else is running into this issue as well, please report it to Apple. I reported this issue on Sep 28, 2020 but did not get any response yet. This bug hasn't been fixed in iOS 14.1 or 14.2 unfortunately.

You have a pretty serious design issue there that is causing your bug. You shouldn't use ObservableObject for view state. If you change it to use @State then it works fine. Only use ObservableObject for loaders/fetchers of model data. Keep view state inside the View structs.

import SwiftUI

struct ContentView: View {
    @State var selectedTab = 1
    
    var body: some View {
        TabView(selection: $selectedTab) {
            ChildView(id: 1)
                .tabItem { Text("Tab Label 1") }
                .tag(1)
            ChildView(id: 2)
                .tabItem { Text("Tab Label 2") }
                .tag(2)
            ChildView(id: 3)
                .tabItem { Text("Tab Label 3") }
                .tag(3)
        }
    }
}

struct ChildView: View {
    let id: Int
    
    var body: some View {
        Text("Tab Content \(id)")
            .onAppear {
                print("ChildView \(id) onAppear")
            }
            .onDisappear {
                print("ChildView \(id) onDisappear")
            }
    }
}

@schrockwell
Copy link

This was a lifesaver! It's baffling that the default SwiftUI implementation does not honor this common behavior. Thank you so much.

@Amzd
Copy link
Author

Amzd commented Dec 30, 2020

@schrockwell I agree and thanks for the nice words.

@scottrichards
Copy link

Awesome, just what I was looking for!

@liamsammut97
Copy link

Great fix, is it possible to add a systemImage to the tab ? to be able to use sfSymbols?

@Amzd
Copy link
Author

Amzd commented Apr 3, 2021

@liamsammut97 yeah that's built in, literally the first line of the View.tab function

@iosdroid
Copy link

iosdroid commented Apr 13, 2021

How to load rootviewcontroller when select the tab? currently its load last view controllers i need to load parent view

@Amzd
Copy link
Author

Amzd commented Apr 13, 2021

@iosdroid that is the exact opposite of what this gist is for. SwiftUI (unlike the 10 years of UIKit before it) pops to rootview when you select a tab. This gist prevents that and keeps the UIKit behavior.

@iosdroid
Copy link

@Amzd Thank you for your reply.

@sesencheg
Copy link

Hello! How i can change badgeValue on tab from any view?

@Amzd
Copy link
Author

Amzd commented Apr 26, 2021

Hello! How i can change badgeValue on tab from any view?

I’d suggest using either a preference key or an environment object

@Amzd
Copy link
Author

Amzd commented Apr 26, 2021

For future commenters; This is not stackoverflow

@sesencheg
Copy link

sesencheg commented Apr 28, 2021

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")
}
}
}

@JanC
Copy link

JanC commented Apr 28, 2021

Hey @sesencheg, can you move your question to stackoverflow as suggested to avoid polluting this gist please? Your question is way out of topic.

@Amzd
Copy link
Author

Amzd commented Apr 28, 2021

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

@basememara
Copy link

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

@basememara
Copy link

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.

Copy link

ghost commented Dec 28, 2021

@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.

@Amzd
Copy link
Author

Amzd commented Dec 28, 2021

@pabloecab because the NavigatinView does not work together with the UITabBarController. Jusr hide it manually when something is pushed.

@pakenas
Copy link

pakenas commented Apr 19, 2022

@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!

@Amzd
Copy link
Author

Amzd commented Apr 20, 2022

@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