Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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")
}
}
}
}
}
@Urkman

This comment has been minimized.

Copy link

@Urkman Urkman commented Nov 17, 2019

Thanks for this... This is a great workaround :)
But one question: Is there a way to show a tab by default?

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Nov 17, 2019

Didn't make that but shouldn't be too hard, lmk when you add it so i can pull it back into this @Urkman

@oivvio

This comment has been minimized.

Copy link

@oivvio oivvio commented Dec 13, 2019

Thanks! This helped me a lot. I'm getting strange behaviour i the iPhone 11 simulator though. The tab bar images are not showing.
Any idea that that might be about?

@oivvio

This comment has been minimized.

Copy link

@oivvio oivvio commented Dec 13, 2019

I see now that your orignial code works on iPhone 11, but my modifications are screwing something up. Sorry for bothering you.

@oivvio

This comment has been minimized.

Copy link

@oivvio oivvio commented Dec 13, 2019

I was adding .edgesIgnoringSafeArea(.all) which messed things up.

@amelnychuck

This comment has been minimized.

Copy link

@amelnychuck amelnychuck commented Jan 2, 2020

Noticing this solution doesn't support the standard interaction of tapping on an already selected tab bar item to go to the root of that navigation. Any ideas on a solution to bringing this functionality back?

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Jan 6, 2020

@amelnychuck Good point, I did not think about that when I made this. In my own apps I use a custom NavigationView that can access it's UINavigationView which I would use to add this functionality.

I don't know an easy way to add that without a custom NavigationView. Please let me know if you figure this out!

@notcome

This comment has been minimized.

Copy link

@notcome notcome commented Feb 19, 2020

May I ask why would you manage the selectedIndex yourself? I have seen your simplified code on Stack Overflow and it seems fine with UITabViewController manages everything internally.

@notcome

This comment has been minimized.

Copy link

@notcome notcome commented Feb 19, 2020

If you have a sandwich structure like SwiftUI -> UIKit -> SwiftUI and you want states to flow from the top SwiftUI level to the lower SwiftUI level this will not work. I have posted a solution on Stack Overflow. Essentially you need to update root views of those UIHostingControllers during the update.

@J-Krush

This comment has been minimized.

Copy link

@J-Krush J-Krush commented Apr 22, 2020

This is awesome, thanks for putting this together.

@Urkman for setting a default tab, you can just change the selectedIndex to be a binding (instead of state) - then you can set the index from outside the UIKitTabView. Which is one reason to manage the selectedIndex here 😉

@rajubd49

This comment has been minimized.

Copy link

@rajubd49 rajubd49 commented Apr 27, 2020

@BrettSchumann

This comment has been minimized.

Copy link

@BrettSchumann BrettSchumann commented Apr 27, 2020

Noticing this solution doesn't support the standard interaction of tapping on an already selected tab bar item to go to the root of that navigation. Any ideas on a solution to bringing this functionality back?

@amelnychuck and @Amzd I think I have managed to figure out the popToRootView issue that so many seem to be having an issue with and that is missing using this method. It has taken me a day or so of some heaving brainstorming and testing but I think I have cracked a simple solution. If you can see improvements that can be made please let me know but here is what I done.

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 {
                popToRootViewController(viewController: viewController)
            }

            parent.selectedIndex = tabBarController.selectedIndex
        }

        fileprivate func popToRootViewController(viewController: UIViewController) {
            guard let navCon = getNavigationController(viewController: viewController)  else {
                return
            }
            
            navCon.popToRootViewController(animated: true)
        }
        
        fileprivate func getNavigationController(viewController: UIViewController) -> UINavigationController? {
            if viewController is UINavigationController {
                return viewController as? UINavigationController
            }
            
            for childViewController in viewController.children {
                if childViewController is UINavigationController {
                    return childViewController as? UINavigationController
                }
                
                if childViewController.children.count > 0 {
                    if let nav = getNavigationController(viewController: childViewController) {
                        return nav
                    }
                }
            }

            return nil
        }
        
    }
}

If you guys would like to see the follow working project I have give us a shout and I will upload to gitHub and stuff.

@rajubd49

This comment has been minimized.

Copy link

@rajubd49 rajubd49 commented May 12, 2020

Showing badge view and updating badge value does not work either using TabView. So update this repo a bit to be able to show and update badge value too. Link: https://github.com/rajubd49/TabViewBadge

@ntnhon

This comment has been minimized.

Copy link

@ntnhon ntnhon commented May 20, 2020

@amelnychuck and @Amzd I think I have managed to figure out the popToRootView issue that so many seem to be having an issue with and that is missing using this method. It has taken me a day or so of some heaving brainstorming and testing but I think I have cracked a simple solution. If you can see improvements that can be made please let me know but here is what I done.

@BrettSchumann: works like a charm for me! Thank you.

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented May 21, 2020

Hey @rajubd49 you must have accidentally copied my code over and put your name above it.
https://github.com/rajubd49/TabViewBadge/blob/master/TabViewBadge/UIKitTabView.swift
https://github.com/rajubd49/TabViewBadge/blob/master/TabViewBadge/TabBarController.swift

I don't mind anyone using it but don't pose it as yours when it's 1:1 copied.

@rajubd49

This comment has been minimized.

Copy link

@rajubd49 rajubd49 commented May 21, 2020

@Amzd, For one of my SwiftUI projects I needed the badge view feature along with tab view state preserved. So I asked here if any of you guys have the solution for it. But then had a workaround for my problem(badge view) and I had my own Tabview solution too where I don't need this code to use in my project.

My intension of using your code is to share this feature here so that anyone in need can get the help from here. That's why I used your code in my project and commented in here to let you know I have an update. My intension was never to get credit for your code or anything, that's why I mentioned in my project readme this repo link (https://github.com/ryangittings/swiftui-bugs) where your solution was mentioned.

Anyway it was purely an unintentional mistake as Xcode create default naming which I should not use. So I've just deleted my repo and I'll put my own codebase and create a repo again. Don't worry mate, your copyright is preserved now :)

@ericlewis

This comment has been minimized.

Copy link

@ericlewis ericlewis commented May 27, 2020

Here is a small improvement to utilize function builders, allowing for system images, and a small addition to the API for convenience, this basically allows for using the API in a very similar way to the official one, as to minimize changes if / when apple fixes this.

import PlaygroundSupport
import SwiftUI

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) {
            parent.selectedIndex = tabBarController.selectedIndex
        }
    }
}

/// An iOS style TabView that doesn't reset it's childrens navigation stacks when tabs are switched.
struct UIKitTabView: View {
    var viewControllers: [UIHostingController<AnyView>]
    @State var selectedIndex: Int = 0
    
    init(@TabBuilder _ views: () -> [Tab]) {
        self.viewControllers = views().map {
            let host = UIHostingController(rootView: $0.view)
            host.tabBarItem = $0.barItem
            return host
        }
    }
    
    var body: some View {
        TabBarController(controllers: viewControllers, selectedIndex: $selectedIndex)
            .edgesIgnoringSafeArea(.all)
    }
    
    struct Tab {
        var view: AnyView
        var barItem: UITabBarItem
        
        init<V: View>(view: V, barItem: UITabBarItem) {
            self.view = AnyView(view)
            self.barItem = barItem
        }
        
        // convenience
        init<V: View>(title: String?, image: String? = nil, selectedImage: String? = nil, content: () -> V) {
            let selectedImage = selectedImage != nil ? UIImage(named: selectedImage!) ?? UIImage(systemName: selectedImage!) : nil
            let barItem = UITabBarItem(title: title, image: image != nil ? UIImage(named: image!) ?? UIImage(systemName: image!) : nil, selectedImage: selectedImage)
            self.init(view: content(), barItem: barItem)
        }
    }
}

@_functionBuilder
struct TabBuilder {
    static func buildBlock(_ items: UIKitTabView.Tab...) -> [UIKitTabView.Tab] {
        items
    }
}

extension View {
    func tab(title: String, image: String? = nil, selectedImage: String? = nil) -> UIKitTabView.Tab {
        UIKitTabView.Tab(title: title, image: image, selectedImage: selectedImage) {
            self
        }
    } 
}

struct ContentView: View {
    var body: some View {
        UIKitTabView {
            Text("First Tab")
                .tab(title: "First")
            Text("Second Tab")
                .tab(title: "Second")
        }
    }
}

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())
@dqii

This comment has been minimized.

Copy link

@dqii dqii commented May 31, 2020

Is there a way to support scrolling to the top on a second tap?

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Jun 1, 2020

@ericlewis that's actually sick, today I learned how @_functionBuilder works. I'll add it in.

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Jun 1, 2020

Is there a way to support scrolling to the top on a second tap?

@dqii Currently ScrollView doesn't really support scrolling programatically but I'm working on a little package for that.

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Jun 1, 2020

Update:

  • Added @_functionBuilder thanks to @ericlewis
  • Added popToRoot with inspiration from @BrettSchumann
  • Added badgeValue by popular request
  • Added an init where you can provide a Binding to handle setting a tab from an event outside the tab bar
  • Added scroll to top (I forgot this was a thing in the original UITabBarController, thanks @dqii)

A limitation in the scroll to top is that it does not show the large title, I don't know how the original UITabBarController does that but I assume private API. I've tried scrollRectToVisible instead of setContentOffset and that does show the large title but I can't get it to scroll the exact height it needs to. scrollRectToVisible is a pain to work with but if someone figures out a robust way to do it please let me know!

@BrettSchumann

This comment has been minimized.

Copy link

@BrettSchumann BrettSchumann commented Jun 1, 2020

When scrolled down in any view (Table, Collection, Scroll, Web, etc) tapping on the NavBar at the top is the default iOS method to return to the top of the list. Should a second tap on the tab bar be needed when a method already exists and works ?

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Jun 1, 2020

@BrettSchumann I don’t know 🤷🏼‍♂️ I’m just implementing the features that are in the standard UIKit tab bar into my SwiftUI version.

@JS1010111

This comment has been minimized.

Copy link

@JS1010111 JS1010111 commented Jun 8, 2020

Thanks for this! And yes it makes sense to replicate the scroll to top feature.

Btw, it is broken when your List expands... SwiftUI seems to shrink the List's content size (check the vertical scroll bar) and keeps growing it back while your scroll. I'm using a List with a ForEach inside (may be the cause).

Can't wait for WWDC20... 🤞

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Jun 8, 2020

@JS1010111 ah that’s unfortunate, I don’t think I can do anything about that

@rajubd49

This comment has been minimized.

Copy link

@rajubd49 rajubd49 commented Jun 24, 2020

This issue is solved now in iOS 14

@SapaTech

This comment has been minimized.

Copy link

@SapaTech SapaTech commented Jul 15, 2020

This issue is solved now in iOS 14

Can you give link please.

@rajubd49

This comment has been minimized.

Copy link

@rajubd49 rajubd49 commented Jul 15, 2020

This issue is solved now in iOS 14

Can you give link please.

  1. You can test it by yourself using Xcode12 with iOS 14 simulator
  2. Check out the demonstration from Paul's video "What's new in SwiftUI for iOS 14?"(Duration 51:50 - 53:35). Link: https://www.youtube.com/watch?v=uitE6bmeFxM
@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Jul 23, 2020

Thanks for the update! @rajubd49

@adampax

This comment has been minimized.

Copy link

@adampax adampax commented Jul 23, 2020

@Amzd I just want to say thanks for putting this together. This has been a big help for me. I understand that most of the issues with TabView will hopefully be resolved in the upcoming release, but your work has made my app and those of many others much more useable in the meantime. Thank you!

And thanks to @ericlewis @BrettSchumann and @dqii for your contributions!

@lukekrikorian

This comment has been minimized.

Copy link

@lukekrikorian lukekrikorian commented Jul 25, 2020

If anyone is have difficult with a Context in environment is not connected to a persistent store coordinator or similar error, the problem is that the view builder doesn't properly pass your environment objects. You'll need to modify the public func tab builder to suit your needs:

public func tab(title: String, image: String? = nil, selectedImage: String? = nil, badgeValue: String? = nil) -> Tabs.Tab {
        ...
		return Tabs.Tab(view: AnyView(self
			.environmentObject(*your code here*)
			.environment(\.managedObjectContext, *your code here*), barItem: barItem)
    }
@pabloecab

This comment has been minimized.

Copy link

@pabloecab pabloecab commented Aug 20, 2020

@Amzd How can I dismiss the tabBar when I navigate to a view inside the NavigationView? Thanks for your work

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Aug 21, 2020

@pabloecab I didn't build that functionality and I am not currently using this in any active projects so I'm not really updating this right now. If you manage to add that functionality I can add it here if you'd like but other than that I am not adding new features.

@jerrycphillips

This comment has been minimized.

Copy link

@jerrycphillips jerrycphillips commented Aug 26, 2020

This is a great work around. Thank you!

Is there a trick to getting the badge value to update? It doesn't seem to respond to observed object changes.

@marc-andrew

This comment has been minimized.

Copy link

@marc-andrew marc-andrew commented Sep 12, 2020

Hi @Amzd,
thank you for sharing the code! I would like to customise the tabBar by adding a spacer between the two tab items.
How can I do that in your code?
Thanks

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Sep 12, 2020

@marc-andrew Can you even do that with the tab bar? I know that’s available for a toolbar, maybe that’s what you’re looking for?

@paulo2703

This comment has been minimized.

Copy link

@paulo2703 paulo2703 commented Sep 21, 2020

This is so AMAZING!

With that you can also set a custom TabBar by just adding a HStack below the UIKitTabView and hiding the real Tabbar in the makeUIViewController with tabbarcontroller.tabbar.isHidden = true ... hope this helps someone

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Sep 21, 2020

@paulo2703 oooh that’s a good idea

@Thomas-Vos

This comment has been minimized.

Copy link

@Thomas-Vos Thomas-Vos commented Sep 28, 2020

Unfortunately this does not appear to work correctly on iOS 14. This is the code I am using with your UIKitTabView implementation:

struct ContentView: View {
    let parentViewModel = ParentViewModel()
    
    var body: some View {
        UIKitTabView {
            NavigationView {
                ParentView(model: parentViewModel)
            }.tab(title: "Tab 1")
        }
    }
}

final class ParentViewModel: ObservableObject {
    let model = ChildViewModel()
}

struct ParentView: View {
    @ObservedObject var model: ParentViewModel
    
    var body: some View {
        NavigationLink(destination: ChildView(model: model.model)) {
            Text(verbatim: "navigate to ChildView")
        }
        .onAppear { print("ParentView onAppear") }
        .onDisappear { print("ParentView onDisappear") }
    }
}
final class ChildViewModel: ObservableObject {
    @Published var showActionSheet = false
}

struct ChildView: View {
    @ObservedObject var model: ChildViewModel
    
    var body: some View {
        Button("Show action sheet") {
            model.showActionSheet = true
        }
        .actionSheet(isPresented: $model.showActionSheet) {
            ActionSheet(title: Text("Action sheet"))
        }
        .onAppear { print("ChildView onAppear") }
        .onDisappear { print("ChildView onDisappear") }
    }
}

Here is a screenrecording of what happens when running on a real iPhone with iOS 14.0.1:

screenrecording

As you can see, when the Show action sheet button is tapped, the NavigationView returns to the root view. If you remove the UIKitTabView all works fine. For some reason this does not happen every time, so if you cannot reproduce it, just tap the Run button in Xcode again and try again for a few times. It seems to happen more often when the app is ran from the Home screen (so without Xcode debugging turned on).

I am using Xcode Version 12.0 (12A7209).

The following appears in the logs:

ParentView onAppear
ChildView onAppear
ParentView onDisappear
2020-09-28 11:59:31.791987+0200 SwiftUI_Testing[6071:1432668] [LayoutConstraints] Unable to simultaneously satisfy constraints.
	Probably at least one of the constraints in the following list is one you don't want. 
	Try this: 
		(1) look at each constraint and try to figure out which you don't expect; 
		(2) find the code that added the unwanted constraint or constraints and fix it. 
(
    "<NSLayoutConstraint:0x28183d950 UIView:0x10450e040.width == - 16   (active)>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x28183d950 UIView:0x10450e040.width == - 16   (active)>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKitCore/UIView.h> may also be helpful.
ParentView onAppear
ParentView onDisappear
ParentView onAppear
ChildView onDisappear

As you can see, onAppear/onDisappear events are completely wrong.

It would be amazing if you could look into this.

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Sep 29, 2020

@Thomas-Vos This works for me on Xcode 12.0 beta (12A6159) on an iOS 14.0 simulator. I do not have a device on iOS 14 yet or even the last Xcode. Very specific bug that I'm sure is not intended by Apple. If you can get to the bottom of it please report it to Apple using the feedback app! They are fixing issues in SwiftUI very fast so please please do report it.

Once I get a device on 14.0.1 I'll see if I can add a workaround but until then you're on your own sadly. Sorry.

@JanC

This comment has been minimized.

Copy link

@JanC JanC commented Nov 8, 2020

hi,
I have exactly the same issue as @Thomas-Vos
This happens also when I present an Alert Sheet and also very randomly. Usually only the 1st time I show the actionSheet and only when running on a device without debugging.

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Nov 9, 2020

@JanC I don't have an iOS 14 device so I cannot help since it works fine on the simulator for me. Please update me if you manage to find a solution.

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Nov 9, 2020

This was originally made to fix bugs in SwiftUI 1.0. I haven't worked much with SwiftUI 2.0 but if those bugs are fixed you could just use the native TabView in iOS 14.

if @available(iOS 14, *) {
  TabView {
    ...
  }
} else {
  UIKitTabView {
      ...
  }
}

Just make sure to test both iOS 13 and 14 because EnvironmentObjects are not send through automatically on the iOS 13 version because of the UIKit layer.

@Thomas-Vos

This comment has been minimized.

Copy link

@Thomas-Vos Thomas-Vos commented Nov 9, 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())
        }
    }
}

Screen Recording 2020-09-28 at 14 43 20

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.

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Nov 16, 2020

@Thomas-Vos That is definitely a weird bug but maybe you could just detect the change of selectedTab instead of work with the onAppear?

@Thomas-Vos

This comment has been minimized.

Copy link

@Thomas-Vos Thomas-Vos commented Nov 16, 2020

@Amzd that is a good idea but I am not sure how to use that with a navigation stack. Let's say you have a sub-view in one of those tabs using a NavigationLink. How do you know which of those sub views is currently visible if you cannot use onAppear/onDisappear as shown above? Your suggestion with selectedTab would only show if the tab is selected, not which one of the sub views in that selected tab is actually visible.

@Amzd

This comment has been minimized.

Copy link
Owner Author

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

This comment has been minimized.

Copy link

@malhal 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 {
    var id: Int
    
    var body: some View {
        Text("Tab Content \(id)")
            .onAppear {
                print("ChildView \(id) onAppear")
            }
            .onDisappear {
                print("ChildView \(id) onDisappear")
            }
    }
}

@schrockwell

This comment has been minimized.

Copy link

@schrockwell schrockwell commented Dec 30, 2020

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

@Amzd

This comment has been minimized.

Copy link
Owner Author

@Amzd Amzd commented Dec 30, 2020

@schrockwell I agree and thanks for the nice words.

@scottrichards

This comment has been minimized.

Copy link

@scottrichards scottrichards commented Jan 12, 2021

Awesome, just what I was looking for!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.