TabView resets navigation stack when switching tabs
Asked Answered
P

3

44

I have a simple TabView:

TabView {
    NavigationView {
        VStack {
            NavigationLink(destination: Text("Detail")) {
                Text("Go to detail")
            }
        }
    }
        .tabItem { Text("First") }
        .tag(0)
    Text("Second View")
        .tabItem { Text("Second") }
        .tag(1)
}

When I go to the detail view on tab 1, switch to tab 2 then switch back to tab 1 I would assume to go back to the detail view (a basic UX found everywhere in iOS). Instead it resets to the root view of tab 1.

Since SwiftUI doesn't look to support this out of the box, how do I work around this?

Pardo answered 3/9, 2019 at 12:50 Comment(0)
B
6

Here's a simple example of how to preserve state for a navigation stack with a list of items at the root:

struct ContentView: View {

    var body: some View {

        TabView {

            Text("First tab")
                .tabItem { Image(systemName: "1.square.fill"); Text("First") }
                .tag(0)

            SecondTabView()
                .tabItem { Image(systemName: "2.square.fill"); Text("Second") }
                .tag(1)
        }
    }
}

struct SecondTabView: View {

    private struct ListItem: Identifiable {
        var id = UUID()
        let title: String
    }

    private let items = (1...10).map { ListItem(title: "Item #\($0)") }

    @State var selectedItemIndex: Int? = nil

    var body: some View {

        NavigationView {
            List(self.items.indices) { index in
                NavigationLink(destination:  Text(self.items[index].title),
                               tag: index,
                               selection: self.$selectedItemIndex) {
                    Text(self.items[index].title)
                }
            }
            .navigationBarTitle("Second tab", displayMode: .inline)
        }
    }
}
Borden answered 5/12, 2019 at 14:53 Comment(6)
This works in Xcode 11.3 beta for me so going forward this is better than my solution. This doesn't work in Xcode 11.0 (the only older version I have installed to test it in)Pardo
@Casper Zandbergen, thanks for your editing suggestion. Missed it when adapting from my code.Borden
And this solution is actually not universal (that's why I made that "navigation stack with a list of items" remark), so yours is still relevant. It works only for cases when the state of the UI can be recreated from some persistent data (selectedItemIndex in that case). If, for instance, there is a web view in each tab, then switching between them will cause them to reload.Borden
since the original question was purely about the navigation stack resetting I'll still keep your answer as the accepted answer.Pardo
This is the right answer. If "the application is a function of state", then you need somewhere to store that state, otherwise the state will be lost. Binding things to a property in a ViewModel - like the selected tab of a TabView or the selection of a NavigationLink - also gives you a way to do state restoration when the app restarts.Skyway
@Borden The web-view-case was exactly mine. This is pretty simple to work around by preserving the instance of the WKWebView when first creating it and returning this instance on calls to makeUIView().Alltime
P
31

The not so obvious solution here was to actually not use SwiftUI. To get the UIKit behaviour I wrapped a UIKit UITabBarController in a SwiftUI UIViewControllerRepresentable like in this example: https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit.

I show a basic implementation here. The full up to date implementation is on github: https://gist.github.com/Amzd/2eb5b941865e8c5cccf149e6e07c8810

Wrap the UIKit UITabBarController in a SwiftUI view:

struct UIKitTabView: View {
    var viewControllers: [UIHostingController<AnyView>]

    init(_ tabs: [Tab]) {
        self.viewControllers = tabs.map {
            let host = UIHostingController(rootView: $0.view)
            host.tabBarItem = $0.barItem
            return host
        }
    }

    var body: some View {
        TabBarController(controllers: viewControllers)
            .edgesIgnoringSafeArea(.all)
    }

    struct Tab {
        var view: AnyView
        var barItem: UITabBarItem

        init<V: View>(view: V, barItem: UITabBarItem) {
            self.view = AnyView(view)
            self.barItem = barItem
        }
    }
}
struct TabBarController: UIViewControllerRepresentable {
    var controllers: [UIViewController]

    func makeUIViewController(context: Context) -> UITabBarController {
        let tabBarController = UITabBarController()
        tabBarController.viewControllers = controllers
        return tabBarController
    }

    func updateUIViewController(_ uiViewController: UITabBarController, context: Context) {

    }
}

Example usage:

struct ExampleView: View {
    @State var text: String = ""

    var body: some View {
        UIKitTabView([
            UIKitTabView.Tab(
                view: NavView(), 
                barItem: UITabBarItem(title: "First", image: nil, selectedImage: nil)
            ),
            UIKitTabView.Tab(
                view: Text("Second View"), 
                barItem: UITabBarItem(title: "Second", image: nil, selectedImage: nil)
            )
        ])
    }
}

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")
                }
            }
        }
    }
}
Pardo answered 30/9, 2019 at 9:22 Comment(5)
Hi, this is a very nice workaround but I still have some issues with the navigation bar. Instead of just a simple Text view for each tab’s content, I used a NavigationView (just like your First tab). This works fine for the first 4 tabs. However, for tabs under More you get a double navigation bar. The top Navigation bar says “More” and the other one below it is the SwiftUI navigation bar with my own title. Are you aware of this issue and do you know a fix for this?Toothless
@ThomasVos I don't know. I don't use the "more" feature of UITabBarController because I think it's very user unfriendlyPardo
@CasperZandbergen: Thanks! I'm using your solution, but I would like to add the functionality of adding a new tab on runtime. Any idea how to do that? Just creating a new Tab doesn't add a new ViewControllerReproval
@CasperZandbergen this solution is better imo than the one you marked as. TabView should preserve tab state (or at least optionally) by itselfCambogia
this is just bad. why did they provide a TabView in the first place if it keeps getting recreated on tab switch.Guilford
B
6

Here's a simple example of how to preserve state for a navigation stack with a list of items at the root:

struct ContentView: View {

    var body: some View {

        TabView {

            Text("First tab")
                .tabItem { Image(systemName: "1.square.fill"); Text("First") }
                .tag(0)

            SecondTabView()
                .tabItem { Image(systemName: "2.square.fill"); Text("Second") }
                .tag(1)
        }
    }
}

struct SecondTabView: View {

    private struct ListItem: Identifiable {
        var id = UUID()
        let title: String
    }

    private let items = (1...10).map { ListItem(title: "Item #\($0)") }

    @State var selectedItemIndex: Int? = nil

    var body: some View {

        NavigationView {
            List(self.items.indices) { index in
                NavigationLink(destination:  Text(self.items[index].title),
                               tag: index,
                               selection: self.$selectedItemIndex) {
                    Text(self.items[index].title)
                }
            }
            .navigationBarTitle("Second tab", displayMode: .inline)
        }
    }
}
Borden answered 5/12, 2019 at 14:53 Comment(6)
This works in Xcode 11.3 beta for me so going forward this is better than my solution. This doesn't work in Xcode 11.0 (the only older version I have installed to test it in)Pardo
@Casper Zandbergen, thanks for your editing suggestion. Missed it when adapting from my code.Borden
And this solution is actually not universal (that's why I made that "navigation stack with a list of items" remark), so yours is still relevant. It works only for cases when the state of the UI can be recreated from some persistent data (selectedItemIndex in that case). If, for instance, there is a web view in each tab, then switching between them will cause them to reload.Borden
since the original question was purely about the navigation stack resetting I'll still keep your answer as the accepted answer.Pardo
This is the right answer. If "the application is a function of state", then you need somewhere to store that state, otherwise the state will be lost. Binding things to a property in a ViewModel - like the selected tab of a TabView or the selection of a NavigationLink - also gives you a way to do state restoration when the app restarts.Skyway
@Borden The web-view-case was exactly mine. This is pretty simple to work around by preserving the instance of the WKWebView when first creating it and returning this instance on calls to makeUIView().Alltime
P
2

So, this does "preserve" the detail view when switching tabs, but only by visibly pushing the detail view when switching back to tab 1. I have been unsuccessful at disabling this with, for example, .animation().

In addition, you pretty much have to override the navigation bar items in the DetailView, because the default back button behaves oddly (comment out the .navigationBarItems() line to see what I mean).

With those caveats, this does qualify as a workaround.

struct ContentView: View {
    @State var showingDetail = false

    var body: some View {
        TabView {
            NavView(showingDetail: $showingDetail)
                .tabItem { Text("First") }
                .tag(0)
            Text("Second View")
                .tabItem { Text("Second") }
                .tag(1)
        }
    }
}

struct NavView: View {
    @Binding var showingDetail: Bool

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: DetailView(showing: $showingDetail), isActive: $showingDetail) {
                    Text("Go to detail")
                }
            }
        }
    }
}

struct DetailView: View {
    @Binding var showing: Bool

    var body: some View {
            Text("Detail")
                .navigationBarItems(leading: Button("Back", action: { self.showing = false }))
    }
}
Plauen answered 17/9, 2019 at 17:50 Comment(2)
Interesting workaround, this breaks the backswipe though and doesn't actually save the page, just recreates it. So if you had a text field there with content in it, it would get cleared. I also think this will get really messy when you start adding more detail views.Pardo
Yes, that's true. In theory, you could pass in an @ObservedObject model to keep track of the detail view's state, but yeah, messy. But if your detail view is mostly presentation, it could be useful. The broken default back button is noted here: swiftui-lab.com/bug-navigationlink-isactive. I would like to see persistent NavigationView state, so maybe we all just need to file feedback with Apple.Plauen

© 2022 - 2024 — McMap. All rights reserved.