TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SOLVED: SwiftUI + Pop to Root TabView Functionality

Forums > SwiftUI

Hi there -

I've spent the last few months on and off trying to figure this out...example project below.

Most apps, including Apple's own, pop the user to the root view when the tab is selected (if already on a given tab, tapping it will take you to the root view). See example of what I'm trying to build here. I have not found a full implementation of this behavior in SwiftUI. It seems this feature either doesn't exist yet (are all these other apps relying on UIKit?) or I'm just struggling to find the relevant articles (help!).

The example code below is an implementation that roughly works on Tab2 as it should. Tab1 at first appears to work, but the NavigationLinks break down due to the isActive binding being generic - the NavigationLink child view you get is seemingly at random. Tabs don't respond to 'onTapGesture' and this has been the closest implementation I've found.

Some resources I've found that have been helpful: Paul H. has an implementation using NavigationStack and path that works great if you're using navBar buttons, but I wasn't able to make the leap to Tabs as they don't respond to .onTapGesture. Ideally a tap on a tab that you're on would clear the path and pop you back to the root: https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-programmatic-navigation-in-swiftui

import SwiftUI

struct ContentView: View {
    @State var showingDetail1 = false
    @State var showingDetail2 = false

    @State var selectedIndex: Int = 0

     // This is the magic, updating the selectionBinding pops you back to the root view.
    var selectionBinding: Binding<Int> { Binding(
        get: {
            self.selectedIndex
        },
        set: {
            if $0 == self.selectedIndex && $0 == 0 {
                print("Pop to root view for first tab!")
                showingDetail1 = false
            } else if $0 == self.selectedIndex && $0 == 1 {
                print("Pop to root view for second tab!")
                showingDetail2 = false
            }
            self.selectedIndex = $0
            print(selectedIndex)
        }
    )}

    var body: some View {
        // TabView that contains two tabs the their main, root views.
        TabView(selection: selectionBinding) {
            MainView1(showingDetail: $showingDetail1)
            .tabItem {
                Label("First", systemImage: "1.circle")
            }
            .tag(0)

            MainView2(showingDetail: $showingDetail2)
            .tabItem {
                Label("Second", systemImage: "2.circle")
            }
            .tag(1)
        }
    }
}

// MainView1 contains a view with 7 NavigationLinks. The crux of the issue is here - due to isActive, the NavigationLinks active at 
// random, meaning the one you click is likely not the view you end up going to.
struct MainView1: View {
    @Binding var showingDetail: Bool

    var body: some View {
        NavigationView {
            VStack {
                Text("First View")
                ForEach(1...7, id: \.self) { number in
                    NavigationLink(destination: DetailView2(number: number), isActive: $showingDetail) {
                        Text("Go to detail on tab 1: \(number)")
                    }
                }
            }
        }
    }
}

// MainView2 shows the behavior as it should work - and it functions because there is only one NavigationLink.
struct MainView2: View {
    @Binding var showingDetail: Bool

    var body: some View {
        NavigationView {
            VStack {
                Text("Second View")
                NavigationLink(destination: DetailView3(), isActive: $showingDetail) {
                    Text("Go to detail on tab 2")
                }
            }
        }
    }
}

// DetailView for MainView1
struct DetailView2: View {
    let number: Int

    var body: some View {
        Text("Detail2: \(number)")
    }
}

// DetailView for MainView2
struct DetailView3: View {
    var body: some View {
        Text("Detail3")
    }
}

3      

While the is no way for access the button tapped on TabBar so you would have to make a custom TabBar View Make a file called CustomTabView then add an enum of the "tab" required.

enum Tab: String, CaseIterable {
    case house, ellipsis = "ellipsis.circle"

    var text: String {
        switch self {
        case .house:
            "Home"
        case .ellipsis:
            "Other"
        }
    }
}

then make a View

struct CustomTabView: View {
    @Binding var selectedTab: Tab
    let action: () -> Void

    var body: some View {
        VStack {
            Spacer()
            HStack {
                ForEach(Tab.allCases, id: \.self) { tab in
                    Spacer()

                    Button {
                        selectedTab = tab
                        action()
                    } label: {
                        VStack(spacing: 5) {
                            Image(systemName: tab.rawValue)
                                .symbolVariant(.fill)
                                .imageScale(.large)
                            Text(tab.text)
                                .font(.caption)
                        }
                    }
                    .foregroundStyle(selectedTab == tab ? .blue : .secondary)

                    Spacer()
                }
            }
        }
    }
}

Now in ContentView

struct ContentView: View {
    @State private var selectedTab = Tab.house
    @State private var path = NavigationPath()

    var body: some View {
        ZStack {
            TabView(selection: $selectedTab) {
                TabOneView(path: $path)
                    .tag(Tab.house)

                TabTwoView()
                    .tag(Tab.ellipsis)
            }

            CustomTabView(selectedTab: $selectedTab) {
                if selectedTab == .house {
                    path = NavigationPath()
                }
            }
        }
    }
}

Now when you tap on the "Home" it bring back "Page 1"

Just added this for test Navigation so you can test it yourself

struct TabOneView: View {
    @Binding var path: NavigationPath

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                NavigationLink("Page 1", value: 2)
            }
            .navigationDestination(for: Int.self) { number in
                PageTwoView(path: $path, number: number)
            }
        }

    }
}

struct PageTwoView: View {
    @Binding var path: NavigationPath
    let number: Int

    var body: some View {
        Text("Page \(number)")
    }
}

struct TabTwoView: View {
    var body: some View {
        Text("Tab Two")
    }
}

3      

Nigel! This is brilliant, thanks - it works perfectly. I evolved your solution a bit to have separate 'path' @State vars to keep the different tabs from all popping back to root when one does, will post up the evolved solution when I'm done tweaking it for my needs.

One thing I noticed when playing around with your solution, when it's doing the actual 'pop' back, the screen blanks out to a solid white background rather than sliding out the view itself. This isn't too noticable when using a generic white background for all views, but when you customize the background to be a solid color, the flash of white is very noticeable. Any idea what's happening there? I'm guessing this has something to do with how you pop back...the 'action()' button kicks out a Void, is that throwing an empty view that Xcode renders as blank?

I wonder if this would react differently if each move through a child view appended to the $path, with action performing a .removeAll or something. Will play around with that.

A few modifications to the CustomTabView(), in case they matter:

struct CustomTabView: View {
    @Binding var selectedTab: Tab
    let action: () -> Void

    var body: some View {
        VStack {
            Spacer()
            HStack {
                ForEach(Tab.allCases, id: \.self) { tab in
                    Spacer()

                    Button {
                        if selectedTab == tab {
                            action() // action() moved under this logic to only pop to root if you're tapping on the tab you're on
                        }
                        selectedTab = tab
//                        action() // original action() placement
                    } label: {
                        VStack(spacing: 5) {
                            Image(systemName: tab.rawValue)
                                .symbolVariant(selectedTab == tab ? .fill : .none)
                                .imageScale(.large)
                            Text(tab.text)
                                .font(.caption)
                        }
                    }
                    .foregroundStyle(.blue)

                    Spacer()
                }
            }
        }
    }
}

3      

if selectedTab == tab { this will only run the closure when you are on the tab already. EG if on "Other" and go to "Home" then it will be in the same Nav view as before and user would have to tap the tab again (so have to tap twice). If that what you want ok.

@State private var homePath = NavigationPath()
@State private var otherPath = NavigationPath()

Then

CustomTabView(selectedTab: $selectedTab) {
    if selectedTab == .house {
        homePath = NavigationPath()
    }
}

only reset the "Home" Nav stack

One thing I noticed when playing around with your solution, when it's doing the actual 'pop' back, the screen blanks out to a solid white background rather than sliding out the view itself

This is proberly to do with the ZStack and redrawing the root screen not the closure.

3      

The 'tap twice' behavior is what I'm looking for!

As for the the screen blanking out...some better google keywords led me to this stackoverflow: https://stackoverflow.com/questions/77306129/pop-view-from-navigationstack-animation-issue-in-swiftui

It was a bug in iOS 17.0 that has now been fixed. Running on a real device with iOS 17.1 rather than the simulator show the views sliding in and out as expected without any flashes to white (or black if you're in dark mode).

Thanks again for the clear walkthrough here, major help.

3      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.