Set TabBar Item badge count with SwiftUI - swift

Is it possible to show TabItem badge with SwiftUI?
It is easy to achieve with UIKit like described here ->
How to set badge value in Tab bar?
I didn't find a way to do this with a SwiftUI.
The only possible way is to access to UITabBarController using scene rootViewController and modify its tab bar items directly.
func setBadgeCount(_ count: Int) {
UIApplication.shared.applicationIconBadgeNumber = count
guard let delegate = app.connectedScenes.first?.delegate as? SceneDelegate else {
return
}
if let tabBarController = delegate.window?.rootViewController?.children.first {
tabBarController.viewControllers?.first?.tabBarItem.badgeValue = "\(count)"
}
}
Any ideas how to do this with native SwiftUI approach?

Currently, SwiftUI don't have badge feature so we must custom.
Reference HERE I create My tabar with badge
struct ContentView: View {
private var badgePosition: CGFloat = 2
private var tabsCount: CGFloat = 2
#State var selectedView = 0
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .bottomLeading) {
TabView {
Text("First View")
.tabItem {
Image(systemName: "list.dash")
Text("First")
}.tag(0)
Text("Second View")
.tabItem {
Image(systemName: "star")
Text("Second")
}.tag(1)
}
ZStack {
Circle()
.foregroundColor(.red)
Text("3")
.foregroundColor(.white)
.font(Font.system(size: 12))
}
.frame(width: 15, height: 15)
.offset(x: ( ( 2 * self.badgePosition) - 0.95 ) * ( geometry.size.width / ( 2 * self.tabsCount ) ) + 2, y: -30)
.opacity(1.0)
}
}
}
}

Now in SwiftUI 3 they added a .badge() modifier
Source: HackingWithSwift
TabView {
Text("Your home screen here")
.tabItem {
Label("Home", systemImage: "house")
}
.badge(5)
}

func calculateBadgeXPos(width: CGFloat) -> CGFloat {
let t = (2*CGFloat(self.selectedTab))+1
return CGFloat(t * width/(2*CGFloat(TABS_COUNT)))
}
then use it here:
GeometryReader { geometry in
ZStack(alignment: .bottomLeading) {
// make sure to update TABS_COUNT
TabView(selection: self.$selectedTab) {
...
}
NotificationBadge(...)
.offset(x: self.calculateBadgeXPos(width: geometry.size.width), y: -28)
}
}
Looks sth like this on Preview

The above mentioned .introspectTabBarController modifier worked for me.
Gave a nice native badge on tabItem. Looks great on both orientations.
.introspectTabBarController { (UITabBarController) in
self.tabBarControl = UITabBarController
if let items = UITabBarController.tabBar.items {
let tabItem = items[2] // in my case it was 3rd item
tabItem.badgeValue = "5" // hardcoded
}
}
Though, when changing tabs, the badge disappears so I saved UITabBarController in #State and when tab changes I set tabItem.badgeValue again.
P.S: iOS 15+ supports .badge modifier. Use it if you're targeting above or iOS 15.

iOS 15 added support for .badge modifier, but as I need to support iOS 14, I've created UITabBarController wrapper:
struct TabBarController<TabContent: View, Tab: Hashable>: UIViewControllerRepresentable {
let tabs: [Tab]
#Binding
var selection: Tab
let tabBarItem: (Tab) -> UITabBarItem
let badgeValue: (Tab) -> String?
#ViewBuilder
let contentView: (Tab) -> TabContent
func makeUIViewController(context: Context) -> UITabBarController {
let controller = UITabBarController()
controller.delegate = context.coordinator
return controller
}
func updateUIViewController(_ uiViewController: UITabBarController, context: Context) {
context.coordinator.viewControllers
.keys
.filterNot(tabs.contains(_:))
.forEach { removedKey in
context.coordinator.viewControllers.removeValue(forKey: removedKey)
}
uiViewController.viewControllers = tabs.map { tab in
let rootView = contentView(tab)
let viewController = context.coordinator.viewControllers[tab] ?? {
let viewController = UIHostingController(rootView: rootView)
viewController.tabBarItem = tabBarItem(tab)
context.coordinator.viewControllers[tab] = viewController
return viewController
}()
viewController.rootView = rootView
viewController.tabBarItem.badgeValue = badgeValue(tab)
return viewController
}
uiViewController.selectedIndex = tabs.firstIndex(of: selection) ?? 0
}
func makeCoordinator() -> Coordinator {
Coordinator(selection: $selection)
}
final class Coordinator: NSObject, UITabBarControllerDelegate {
#Binding
private var selection: Tab
var viewControllers = [Tab: UIHostingController<TabContent>]()
init(selection: Binding<Tab>) {
_selection = selection
}
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard let newTab = viewControllers.first(where: { $0.value == viewController })?.key else {
print("tabBarController:didSelect: unexpected")
return
}
selection = newTab
}
}
}
filterNot:
extension Collection {
#inlinable public func filterNot(_ isNotIncluded: (Element) throws -> Bool) rethrows -> [Element] {
try filter { try !isNotIncluded($0) }
}
}
Usage:
TabBarController(
tabs: [1,2,3],
selection: $selection,
tabBarItem: { tab in
UITabBarItem(title: "tab \(tab)", image: UIImage(systemName: "1.square.fill"), tag: 0)
},
badgeValue: { tab in
"\(tab)"
},
contentView: { tab in
Text("\(tab) screen")
}
).ignoresSafeArea()

struct ContentView: View {
var body: some View {
TabView {
Text("Home")
.tabItem {
Text("Home")
}
Text("Home")
.tabItem {
Text("Home")
}
Text("Home")
.tabItem {
Text("Home")
}
}
.introspectTabBarController { (tabbarController) in
if let items = tabbarController.tabBar.items {
let tabItem = items[2]
tabItem.badgeValue = "1"
}
}
}
}

Related

How can I make a toolbar with page indicators in SwiftUI like the Weather App?

In SwiftUI, I am trying to place page Indicators on top of a bottom toolbar, but have not come to a resolution.
Paging Indicators
Right now, I have a tabview that organizes Views 1-7 horizontally, but the page indicators are on its own island at the bottom of the screen:
TabView {
View1()
View2()
View3()
View4()
View5()
View6()
View7()
}
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
I am trying to place the indicators on top of a toolbar with other buttons like how the Apple Weather App has done it:
Apple Weather App
I have also tried using a NavigationView with the .toolbar(ToolbarItemGroup) modifier, but that has not worked for me either.
Please let me know if you can help me with this.
Thanks
You can do this by wrapping a UIPageControl in a UIViewRepresentable, and then overlay that over your TabView using a ZStack or a .overlay modifier. You'll want to use .tabViewStyle(.page(indexDisplayMode: .never)) to prevent the tab view from displaying its own page control.
Here's a wrapper for UIPageControl.
struct PageControl: UIViewRepresentable {
#Binding var currentPage: Int
var numberOfPages: Int
func makeCoordinator() -> Coordinator {
return Coordinator(currentPage: $currentPage)
}
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = 1
control.setIndicatorImage(UIImage(systemName: "location.fill"), forPage: 0)
control.pageIndicatorTintColor = UIColor(.primary)
control.currentPageIndicatorTintColor = UIColor(.accentColor)
control.translatesAutoresizingMaskIntoConstraints = false
control.setContentHuggingPriority(.required, for: .horizontal)
control.addTarget(
context.coordinator,
action: #selector(Coordinator.pageControlDidFire(_:)),
for: .valueChanged)
return control
}
func updateUIView(_ control: UIPageControl, context: Context) {
context.coordinator.currentPage = $currentPage
control.numberOfPages = numberOfPages
control.currentPage = currentPage
}
class Coordinator {
var currentPage: Binding<Int>
init(currentPage: Binding<Int>) {
self.currentPage = currentPage
}
#objc
func pageControlDidFire(_ control: UIPageControl) {
currentPage.wrappedValue = control.currentPage
}
}
}
And here's an example of how to use it:
struct ContentView: View {
#State var page = 0
var locations = ["Current Location", "San Francisco", "Chicago", "New York", "London"]
var body: some View {
ZStack(alignment: .bottom) {
tabView
VStack {
Spacer()
controlBar.padding()
Spacer().frame(height: 60)
}
}
}
#ViewBuilder
private var tabView: some View {
TabView(selection: $page) {
ForEach(locations.indices, id: \.self) { i in
WeatherPage(location: locations[i])
.tag(i)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
#ViewBuilder
private var controlBar: some View {
HStack {
Image(systemName: "map")
Spacer()
PageControl(
currentPage: $page,
numberOfPages: locations.count
)
Spacer()
Image(systemName: "list.bullet")
}
}
}
struct WeatherPage: View {
var location: String
var body: some View {
VStack {
Spacer()
Text("Weather in \(location)")
Spacer()
}
}
}

`ColorPicker` with active label?

Consider the following code:
struct ContentView: View {
#State var color: Color = .blue
var body: some View {
ColorPicker(selection: $color) {
Label("Pallete", systemImage: "paintpalette")
}
}
}
It brings up a color picker modal view if you tap on color circle. I would like the same to happen also for taps on the label.
These is way to use the fancy system color picker any way we like, but as of iOS 15 it will require bringing with UIKit.
Create a new view struct like this:
import SwiftUI
struct ColorPickerPanel: UIViewControllerRepresentable {
#Binding var color: Color
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIColorPickerViewController {
let picker = UIColorPickerViewController()
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ picker: UIColorPickerViewController, context: Context) {
picker.selectedColor = UIColor(color)
}
class Coordinator: NSObject, UIColorPickerViewControllerDelegate {
var parent: ColorPickerPanel
init(_ pageViewController: ColorPickerPanel) {
self.parent = pageViewController
}
func colorPickerViewControllerDidSelectColor(_ viewController: UIColorPickerViewController) {
parent.color = Color(uiColor: viewController.selectedColor)
}
}
}
Then use it like this:
struct ContentView: View {
#State var color: Color = .accentColor
#State var isColorPickerPresented = false
var body: some View {
VStack {
Button {
isColorPickerPresented = true
} label: {
ColorPicker(selection: $color) {
Label("Pallete", systemImage: "paintpalette")
.allowsHitTesting(true)
.accessibilityAddTraits(.isButton)
}
}
}
.sheet(isPresented: $isColorPickerPresented) {
ZStack (alignment: .topTrailing) {
ColorPickerPanel(color: $color)
Button {
isColorPickerPresented = false
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tint, .secondary)
.font(.title)
}
.offset(x: -10, y: 10)
}
}
}
}
You may provide another to dismiss picker, of course.

SwiftUI - Hiding a ScrollView's indicators makes it stop scrolling

I'm trying to hide the indicators of a ScrollView but when I try doing so, the ScrollView just doesn't scroll anymore. I'm using macOS if that matters.
ScrollView(showsIndicators: false) {
// Everything is in here
}
On request of #SoOverIt
Demo:
Nothing special, just launched some other test example. Xcode 11.2 / macOS 10.15
var body : some View {
VStack {
ScrollView([.vertical], showsIndicators: false) {
Group {
Text("AAA")
Text("BBB")
Text("CCC")
Text("DDD")
Text("EEE")
}
Group {
Text("AAA")
Text("BBB")
Text("CCC")
Text("DDD")
Text("EEE")
}
Group {
Text("AAA")
Text("BBB")
Text("CCC")
Text("DDD")
Text("EEE")
}
Group {
Text("AAA")
Text("BBB")
Text("CCC")
Text("DDD")
Text("EEE")
}
}
.frame(height: 100)
.border(Color.blue)
}
.border(Color.red)
}
I fixed the issue.
extension View {
func hideIndicators() -> some View {
return PanelScrollView{ self }
}
}
struct PanelScrollView<Content> : View where Content : View {
let content: () -> Content
var body: some View {
PanelScrollViewControllerRepresentable(content: self.content())
}
}
struct PanelScrollViewControllerRepresentable<Content>: NSViewControllerRepresentable where Content: View{
func makeNSViewController(context: Context) -> PanelScrollViewHostingController<Content> {
return PanelScrollViewHostingController(rootView: self.content)
}
func updateNSViewController(_ nsViewController: PanelScrollViewHostingController<Content>, context: Context) {
}
typealias NSViewControllerType = PanelScrollViewHostingController<Content>
let content: Content
}
class PanelScrollViewHostingController<Content>: NSHostingController<Content> where Content : View {
var scrollView: NSScrollView?
override func viewDidAppear() {
self.scrollView = findNSScrollView(view: self.view)
self.scrollView?.scrollerStyle = .overlay
self.scrollView?.hasVerticalScroller = false
self.scrollView?.hasHorizontalScroller = false
super.viewDidAppear()
}
func findNSScrollView(view: NSView?) -> NSScrollView? {
if view?.isKind(of: NSScrollView.self) ?? false {
return (view as? NSScrollView)
}
for v in view?.subviews ?? [] {
if let vc = findNSScrollView(view: v) {
return vc
}
}
return nil
}
}
Preview:
struct MyScrollView_Previews: PreviewProvider {
static var previews: some View {
ScrollView{
VStack{
Text("hello")
Text("hello")
Text("hello")
Text("hello")
Text("hello")
}
}.hideIndicators()
}
}
So... I think that's the only way for now.
You basically just put a View over your ScrollView indicator with the same backgroundColor as your background View
Note: This obviously only works if your background is static with no content at the trailing edge.
Idea
struct ContentView: View {
#Environment(\.colorScheme) var colorScheme: ColorScheme
let yourBackgroundColorLight: Color = .white
let yourBackgroundColorDark: Color = .black
var yourBackgroundColor: Color { colorScheme == .light ? yourBackgroundColorLight : yourBackgroundColorDark }
var body: some View {
ScrollView {
VStack {
ForEach(0..<1000) { i in
Text(String(i)).frame(width: 280).foregroundColor(.green)
}
}
}
.background(yourBackgroundColor) //<-- Same
.overlay(
HStack {
Spacer()
Rectangle()
.frame(width: 10)
.foregroundColor(yourBackgroundColor) //<-- Same
}
)
}
}
Compact version
You could improve this like that, I suppose you have your color dynamically set up inside assets.
Usage:
ScrollView {
...
}
.hideIndicators(with: <#Your Color#>)
Implementation:
extension View {
func hideIndicators(with color: Color) -> some View {
return modifier(HideIndicators(color: color))
}
}
struct HideIndicators: ViewModifier {
let color: Color
func body(content: Content) -> some View {
content
.overlay(
HStack {
Spacer()
Rectangle()
.frame(width: 10)
.foregroundColor(color)
}
)
}
}

SwiftUI hide TabBar in subview

I am working with SwiftUI, and I have some issues with the TabBar.
I want to hide the TabBar on a specific subview.
Have tried with
UITabBar.appearance().isHidden = true
It only works on the direct views in the TabView. But when I place it in a subview it doesn't work.
Have anyone a solution for this?
Thanks.
iOS 16 native way
.toolbar(.hidden, for: .tabBar)
iOS 14
Install the Introspect SwiftPM: https://github.com/siteline/SwiftUI-Introspect
struct SomeView: View{
#State var uiTabarController: UITabBarController?
var body: some View {
List {
-your code here-
}
.navigationBarTitle("Title", displayMode: .inline)
.introspectTabBarController { (UITabBarController) in
UITabBarController.tabBar.isHidden = true
uiTabarController = UITabBarController
}.onDisappear{
uiTabarController?.tabBar.isHidden = false
}
}
}
In this code in uiTabarController, we are taking the reference of UITabarController. When we go back then we enabled the Tabar again. So, that's why this is needed.
iOS 14 Simple Solution
Install the Introspect SwiftPM: https://github.com/siteline/SwiftUI-Introspect
var body: some View {
List {
-your code here-
}
.navigationBarTitle("Title", displayMode: .inline)
.introspectTabBarController { (UITabBarController) in
UITabBarController.tabBar.isHidden = true
}
}
NOTE: You have to re-enable the TabBar it in the parent view or it will still be hidden.
.introspectTabBarController { (UITabBarController) in
UITabBarController.tabBar.isHidden = false
}
It's working, only changes are need to be called on the main queue
struct ShowTabBar: ViewModifier {
func body(content: Content) -> some View {
return content.padding(.zero).onAppear {
DispatchQueue.main.async {
Tool.showTabBar()
}
}
}
}
struct HiddenTabBar: ViewModifier {
func body(content: Content) -> some View {
return content.padding(.zero).onAppear {
DispatchQueue.main.async {
Tool.hiddenTabBar()
}
}
}
}
Traverse the allsubview of the window to hide the UITabBar. You can write it as ViewModifier and use it in SwiftUI or use tools to hide it. This method works for me.
extension UIView {
func allSubviews() -> [UIView] {
var res = self.subviews
for subview in self.subviews {
let riz = subview.allSubviews()
res.append(contentsOf: riz)
}
return res
}
}
struct Tool {
static func showTabBar() {
UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.allSubviews().forEach({ (v) in
if let view = v as? UITabBar {
view.isHidden = false
}
})
}
static func hiddenTabBar() {
UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.allSubviews().forEach({ (v) in
if let view = v as? UITabBar {
view.isHidden = true
}
})
}
}
struct ShowTabBar: ViewModifier {
func body(content: Content) -> some View {
return content.padding(.zero).onAppear {
Tool.showTabBar()
}
}
}
struct HiddenTabBar: ViewModifier {
func body(content: Content) -> some View {
return content.padding(.zero).onAppear {
Tool.hiddenTabBar()
}
}
}
extension View {
func showTabBar() -> some View {
return self.modifier(ShowTabBar())
}
func hiddenTabBar() -> some View {
return self.modifier(HiddenTabBar())
}
}
iOS 15 solution
This solution works well except with view modifier in the SwiftUI.TabView.
Since my TabView is in the struct that conforms App, it looks like there still is not any UITabBar subview in the connected scenes.
With the code below, you only need to use showTabBar() or hiddenTabBar() in your SwiftUI.View.
extension UIApplication {
var key: UIWindow? {
self.connectedScenes
.map({$0 as? UIWindowScene})
.compactMap({$0})
.first?
.windows
.filter({$0.isKeyWindow})
.first
}
}
extension UIView {
func allSubviews() -> [UIView] {
var subs = self.subviews
for subview in self.subviews {
let rec = subview.allSubviews()
subs.append(contentsOf: rec)
}
return subs
}
}
struct TabBarModifier {
static func showTabBar() {
UIApplication.shared.key?.allSubviews().forEach({ subView in
if let view = subView as? UITabBar {
view.isHidden = false
}
})
}
static func hideTabBar() {
UIApplication.shared.key?.allSubviews().forEach({ subView in
if let view = subView as? UITabBar {
view.isHidden = true
}
})
}
}
struct ShowTabBar: ViewModifier {
func body(content: Content) -> some View {
return content.padding(.zero).onAppear {
TabBarModifier.showTabBar()
}
}
}
struct HiddenTabBar: ViewModifier {
func body(content: Content) -> some View {
return content.padding(.zero).onAppear {
TabBarModifier.hideTabBar()
}
}
}
extension View {
func showTabBar() -> some View {
return self.modifier(ShowTabBar())
}
func hiddenTabBar() -> some View {
return self.modifier(HiddenTabBar())
}
}
here's no way to hide TabView so I had to add TabView inside ZStack as this:
var body: some View {
ZStack {
TabView {
TabBar1().environmentObject(self.userData)
.tabItem {
Image(systemName: "1.square.fill")
Text("First")
}
TabBar2()
.tabItem {
Image(systemName: "2.square.fill")
Text("Second")
}
}
if self.userData.showFullScreen {
FullScreen().environmentObject(self.userData)
}
}
}
UserData:
final class UserData: ObservableObject {
#Published var showFullScreen = false
}
TabBar1:
struct TabBar1: View {
#EnvironmentObject var userData: UserData
var body: some View {
Text("TabBar 1")
.edgesIgnoringSafeArea(.all)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.background(Color.green)
.onTapGesture {
self.userData.showFullScreen.toggle()
}
}
}
FullScreen:
struct FullScreen: View {
#EnvironmentObject var userData: UserData
var body: some View {
Text("FullScreen")
.edgesIgnoringSafeArea(.all)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.background(Color.red)
.onTapGesture {
self.userData.showFullScreen.toggle()
}
}
}
check full code on Github
there's also some other ways but it depends on the structure of the views
The basic idea I'm using is to combine ObservableObject and ZStack. I have placed TabView into ZStack with conditional subview presentation.
It's look like.
Look through github repo
To hide TabBar when we jumps towards next screen we just have to place NavigationView to the right place. Makesure Embed TabView inside NavigationView so creating unique Navigationview for both tabs.
As explained here https://janeshswift.com/ios/swiftui/how-to-hide-tabbar-on-push-with-swiftui/
import SwiftUI
struct TabBarView: View {
#State var tabSelection: Int = 0
#State var tabArray = ["Profile", "Settings"]
var body: some View {
NavigationView {
TabView(selection: $tabSelection){
ForEach(0 ..< tabArray.count, id: \.self) { indexValue in
NavigationLink(destination: DetailView()){
VStack{
Text("\(tabArray[indexValue]) tab -- Click to jump next view")
}
}
.tabItem {
Image(systemName: "\(indexValue).circle.fill")
Text(tabArray[indexValue])
}
.tag(indexValue)
}
}
.navigationBarTitle(tabArray[tabSelection])
}
}
}
struct DetailView: View {
var body: some View {
Text("Detail View")
.navigationBarTitle("NavigatedView")
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("helllo")
}
}
Install the Introspect SwiftPM: https://github.com/siteline/SwiftUI-Introspect
in order to use this you need to create a variable of type UITabBar in the view you want the tabbar to be hidden...
enter code here
#State private var tabBar: UITabBar?
then below the navigationView in the same view you have to add this line:
.introspectTabBarController { UITabBarController in tabBar = UITabBarController.tabBar
self.tabBar?.isHidden = true } .onDisappear() { self.tabBar?.isHidden = false }
It is actually possible to get the underlying UITabbarController for TabView by using this handy little framework :
https://github.com/siteline/SwiftUI-Introspect
This solution uses the MVVM pattern as an example to have programmatic control over the Tabbar visibility, and be able to show, hide, enable, disable form anywhere in the code using NSNotifications
SwiftUI View : Setup the tabview like this
struct MainTabView: View {
var viewModel: MainTabViewModel
var body: some View {
TabView() {
Text("View1")
.tabItem {
Text("View1")
}
Text("View2")
.tabItem {
Text("View2")
}
}
.introspectTabBarController { tabBarController in
// customize here the UITabBarViewController if you like
self.viewModel.tabBarController = tabBarController
}
}
}
Then for the ViewModel
final class MainTabViewModel: ObservableObject {
var tabBarController: UITabBarController?
init() {
startListeningNotifications()
}
func startListeningNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(showTabbarView), name: "showBottomTabbar", object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(hideTabbarView), name: "hideBottomTabbar", object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(enableTabbarTouch), name: "enableTouchTabbar", object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(disableTabbarTouch), name: "disableTouchTabbar", object: nil)
}
#objc func showTabbarView() {
self.tabBarController?.tabBar.isHidden = false
}
#objc func hideTabbarView() {
self.tabBarController?.tabBar.isHidden = true
}
#objc func disableTabbarTouch() {
self.tabBarController?.tabBar.isUserInteractionEnabled = false
}
#objc func enableTabbarTouch() {
self.tabBarController?.tabBar.isUserInteractionEnabled = true
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
and finally to control the tabbar, just use these fonctions from wherever you feel like (will be in the viewmodels in the pattern of this example)
public func showTabbar() {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .showBottomTabbar, object: nil)
}
}
public func hideTabbar() {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .hideBottomTabbar, object: nil)
}
}
public func enableTouchTabbar() {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .enableTouchTabbar, object: nil)
}
}
public func disableTouchTabbar() {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .disableTouchTabbar, object: nil)
}
}
Increase the frame size of TabView like this:
.frame(width: UIScreen.main.bounds.width, height: showTabbar ? UIScreen.main.bounds.height : UIScreen.main.bounds.height + 100.00)
Not ideal and hacky but the simplest thing to do for me that is working very well was to hide the navigationBar of the outer navigationView and then add another navigationView in each of the TabView's views. Works well so far:
struct LaunchView: View {
var body: some View {
NavigationView {
TabView {
ViewA()
.tabItem {
Label("TabA", systemImage: "some.image")
}
ViewB()
.tabItem {
Label("TabB", systemImage: "some.image")
}
ViewC()
.tabItem {
Label("TabC", systemImage: "some.image")
}
}
.navigationBarHidden(true)
}
}
}
struct ViewA: View {
var body: some View {
NavigationView {
// Content
.navigationTitle("Settings")
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
This way you can set the title but also the .toolBarItem's in each separate view.
in general, it's nice to be able to create pages with an w/o tabbar
it looks smooth and your page content doesn't change it's size while hiding tabbar on the page
solution is
hide tabbar from the root container
add custom tabbar modifier
use this modifier on navViews to show the tabbar for all nav view hierarchy OR use it on the specific pages in the view hierarchy
here is a small sample project how your app could look like with this approach
https://github.com/alexis-ag/swiftui_classic-tabview_show-hide
I have tried to use https://stackoverflow.com/a/62963499/11844048 solution but the TabBar hide in all views once I landed this view. I have modified it a bit to achieve to hide TabBar in single view.
struct AppInfoView: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var body: some View {
ZStack{
}
.frame(maxWidth: .infinity)
.background(Color("homepage_bg")).ignoresSafeArea(.all)
.onAppear{
UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.allSubviews().forEach({ (v) in
if let view = v as? UITabBar {
view.isHidden = true
}
})
}
.onDisAppear(...) //it works too. But seeing TabBar shown bit delay when naviagting back. So below the customizable back button.
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: Button(action : {
self.mode.wrappedValue.dismiss()
UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.allSubviews().forEach({ (v) in
if let view = v as? UITabBar {
view.isHidden = false
}
})
}){
Image(systemName: "chevron.left")
})
}
extension UIView {
func allSubviews() -> [UIView] {
var res = self.subviews
for subview in self.subviews {
let riz = subview.allSubviews()
res.append(contentsOf: riz)
}
return res
}
}
Most answers here deal with this requirement in one of two ways:
import a framework to locate the UITabBarController
modify the view hierarchy (ZStack, NavigationView, ...)
The first one is a clean approach: it locates the underlying element that enables the desired action. However, it may be overkill for a single use case.
The second approach involves some tradeoffs and could be generally considered a smell, since it introduces hierarchy changes for the sake of working around the lack of access to the required element.
Instead, we could follow a clean, simple approach by creating a protocol extension like so:
import UIKit
protocol TabBarAppearanceDelegate {
func toggleVisibility()
func hideTabBar()
func showTabBar()
// add more methods to control appearance as needed
}
extension TabBarAppearanceDelegate {
private var tabBarController: UITabBarController? {
// this is where we access the underlying element, no need to import a framework for a one-liner
UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.rootViewController?.children.first as? UITabBarController
}
func toggleVisibility() {
tabBarController?.tabBar.isHidden.toggle()
}
func hideTabBar() {
tabBarController?.tabBar.isHidden = true
}
func showTabBar() {
tabBarController?.tabBar.isHidden = false
}
}
Then we can make any object conform to this protocol, and inject it as dependency in the views as needed. This will depend on your architecture but it could go like follows.
This is where you'd keep app-wide state, an ObservableObject (you could designate a different one, if preferred):
import Foundation
class StateController: ObservableObject {
// you would typically manage app-wide state here
}
// this is where we adopt the needed behaviour
extension StateController: TabBarAppearanceDelegate {}
We can now inject the object as a view dependency:
#main
struct TabBarVisibilityApp: App {
private let stateController = StateController()
var body: some Scene {
WindowGroup {
TabView {
NavigationView {
SampleView(tabBarAppearanceDelegate: stateController)
}
.tabItem {
Label("Home", systemImage: "house")
}
}
}
}
}
This is how you would use it (valid for in any view that requires the behaviour):
import SwiftUI
struct SampleView: View {
let tabBarAppearanceDelegate: TabBarAppearanceDelegate
var body: some View {
VStack {
Spacer()
Button(action: {
tabBarAppearanceDelegate.toggleVisibility()
} ) {
Text("Toggle tab bar visibility")
}
Spacer()
}
}
}
This approach is simple, testable, and requires no extra dependencies... until Apple provides a direct way to control tab bar visibility with a SwiftUI API.
iOS 16
Usage of .toolbar modifier. Create state property of type Visibility and manage its value from the pushed view
First view
struct FirstTab: View {
#State var tabBarVisibility: Visibility = .visible
var body: some View {
NavigationView {
NavigationLink(destination: WidgetDetailView(tab: self)) {
Text("test")
}
}
.toolbar(tabBarVisibility, for: .tabBar)
}
}
Second view
struct WidgetDetailView: View {
var tab: FirstTab
var body: some View {
Rectangle()
.foregroundColor(Color.red)
.onAppear {
tab.tabBarVisibility = .hidden
}
.onDisappear {
tab.tabBarVisibility = .visible
}
}
}
On iOS 16
Use .toolbar(.hidden, for: .tabBar).
For example:
var body: some View {
TabView {
FirstView()
.tabItem {
Text("First tab")
}
.toolbar(.hidden, for: .tabBar)
SecondView()
.tabItem {
Text("Second tab")
}
.toolbar(.hidden, for: .tabBar)
}
}
Note that .toolbar(.hidden, for: .tabBar) is applied to each tabItem, not to the parent view. YMMV if you have a different structure (like a parent NavigationView etc.)
Add NavigationView as root instead of TabView
NavigationView{
TabView(selection:$selectedIndex) {
}
}
If you don't want NavigationView in TabBar page just hide it.
.navigationBarHidden(true)
Refer sample project - https://github.com/TreatTrick/Hide-TabBar-In-SwiftUI
just use UIKit‘s UINavigationController, like this:
let host = UINavigationController(rootViewController:
UIHostingController(rootView: HLHome()))
It is possible!
Basically your task is to extract UITabBar somehow and then hide it programatically.
Below is the code which emulates tab bar hiding on push behaviour.
struct ContentView: View {
var body: some View {
TabView {
ForEach(titles, id: \.self) { title in
NavigationView {
view(fromTitle: title)
}
.tabItem {
Text(title)
Image(systemName: "photo")
}
.tag(title)
}
}
}
private let titles = ["one", "two"]
#ViewBuilder
private func view(fromTitle title: String) -> some View {
if title == "one" {
RegularView(title: title)
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
} else {
HideOnPushView(title: title)
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct RegularView: View {
let title: String
var body: some View {
VStack(spacing: 20) {
Text(title)
NavigationLink("Regular push") {
Text("1111111")
}
}
}
}
struct HideOnPushView: View {
let title: String
var body: some View {
VStack(spacing: 20) {
Text(title)
NavigationLink("Hide on push") {
Text("222222")
.onAppear {
tabBar?.hide()
}
}
}
.background(
TabBarExtractor(tabBar: $tabBar)
)
.onAppear {
tabBar?.show()
}
}
#State private var tabBar: UITabBar?
}
TabBar extractor code:
import SwiftUI
struct TabBarExtractor: UIViewControllerRepresentable {
#Binding var tabBar: UITabBar?
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeUIViewController(context: Context) -> some UIViewController {
let controller = ViewController()
controller.onTabBarAppearance = {
tabBar = $0
}
return controller
}
}
private extension TabBarExtractor {
class ViewController: UIViewController {
var onTabBarAppearance: ((UITabBar) -> Void)?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let tabBar = self.tabBarController?.tabBar {
onTabBarAppearance?(tabBar)
} else {
print("Could not locate TabBar! Try change extractor place in views hierarchy.")
}
}
}
}
TabBar category:
import UIKit
extension UITabBar {
func toggleVisibility() {
if isHidden {
show()
} else {
hide()
}
}
func show() {
guard isHidden else { return }
let visibleY = frame.origin.y
let hiddenY = visibleY + frame.height
frame.origin.y = hiddenY
isHidden = false
UIView.animate(withDuration: 0.3) { [weak self] in
self?.frame.origin.y = visibleY
}
}
func hide() {
guard !isHidden else { return }
let visibleY = frame.origin.y
let hiddenY = visibleY + frame.height
UIView.animate(withDuration: 0.3) { [weak self] in
self?.frame.origin.y = hiddenY
} completion: { [weak self] completed in
guard completed else { return }
self?.isHidden = true
self?.frame.origin.y = visibleY
}
}
}
I had the same problem, and ended up using:
.opacity(hideTabBar == true ? 0 : 1)
where hideTabBar is a Bool that I pass around to the views that need to hide the TabBar().
So basically you should try something like this:
TabView(selection: your desired tab).opacity(hideTabBar == true ? 0 : 1)

How can I pop to the Root view using SwiftUI?

Finally now with Beta 5 we can programmatically pop to a parent View. However, there are several places in my app where a view has a "Save" button that concludes a several step process and returns to the beginning. In UIKit, I use popToRootViewController(), but I have been unable to figure out a way to do the same in SwiftUI.
Below is a simple example of the pattern I'm trying to achieve.
How can I do it?
import SwiftUI
struct DetailViewB: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Text("This is Detail View B.")
Button(action: { self.presentationMode.value.dismiss() } )
{ Text("Pop to Detail View A.") }
Button(action: { /* How to do equivalent to popToRootViewController() here?? */ } )
{ Text("Pop two levels to Master View.") }
}
}
}
struct DetailViewA: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Text("This is Detail View A.")
NavigationLink(destination: DetailViewB() )
{ Text("Push to Detail View B.") }
Button(action: { self.presentationMode.value.dismiss() } )
{ Text("Pop one level to Master.") }
}
}
}
struct MasterView: View {
var body: some View {
VStack {
Text("This is Master View.")
NavigationLink(destination: DetailViewA() )
{ Text("Push to Detail View A.") }
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
MasterView()
}
}
}
Setting the view modifier isDetailLink to false on a NavigationLink is the key to getting pop-to-root to work. isDetailLink is true by default and is adaptive to the containing View. On iPad landscape for example, a Split view is separated and isDetailLink ensures the destination view will be shown on the right-hand side. Setting isDetailLink to false consequently means that the destination view will always be pushed onto the navigation stack; thus can always be popped off.
Along with setting isDetailLink to false on NavigationLink, pass the isActive binding to each subsequent destination view. At last when you want to pop to the root view, set the value to false and it will automatically pop everything off:
import SwiftUI
struct ContentView: View {
#State var isActive : Bool = false
var body: some View {
NavigationView {
NavigationLink(
destination: ContentView2(rootIsActive: self.$isActive),
isActive: self.$isActive
) {
Text("Hello, World!")
}
.isDetailLink(false)
.navigationBarTitle("Root")
}
}
}
struct ContentView2: View {
#Binding var rootIsActive : Bool
var body: some View {
NavigationLink(destination: ContentView3(shouldPopToRootView: self.$rootIsActive)) {
Text("Hello, World #2!")
}
.isDetailLink(false)
.navigationBarTitle("Two")
}
}
struct ContentView3: View {
#Binding var shouldPopToRootView : Bool
var body: some View {
VStack {
Text("Hello, World #3!")
Button (action: { self.shouldPopToRootView = false } ){
Text("Pop to root")
}
}.navigationBarTitle("Three")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Definitely, malhal has the key to the solution, but for me, it is not practical to pass the Binding's into the View's as parameters. The environment is a much better way as pointed out by Imthath.
Here is another approach that is modeled after Apple's published dismiss() method to pop to the previous View.
Define an extension to the environment:
struct RootPresentationModeKey: EnvironmentKey {
static let defaultValue: Binding<RootPresentationMode> = .constant(RootPresentationMode())
}
extension EnvironmentValues {
var rootPresentationMode: Binding<RootPresentationMode> {
get { return self[RootPresentationModeKey.self] }
set { self[RootPresentationModeKey.self] = newValue }
}
}
typealias RootPresentationMode = Bool
extension RootPresentationMode {
public mutating func dismiss() {
self.toggle()
}
}
USAGE:
Add .environment(\.rootPresentationMode, self.$isPresented) to the root NavigationView, where isPresented is Bool used to present the first child view.
Either add .navigationViewStyle(StackNavigationViewStyle()) modifier to the root NavigationView, or add .isDetailLink(false) to the NavigationLink for the first child view.
Add #Environment(\.rootPresentationMode) private var rootPresentationMode to any child view from where pop to root should be performed.
Finally, invoking the self.rootPresentationMode.wrappedValue.dismiss() from that child view will pop to the root view.
I have published a complete working example on GitHub.
Since currently SwiftUI still uses a UINavigationController in the background it is also possible to call its popToRootViewController(animated:) function. You only have to search the view controller hierarchy for the UINavigationController like this:
struct NavigationUtil {
static func popToRootView() {
findNavigationController(viewController: UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.rootViewController)?
.popToRootViewController(animated: true)
}
static func findNavigationController(viewController: UIViewController?) -> UINavigationController? {
guard let viewController = viewController else {
return nil
}
if let navigationController = viewController as? UINavigationController {
return navigationController
}
for childViewController in viewController.children {
return findNavigationController(viewController: childViewController)
}
return nil
}
}
And use it like this:
struct ContentView: View {
var body: some View {
NavigationView { DummyView(number: 1) }
}
}
struct DummyView: View {
let number: Int
var body: some View {
VStack(spacing: 10) {
Text("This is view \(number)")
NavigationLink(destination: DummyView(number: number + 1)) {
Text("Go to view \(number + 1)")
}
Button(action: { NavigationUtil.popToRootView() }) {
Text("Or go to root view!")
}
}
}
}
Introducing Apple's solution to this very problem
It also presented to you via HackingWithSwift (which I stole this from, LOL) under programmatic navigation:
(Tested on Xcode 12 and iOS 14)
Essentially, you use tag and selection inside navigationlink to go straight to whatever page you want.
struct ContentView: View {
#State private var selection: String? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("Second View"), tag: "Second", selection: $selection) { EmptyView() }
NavigationLink(destination: Text("Third View"), tag: "Third", selection: $selection) { EmptyView() }
Button("Tap to show second") {
self.selection = "Second"
}
Button("Tap to show third") {
self.selection = "Third"
}
}
.navigationBarTitle("Navigation")
}
}
}
You can use an #environmentobject injected into ContentView() to handle the selection:
class NavigationHelper: ObservableObject {
#Published var selection: String? = nil
}
inject into App:
#main
struct YourApp: App {
var body: some Scene {
WindowGroup {
ContentView().environmentObject(NavigationHelper())
}
}
}
and use it:
struct ContentView: View {
#EnvironmentObject var navigationHelper: NavigationHelper
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("Second View"), tag: "Second", selection: $navigationHelper.selection) { EmptyView() }
NavigationLink(destination: Text("Third View"), tag: "Third", selection: $navigationHelper.selection) { EmptyView() }
Button("Tap to show second") {
self.navigationHelper.selection = "Second"
}
Button("Tap to show third") {
self.navigationHelper.selection = "Third"
}
}
.navigationBarTitle("Navigation")
}
}
}
To go back to contentview in child navigationlinks, you just set the navigationHelper.selection = nil.
Note you don't even have to use tag and selection for subsequent child nav links if you don't want to—they will not have functionality to go to that specific navigationLink though.
As far as I can see, there isn't any easy way to do it with the current beta 5. The only way I found is very hacky, but it works.
Basically, add a publisher to your DetailViewA which will be triggered from DetailViewB. In DetailViewB dismiss the view and inform the publisher, which itself will close DetailViewA.
struct DetailViewB: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var publisher = PassthroughSubject<Void, Never>()
var body: some View {
VStack {
Text("This is Detail View B.")
Button(action: { self.presentationMode.value.dismiss() } )
{ Text("Pop to Detail View A.") }
Button(action: {
DispatchQueue.main.async {
self.presentationMode.wrappedValue.dismiss()
self.publisher.send()
}
} )
{ Text("Pop two levels to Master View.") }
}
}
}
struct DetailViewA: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var publisher = PassthroughSubject<Void, Never>()
var body: some View {
VStack {
Text("This is Detail View A.")
NavigationLink(destination: DetailViewB(publisher:self.publisher) )
{ Text("Push to Detail View B.") }
Button(action: { self.presentationMode.value.dismiss() } )
{ Text("Pop one level to Master.") }
}
.onReceive(publisher, perform: { _ in
DispatchQueue.main.async {
print("Go Back to Master")
self.presentationMode.wrappedValue.dismiss()
}
})
}
}
And Beta 6 still doesn't have a solution.
I found another way to go back to the root, but this time I'm losing the animation, and go straight to the root.
The idea is to force a refresh of the root view, this way leading to a cleaning of the navigation stack.
But ultimately only Apple could bring a proper solution, as the management of the navigation stack is not available in SwiftUI.
NB: The simple solution by notification below works on iOS, not watchOS, as watchOS clears the root view from memory after two navigation levels. But having an external class managing the state for watchOS should just work.
struct DetailViewB: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#State var fullDissmiss:Bool = false
var body: some View {
SGNavigationChildsView(fullDissmiss: self.fullDissmiss){
VStack {
Text("This is Detail View B.")
Button(action: { self.presentationMode.wrappedValue.dismiss() } )
{ Text("Pop to Detail View A.") }
Button(action: {
self.fullDissmiss = true
} )
{ Text("Pop two levels to Master View with SGGoToRoot.") }
}
}
}
}
struct DetailViewA: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#State var fullDissmiss:Bool = false
var body: some View {
SGNavigationChildsView(fullDissmiss: self.fullDissmiss){
VStack {
Text("This is Detail View A.")
NavigationLink(destination: DetailViewB() )
{ Text("Push to Detail View B.") }
Button(action: { self.presentationMode.wrappedValue.dismiss() } )
{ Text("Pop one level to Master.") }
Button(action: { self.fullDissmiss = true } )
{ Text("Pop one level to Master with SGGoToRoot.") }
}
}
}
}
struct MasterView: View {
var body: some View {
VStack {
Text("This is Master View.")
NavigationLink(destination: DetailViewA() )
{ Text("Push to Detail View A.") }
}
}
}
struct ContentView: View {
var body: some View {
SGRootNavigationView{
MasterView()
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
struct SGRootNavigationView<Content>: View where Content: View {
let cancellable = NotificationCenter.default.publisher(for: Notification.Name("SGGoToRoot"), object: nil)
let content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
#State var goToRoot:Bool = false
var body: some View {
return
Group{
if goToRoot == false{
NavigationView {
content()
}
}else{
NavigationView {
content()
}
}
}.onReceive(cancellable, perform: {_ in
DispatchQueue.main.async {
self.goToRoot.toggle()
}
})
}
}
struct SGNavigationChildsView<Content>: View where Content: View {
let notification = Notification(name: Notification.Name("SGGoToRoot"))
var fullDissmiss:Bool{
get{ return false }
set{ if newValue {self.goToRoot()} }
}
let content: () -> Content
init(fullDissmiss:Bool, #ViewBuilder content: #escaping () -> Content) {
self.content = content
self.fullDissmiss = fullDissmiss
}
var body: some View {
return Group{
content()
}
}
func goToRoot(){
NotificationCenter.default.post(self.notification)
}
}
I figured out a simple solution to pop to the root view. I am sending a notification and then listening for the notification to change the id of the NavigationView; this will refresh the NavigationView. There is not an animation, but it looks good. Here is the example:
#main
struct SampleApp: App {
#State private var navigationId = UUID()
var body: some Scene {
WindowGroup {
NavigationView {
Screen1()
}
.id(navigationId)
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("popToRootView"))) { output in
navigationId = UUID()
}
}
}
}
struct Screen1: View {
var body: some View {
VStack {
Text("This is screen 1")
NavigationLink("Show Screen 2", destination: Screen2())
}
}
}
struct Screen2: View {
var body: some View {
VStack {
Text("This is screen 2")
Button("Go to Home") {
NotificationCenter.default.post(name: Notification.Name("popToRootView"), object: nil)
}
}
}
}
I figured out how to use complex navigation in SwiftUI. The trick is to collect all the states of your views, which tell if they are shown.
Start by defining a NavigationController. I have added the selection for the tabview tab and the Boolean values saying if a specific view is shown:
import SwiftUI
final class NavigationController: ObservableObject {
#Published var selection: Int = 1
#Published var tab1Detail1IsShown = false
#Published var tab1Detail2IsShown = false
#Published var tab2Detail1IsShown = false
#Published var tab2Detail2IsShown = false
}
Setting up the tabview with two tabs and binding our NavigationController.selection to the tabview:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var nav: NavigationController
var body: some View {
TabView(selection: self.$nav.selection) {
FirstMasterView()
.tabItem {
Text("First")
}
.tag(0)
SecondMasterView()
.tabItem {
Text("Second")
}
.tag(1)
}
}
}
As an example, this is one navigationStacks
import SwiftUI
struct FirstMasterView: View {
#EnvironmentObject var nav: NavigationController
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: FirstDetailView(), isActive: self.$nav.tab1Detail1IsShown) {
Text("go to first detail")
}
} .navigationBarTitle(Text("First MasterView"))
}
}
}
struct FirstDetailView: View {
#EnvironmentObject var nav: NavigationController
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack(spacing: 20) {
Text("first detail View").font(.title)
NavigationLink(destination: FirstTabLastView(), isActive: self.$nav.tab1Detail2IsShown) {
Text("go to last detail on nav stack")
}
Button(action: {
self.nav.tab2Detail1IsShown = false // true will go directly to detail
self.nav.tab2Detail2IsShown = false
self.nav.selection = 1
}) {
Text("Go to second tab")
}
}
// In case of collapsing all the way back
// there is a bug with the environment object
// to go all the way back I have to use the presentationMode
.onReceive(self.nav.$tab1Detail2IsShown, perform: { (out) in
if out == false {
self.presentationMode.wrappedValue.dismiss()
}
})
}
}
struct FirstTabLastView: View {
#EnvironmentObject var nav: NavigationController
var body: some View {
Button(action: {
self.nav.tab1Detail1IsShown = false
self.nav.tab1Detail2IsShown = false
}) {
Text("Done and go back to beginning of navigation stack")
}
}
}
This approach is quite SwiftUI-state oriented.
For me, in order to achieve full control for the navigation that is still missing in SwiftUI, I just embedded the SwiftUI View inside a UINavigationController. inside the SceneDelegate. Take note that I hide the navigation bar in order to use the NavigationView as my display.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
UINavigationBar.appearance().tintColor = .black
let contentView = OnBoardingView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let hostingVC = UIHostingController(rootView: contentView)
let mainNavVC = UINavigationController(rootViewController: hostingVC)
mainNavVC.navigationBar.isHidden = true
window.rootViewController = mainNavVC
self.window = window
window.makeKeyAndVisible()
}
}
}
And then I have created this Protocol and Extension, HasRootNavigationController
import SwiftUI
import UIKit
protocol HasRootNavigationController {
var rootVC:UINavigationController? { get }
func push<Content:View>(view: Content, animated:Bool)
func setRootNavigation<Content:View>(views:[Content], animated:Bool)
func pop(animated: Bool)
func popToRoot(animated: Bool)
}
extension HasRootNavigationController where Self:View {
var rootVC:UINavigationController? {
guard let scene = UIApplication.shared.connectedScenes.first,
let sceneDelegate = scene as? UIWindowScene,
let rootvc = sceneDelegate.windows.first?.rootViewController
as? UINavigationController else { return nil }
return rootvc
}
func push<Content:View>(view: Content, animated:Bool = true) {
rootVC?.pushViewController(UIHostingController(rootView: view), animated: animated)
}
func setRootNavigation<Content:View>(views: [Content], animated:Bool = true) {
let controllers = views.compactMap { UIHostingController(rootView: $0) }
rootVC?.setViewControllers(controllers, animated: animated)
}
func pop(animated:Bool = true) {
rootVC?.popViewController(animated: animated)
}
func popToRoot(animated: Bool = true) {
rootVC?.popToRootViewController(animated: animated)
}
}
After that, on my SwiftUI View, I used/implemented the HasRootNavigationController protocol and extension
extension YouSwiftUIView:HasRootNavigationController {
func switchToMainScreen() {
self.setRootNavigation(views: [MainView()])
}
func pushToMainScreen() {
self.push(view: [MainView()])
}
func goBack() {
self.pop()
}
func showTheInitialView() {
self.popToRoot()
}
}
Here is the gist of my code in case I have some updates. https://gist.github.com/michaelhenry/945fc63da49e960953b72bbc567458e6
Thanks to Malhal's #Binding solution, I learned I was missing the .isDetailLink(false) modifier.
In my case, I don't want to use the #Binding at every subsequent view.
This is my solution where I am using EnvironmentObject.
Step 1: Create an AppState ObservableObject
import SwiftUI
import Combine
class AppState: ObservableObject {
#Published var moveToDashboard: Bool = false
}
Step 2: Create instance of AppState and add in contentView in SceneDelegate
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
let appState = AppState()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView:
contentView
.environmentObject(appState)
)
self.window = window
window.makeKeyAndVisible()
}
}
Step 3: Code of ContentView.swift
I am updating the appState value of the last view in the Stack which using .onReceive() I am capturing in the contentView to update the isActive to false for the NavigationLink.
The key here is to use .isDetailLink(false) with the NavigationLink. Otherwise, it will not work.
import SwiftUI
import Combine
class AppState: ObservableObject {
#Published var moveToDashboard: Bool = false
}
struct ContentView: View {
#EnvironmentObject var appState: AppState
#State var isView1Active: Bool = false
var body: some View {
NavigationView {
VStack {
Text("Content View")
.font(.headline)
NavigationLink(destination: View1(), isActive: $isView1Active) {
Text("View 1")
.font(.headline)
}
.isDetailLink(false)
}
.onReceive(self.appState.$moveToDashboard) { moveToDashboard in
if moveToDashboard {
print("Move to dashboard: \(moveToDashboard)")
self.isView1Active = false
self.appState.moveToDashboard = false
}
}
}
}
}
// MARK:- View 1
struct View1: View {
var body: some View {
VStack {
Text("View 1")
.font(.headline)
NavigationLink(destination: View2()) {
Text("View 2")
.font(.headline)
}
}
}
}
// MARK:- View 2
struct View2: View {
#EnvironmentObject var appState: AppState
var body: some View {
VStack {
Text("View 2")
.font(.headline)
Button(action: {
self.appState.moveToDashboard = true
}) {
Text("Move to Dashboard")
.font(.headline)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
This solution is based on malhal's answer, uses suggestions from Imthath and Florin Odagiu, and required Paul Hudson's NavigationView video to bring it all together for me.
The idea is very simple. The isActive parameter of a navigationLink is set to true when tapped. That allows a second view to appear. You can use additional links to add more views. To go back to the root, just set isActive to false. The second view, plus any others that may have stacked up, disappear.
import SwiftUI
class Views: ObservableObject {
#Published var stacked = false
}
struct ContentView: View {
#ObservedObject var views = Views()
var body: some View {
NavigationView {
NavigationLink(destination: ContentView2(), isActive: self.$views.stacked) {
Text("Go to View 2") // Tapping this link sets stacked to true
}
.isDetailLink(false)
.navigationBarTitle("ContentView")
}
.environmentObject(views) // Inject a new views instance into the navigation view environment so that it's available to all views presented by the navigation view.
}
}
struct ContentView2: View {
var body: some View {
NavigationLink(destination: ContentView3()) {
Text("Go to View 3")
}
.isDetailLink(false)
.navigationBarTitle("View 2")
}
}
struct ContentView3: View {
#EnvironmentObject var views: Views
var body: some View {
Button("Pop to root") {
self.views.stacked = false // By setting this to false, the second view that was active is no more. Which means, the content view is being shown once again.
}
.navigationBarTitle("View 3")
}
}
IOS 16 Solution
Now finally you can pop to the root view with the newly added NavigationStack!!!
struct DataObject: Identifiable, Hashable {
let id = UUID()
let name: String
}
#available(iOS 16.0, *)
struct ContentView8: View {
#State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
Text("Root Pop")
.font(.largeTitle)
.foregroundColor(.primary)
NavigationLink("Click Item", value: DataObject.init(name: "Item"))
.listStyle(.plain)
.navigationDestination(for: DataObject.self) { course in
Text(course.name)
NavigationLink("Go Deeper", value: DataObject.init(name: "Item"))
Button("Back to root") {
path = NavigationPath()
}
}
}
.padding()
}
}
Here is my slow, animated, a bit rough backwards pop solution using onAppear, valid for Xcode 11 and iOS 13.1:
import SwiftUI
import Combine
struct NestedViewLevel3: View {
#Binding var resetView:Bool
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Spacer()
Text("Level 3")
Spacer()
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Back")
.padding(.horizontal, 15)
.padding(.vertical, 2)
.foregroundColor(Color.white)
.clipped(antialiased: true)
.background(
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.blue)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
)}
Spacer()
Button(action: {
self.$resetView.wrappedValue = true
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Reset")
.padding(.horizontal, 15)
.padding(.vertical, 2)
.foregroundColor(Color.white)
.clipped(antialiased: true)
.background(
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.blue)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
)}
Spacer()
}
.navigationBarBackButtonHidden(false)
.navigationBarTitle("Level 3", displayMode: .inline)
.onAppear(perform: {print("onAppear level 3")})
.onDisappear(perform: {print("onDisappear level 3")})
}
}
struct NestedViewLevel2: View {
#Binding var resetView:Bool
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Spacer()
NavigationLink(destination: NestedViewLevel3(resetView:$resetView)) {
Text("To level 3")
.padding(.horizontal, 15)
.padding(.vertical, 2)
.foregroundColor(Color.white)
.clipped(antialiased: true)
.background(
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.gray)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
)
.shadow(radius: 10)
}
Spacer()
Text("Level 2")
Spacer()
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Back")
.padding(.horizontal, 15)
.padding(.vertical, 2)
.foregroundColor(Color.white)
.clipped(antialiased: true)
.background(
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.blue)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
)}
Spacer()
}
.navigationBarBackButtonHidden(false)
.navigationBarTitle("Level 2", displayMode: .inline)
.onAppear(perform: {
print("onAppear level 2")
if self.$resetView.wrappedValue {
self.presentationMode.wrappedValue.dismiss()
}
})
.onDisappear(perform: {print("onDisappear level 2")})
}
}
struct NestedViewLevel1: View {
#Binding var resetView:Bool
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Spacer()
NavigationLink(destination: NestedViewLevel2(resetView:$resetView)) {
Text("To level 2")
.padding(.horizontal, 15)
.padding(.vertical, 2)
.foregroundColor(Color.white)
.clipped(antialiased: true)
.background(
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.gray)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
)
.shadow(radius: 10)
}
Spacer()
Text("Level 1")
Spacer()
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Back")
.padding(.horizontal, 15)
.padding(.vertical, 2)
.foregroundColor(Color.white)
.clipped(antialiased: true)
.background(
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.blue)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
)}
Spacer()
}
.navigationBarBackButtonHidden(false)
.navigationBarTitle("Level 1", displayMode: .inline)
.onAppear(perform: {
print("onAppear level 1")
if self.$resetView.wrappedValue {
self.presentationMode.wrappedValue.dismiss()
}
})
.onDisappear(perform: {print("onDisappear level 1")})
}
}
struct RootViewLevel0: View {
#Binding var resetView:Bool
var body: some View {
NavigationView {
VStack {
Spacer()
NavigationLink(destination: NestedViewLevel1(resetView:$resetView)) {
Text("To level 1")
.padding(.horizontal, 15)
.padding(.vertical, 2)
.foregroundColor(Color.white)
.clipped(antialiased: true)
.background(
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color.gray)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 40, alignment: .center)
)
.shadow(radius: 10)
}
//.disabled(false)
//.hidden()
Spacer()
}
}
//.frame(width:UIScreen.main.bounds.width,height: UIScreen.main.bounds.height - 110)
.navigationBarTitle("Root level 0", displayMode: .inline)
.navigationBarBackButtonHidden(false)
.navigationViewStyle(StackNavigationViewStyle())
.onAppear(perform: {
print("onAppear root level 0")
self.resetNavView()
})
.onDisappear(perform: {print("onDisappear root level 0")})
}
func resetNavView(){
print("resetting objects")
self.$resetView.wrappedValue = false
}
}
struct ContentView: View {
#State var resetView = false
var body: some View {
RootViewLevel0(resetView:$resetView)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here is my solution. IT works anywhere, without dependency.
let window = UIApplication.shared.connectedScenes
.filter { $0.activationState == .foregroundActive }
.map { $0 as? UIWindowScene }
.compactMap { $0 }
.first?.windows
.filter { $0.isKeyWindow }
.first
let nvc = window?.rootViewController?.children.first as? UINavigationController
nvc?.popToRootViewController(animated: true)
NavigationViewKit
import NavigationViewKit
NavigationView {
List(0..<10) { _ in
NavigationLink("abc", destination: DetailView())
}
}
.navigationViewManager(for: "nv1", afterBackDo: {print("back to root") })
In any view in NavigationView:
#Environment(\.navigationManager) var nvmanager
Button("back to root view") {
nvmanager.wrappedValue.popToRoot(tag:"nv1") {
print("other back")
}
}
You can also call it through NotificationCenter without calling it in the view
let backToRootItem = NavigationViewManager.BackToRootItem(tag: "nv1", animated: false, action: {})
NotificationCenter.default.post(name: .NavigationViewManagerBackToRoot, object: backToRootItem)
Details
Xcode Version 13.2.1 (13C100), Swift 5.5
Solution
Linked list
https://github.com/raywenderlich/swift-algorithm-club/blob/master/Linked%20List/LinkedList.swift
NavigationStack
import SwiftUI
import Combine
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// MARK: Custom NavigationLink
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
final class CustomNavigationLinkViewModel<CustomViewID>: ObservableObject where CustomViewID: Equatable {
private weak var navigationStack: NavigationStack<CustomViewID>?
/// `viewId` is used to find a `CustomNavigationLinkViewModel` in the `NavigationStack`
let viewId = UUID().uuidString
/// `customId` is used to mark a `CustomNavigationLink` in the `NavigationStack`. This is kind of external id.
/// In `NavigationStack` we always prefer to use `viewId`. But from time to time we need to implement `pop several views`
/// and that is the purpose of the `customId`
/// Developer can just create a link with `customId` e.g. `navigationStack.navigationLink(customId: "123") { .. }`
/// And to pop directly to view `"123"` should use `navigationStack.popToLast(customId: "123")`
let customId: CustomViewID?
#Published var isActive = false {
didSet { navigationStack?.updated(linkViewModel: self) }
}
init (navigationStack: NavigationStack<CustomViewID>, customId: CustomViewID? = nil) {
self.navigationStack = navigationStack
self.customId = customId
}
}
extension CustomNavigationLinkViewModel: Equatable {
static func == (lhs: CustomNavigationLinkViewModel, rhs: CustomNavigationLinkViewModel) -> Bool {
lhs.viewId == rhs.viewId && lhs.customId == rhs.customId
}
}
struct CustomNavigationLink<Label, Destination, CustomViewID>: View where Label: View, Destination: View, CustomViewID: Equatable {
/// Link `ViewModel` where all states are stored
#StateObject var viewModel: CustomNavigationLinkViewModel<CustomViewID>
let destination: () -> Destination
let label: () -> Label
var body: some View {
NavigationLink(isActive: $viewModel.isActive, destination: destination, label: label)
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// MARK: NavigationStack
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
class NavigationStack<CustomViewID>: ObservableObject where CustomViewID: Equatable {
typealias Link = WeakReference<CustomNavigationLinkViewModel<CustomViewID>>
private var linkedList = LinkedList<Link>()
func navigationLink<Label, Destination>(customId: CustomViewID? = nil,
#ViewBuilder destination: #escaping () -> Destination,
#ViewBuilder label: #escaping () -> Label)
-> some View where Label: View, Destination: View {
createNavigationLink(customId: customId, destination: destination, label: label)
}
private func createNavigationLink<Label, Destination>(customId: CustomViewID? = nil,
#ViewBuilder destination: #escaping () -> Destination,
#ViewBuilder label: #escaping () -> Label)
-> CustomNavigationLink<Label, Destination, CustomViewID> where Label: View, Destination: View {
.init(viewModel: CustomNavigationLinkViewModel(navigationStack: self, customId: customId),
destination: destination,
label: label)
}
}
// MARK: Nested Types
extension NavigationStack {
/// To avoid retain cycle it is important to store weak reference to the `CustomNavigationLinkViewModel`
final class WeakReference<T> where T: AnyObject {
private(set) weak var weakReference: T?
init(value: T) { self.weakReference = value }
deinit { print("deinited WeakReference") }
}
}
// MARK: Searching
extension NavigationStack {
private func last(where condition: (Link) -> Bool) -> LinkedList<Link>.Node? {
var node = linkedList.last
while(node != nil) {
if let node = node, condition(node.value) {
return node
}
node = node?.previous
}
return nil
}
}
// MARK: Binding
extension NavigationStack {
fileprivate func updated(linkViewModel: CustomNavigationLinkViewModel<CustomViewID>) {
guard linkViewModel.isActive else {
switch linkedList.head?.value.weakReference {
case nil: break
case linkViewModel: linkedList.removeAll()
default:
last (where: { $0.weakReference === linkViewModel })?.previous?.next = nil
}
return
}
linkedList.append(WeakReference(value: linkViewModel))
}
}
// MARK: pop functionality
extension NavigationStack {
func popToRoot() {
linkedList.head?.value.weakReference?.isActive = false
}
func pop() {
linkedList.last?.value.weakReference?.isActive = false
}
func popToLast(customId: CustomViewID) {
last (where: { $0.weakReference?.customId == customId })?.value.weakReference?.isActive = false
}
}
#if DEBUG
extension NavigationStack {
var isEmpty: Bool { linkedList.isEmpty }
var count: Int { linkedList.count }
func testCreateNavigationLink<Label, Destination>(viewModel: CustomNavigationLinkViewModel<CustomViewID>,
#ViewBuilder destination: #escaping () -> Destination,
#ViewBuilder label: #escaping () -> Label)
-> CustomNavigationLink<Label, Destination, CustomViewID> where Label: View, Destination: View {
.init(viewModel: viewModel, destination: destination, label: label)
}
}
#endif
Usage (short sample)
Create NavigationLink:
struct Page: View {
#EnvironmentObject var navigationStack: NavigationStack<String>
var body: some View {
navigationStack.navigationLink {
NextView(...)
} label: {
Text("Next page")
}
}
}
Pop functionality
struct Page: View {
#EnvironmentObject var navigationStack: NavigationStack<String>
var body: some View {
Button("Pop") {
navigationStack.pop()
}
Button("Pop to Page 1") {
navigationStack.popToLast(customId: "1")
}
Button("Pop to root") {
navigationStack.popToRoot()
}
}
}
Usage (full sample)
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
addTab(title: "Tab 1", systemImageName: "house")
addTab(title: "Tab 2", systemImageName: "bookmark")
}
}
func addTab(title: String, systemImageName: String) -> some View {
NavigationView {
RootPage(title: "\(title) home")
.navigationBarTitle(title)
}
.environmentObject(NavigationStack<String>())
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Image(systemName: systemImageName)
Text(title)
}
}
}
struct RootPage: View {
let title: String
var body: some View {
SimplePage(title: title, pageCount: 0)
}
}
struct SimplePage: View {
#EnvironmentObject var navigationStack: NavigationStack<String>
var title: String
var pageCount: Int
var body: some View {
VStack {
navigationStack.navigationLink(customId: "\(pageCount)") {
// router.navigationLink {
SimplePage(title: "Page: \(pageCount + 1)", pageCount: pageCount + 1)
} label: {
Text("Next page")
}
Button("Pop") {
navigationStack.pop()
}
Button("Pop to Page 1") {
navigationStack.popToLast(customId: "1")
}
Button("Pop to root") {
navigationStack.popToRoot()
}
}
.navigationTitle(title)
}
}
Some Unit tests
#testable import SwiftUIPop
import XCTest
import SwiftUI
import Combine
class SwiftUIPopTests: XCTestCase {
typealias CustomLinkID = String
typealias Stack = NavigationStack<CustomLinkID>
private let stack = Stack()
}
// MARK: Empty Navigation Stack
extension SwiftUIPopTests {
func testNoCrashOnPopToRootOnEmptyStack() {
XCTAssertTrue(stack.isEmpty)
stack.popToRoot()
}
func testNoCrashOnPopToLastOnEmptyStack() {
XCTAssertTrue(stack.isEmpty)
stack.popToLast(customId: "123")
}
func testNoCrashOnPopOnEmptyStack() {
XCTAssertTrue(stack.isEmpty)
stack.pop()
}
}
// MARK: expectation functions
private extension SwiftUIPopTests {
func navigationStackShould(beEmpty: Bool) {
if beEmpty {
XCTAssertTrue(stack.isEmpty, "Navigation Stack should be empty")
} else {
XCTAssertFalse(stack.isEmpty, "Navigation Stack should not be empty")
}
}
}
// MARK: Data / model generators
private extension SwiftUIPopTests {
func createNavigationLink(viewModel: CustomNavigationLinkViewModel<CustomLinkID>, stack: Stack)
-> CustomNavigationLink<EmptyView, EmptyView, CustomLinkID> {
stack.testCreateNavigationLink(viewModel: viewModel) {
EmptyView()
} label: {
EmptyView()
}
}
func createNavigationLinkViewModel(customId: CustomLinkID? = nil) -> CustomNavigationLinkViewModel<CustomLinkID> {
.init(navigationStack: stack, customId: customId)
}
}
// MARK: test `isActive` changing from `true` to `false` on `pop`
extension SwiftUIPopTests {
private func isActiveChangeOnPop(customId: String? = nil,
popAction: (Stack) -> Void,
file: StaticString = #file,
line: UInt = #line) {
navigationStackShould(beEmpty: true)
let expec = expectation(description: "Wait for viewModel.isActive changing")
var canalables = Set<AnyCancellable>()
let viewModel = createNavigationLinkViewModel(customId: customId)
let navigationLink = createNavigationLink(viewModel: viewModel, stack: stack)
navigationLink.viewModel.isActive = true
navigationLink.viewModel.$isActive.dropFirst().sink { value in
expec.fulfill()
}.store(in: &canalables)
navigationStackShould(beEmpty: false)
popAction(stack)
waitForExpectations(timeout: 2)
navigationStackShould(beEmpty: true)
}
func testIsActiveChangeOnPop() {
isActiveChangeOnPop { $0.pop() }
}
func testIsActiveChangeOnPopToRoot() {
isActiveChangeOnPop { $0.popToRoot() }
}
func testIsActiveChangeOnPopToLast() {
let customId = "1234"
isActiveChangeOnPop(customId: customId) { $0.popToLast(customId: customId) }
}
func testIsActiveChangeOnPopToLast2() {
navigationStackShould(beEmpty: true)
let expec = expectation(description: "Wait")
var canalables = Set<AnyCancellable>()
let viewModel = createNavigationLinkViewModel(customId: "123")
let navigationLink = createNavigationLink(viewModel: viewModel, stack: stack)
navigationLink.viewModel.isActive = true
navigationLink.viewModel.$isActive.dropFirst().sink { value in
expec.fulfill()
}.store(in: &canalables)
navigationStackShould(beEmpty: false)
stack.popToLast(customId: "1234")
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
expec.fulfill()
}
waitForExpectations(timeout: 3)
navigationStackShould(beEmpty: false)
}
}
// MARK: Check that changing `CustomNavigationLinkViewModel.isActive` will update `Navigation Stack`
extension SwiftUIPopTests {
// Add and remove view to the empty stack
private func isActiveChangeUpdatesNavigationStack1(createLink: (Stack) -> CustomNavigationLink<EmptyView, EmptyView, String>) {
navigationStackShould(beEmpty: true)
let navigationLink = createLink(stack)
navigationStackShould(beEmpty: true)
navigationLink.viewModel.isActive = true
navigationStackShould(beEmpty: false)
navigationLink.viewModel.isActive = false
navigationStackShould(beEmpty: true)
}
func testIsActiveChangeUpdatesNavigationStack1() {
isActiveChangeUpdatesNavigationStack1 { stack in
let viewModel = createNavigationLinkViewModel()
return createNavigationLink(viewModel: viewModel, stack: stack)
}
}
func testIsActiveChangeUpdatesNavigationStack2() {
isActiveChangeUpdatesNavigationStack1 { stack in
let viewModel = createNavigationLinkViewModel(customId: "123")
return createNavigationLink(viewModel: viewModel, stack: stack)
}
}
// Add and remove view to the non-empty stack
private func isActiveChangeUpdatesNavigationStack2(createLink: (Stack) -> CustomNavigationLink<EmptyView, EmptyView, String>) {
navigationStackShould(beEmpty: true)
let viewModel1 = createNavigationLinkViewModel()
let navigationLink1 = createNavigationLink(viewModel: viewModel1, stack: stack)
navigationLink1.viewModel.isActive = true
navigationStackShould(beEmpty: false)
XCTAssertEqual(stack.count, 1, "Navigation Stack Should contains only one link")
let navigationLink2 = createLink(stack)
navigationLink2.viewModel.isActive = true
navigationStackShould(beEmpty: false)
navigationLink2.viewModel.isActive = false
XCTAssertEqual(stack.count, 1, "Navigation Stack Should contains only one link")
}
func testIsActiveChangeUpdatesNavigationStack3() {
isActiveChangeUpdatesNavigationStack2 { stack in
let viewModel = createNavigationLinkViewModel()
return createNavigationLink(viewModel: viewModel, stack: stack)
}
}
func testIsActiveChangeUpdatesNavigationStack4() {
isActiveChangeUpdatesNavigationStack2 { stack in
let viewModel = createNavigationLinkViewModel(customId: "123")
return createNavigationLink(viewModel: viewModel, stack: stack)
}
}
}
I recently created an open source project called swiftui-navigation-stack. It's an alternative navigation stack for SwiftUI. Take a look at the README for all the details; it's really easy to use.
First of all, if you want to navigate between screens (i.e., fullscreen views) define your own simple Screen view:
struct Screen<Content>: View where Content: View {
let myAppBackgroundColour = Color.white
let content: () -> Content
var body: some View {
ZStack {
myAppBackgroundColour.edgesIgnoringSafeArea(.all)
content()
}
}
}
Then embed your root in a NavigationStackView (as you'd do with the standard NavigationView):
struct RootView: View {
var body: some View {
NavigationStackView {
Homepage()
}
}
}
Now let's create a couple of child views just to show you the basic behaviour:
struct Homepage: View {
var body: some View {
Screen {
PushView(destination: FirstChild()) {
Text("PUSH FORWARD")
}
}
}
}
struct FirstChild: View {
var body: some View {
Screen {
VStack {
PopView {
Text("JUST POP")
}
PushView(destination: SecondChild()) {
Text("PUSH FORWARD")
}
}
}
}
}
struct SecondChild: View {
var body: some View {
Screen {
VStack {
PopView {
Text("JUST POP")
}
PopView(destination: .root) {
Text("POP TO ROOT")
}
}
}
}
}
You can exploit PushView and PopView to navigate back and forth. Of course, your content view inside the SceneDelegate must be:
// Create the SwiftUI view that provides the window contents.
let contentView = RootView()
The result is:
There is a simple solution in iOS 15 for that by using dismiss() and passing dismiss to the subview:
struct ContentView: View {
#State private var showingSheet = false
var body: some View {
NavigationView {
Button("show sheet", action: { showingSheet.toggle()})
.navigationTitle("ContentView")
}.sheet(isPresented: $showingSheet) { FirstSheetView() }
}
}
struct FirstSheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
List {
NavigationLink(destination: SecondSheetView(dismiss: _dismiss)) {
Text("show 2nd Sheet view")
}
NavigationLink(destination: ThirdSheetView(dismiss: _dismiss)) {
Text("show 3rd Sheet view")
}
Button("cancel", action: {dismiss()})
} .navigationTitle("1. SheetView")
}
}
}
struct SecondSheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
List {
NavigationLink(destination: ThirdSheetView(dismiss: _dismiss)) {
Text("show 3rd SheetView")
}
Button("cancel", action: {dismiss()})
} .navigationTitle("2. SheetView")
}
}
struct ThirdSheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
List {
Button("cancel", action: {dismiss()})
} .navigationTitle("3. SheetView")
}
}
I did not find a solution in SwiftUI yet, but I found the
library CleanUI.
Using the CUNavigation class, I can achieve exactly the navigation pattern I wanted.
An example from the library's README:
NavigationView {
Button(action: {
CUNavigation.pushToSwiftUiView(YOUR_VIEW_HERE)
}){
Text("Push To SwiftUI View")
}
Button(action: {
CUNavigation.popToRootView()
}){
Text("Pop to the Root View")
}
Button(action: {
CUNavigation.pushBottomSheet(YOUR_VIEW_HERE)
}){
Text("Push to a Botton-Sheet")
}
}
This is an update to x0randgat3's answer that works for multiple NavigationViews within a TabView.
struct NavigationUtil {
static func popToRootView() {
findNavigationController(viewController: UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.rootViewController)?
.popToRootViewController(animated: true)
}
static func findNavigationController(viewController: UIViewController?) -> UINavigationController? {
guard let viewController = viewController else {
return nil
}
if let navigationController = viewController as? UITabBarController {
return findNavigationController(viewController: navigationController.selectedViewController)
}
if let navigationController = viewController as? UINavigationController {
return navigationController
}
for childViewController in viewController.children {
return findNavigationController(viewController: childViewController)
}
return nil
}
}
I came up with another technique which works but it still feels strange. It also still animates both screens dismissing, but it's a little cleaner. You can either A ) Pass a closure down to the subsequent detail screens or B ) pass detailB the presentationMode of detailA. Both of these require dismissing detailB, then delaying a short while so detailA is back on-screen before attempting to dismiss detailA.
let minDelay = TimeInterval(0.001)
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink("Push Detail A", destination: DetailViewA())
}.navigationBarTitle("Root View")
}
}
}
struct DetailViewA: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Spacer()
NavigationLink("Push Detail With Closure",
destination: DetailViewWithClosure(dismissParent: { self.dismiss() }))
Spacer()
NavigationLink("Push Detail with Parent Binding",
destination: DetailViewWithParentBinding(parentPresentationMode: self.presentationMode))
Spacer()
}.navigationBarTitle("Detail A")
}
func dismiss() {
print ("Detail View A dismissing self.")
presentationMode.wrappedValue.dismiss()
}
}
struct DetailViewWithClosure: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#State var dismissParent: () -> Void
var body: some View {
VStack {
Button("Pop Both Details") { self.popParent() }
}.navigationBarTitle("Detail With Closure")
}
func popParent() {
presentationMode.wrappedValue.dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + minDelay) { self.dismissParent() }
}
}
struct DetailViewWithParentBinding: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#Binding var parentPresentationMode: PresentationMode
var body: some View {
VStack {
Button("Pop Both Details") { self.popParent() }
}.navigationBarTitle("Detail With Binding")
}
func popParent() {
presentationMode.wrappedValue.dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + minDelay) { self.parentPresentationMode.dismiss() }
}
}
The more I think about how SwiftUI works and how things are structured, the less I think Apple will provide something equivalent to popToRootViewController or other direct edits to the navigation stack. It flies in the face of the way SwiftUI builds up view structs because it lets a child view reach up into a parent's state and manipulate it. Which is exactly what these approaches do, but they do it explicitly and overtly. DetailViewA can't create either of the of the destination views without providing access into its own state, meaning the author has to think through the implications of providing said access.
Elementary.
Enough in the root view (where you want to go back) use NavigationLink with an isActive designer. In the last view, switch to the FALSE variable controlling the isActive parameter.
In the Swift version 5.5 use .isDetaillink(false) is optional.
You can use some common class as I have in the example, or transmit this variable down the VIEW hierarchy through binding. Use how it is more convenient for you.
class ViewModel: ObservableObject {
#Published var isActivate = false
}
#main
struct TestPopToRootApp: App {
let vm = ViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(vm)
}
}
}
struct ContentView: View {
#EnvironmentObject var vm: ViewModel
var body: some View {
NavigationView {
NavigationLink("Go to view2", destination: NavView2(), isActive: $vm.isActivate)
.navigationTitle(Text("Root view"))
}
}
}
struct NavView2: View {
var body: some View {
NavigationLink("Go to view3", destination: NavView3())
.navigationTitle(Text("view2"))
}
}
struct NavView3: View {
#EnvironmentObject var vm: ViewModel
var body: some View {
Button {
vm.isActivate = false
} label: {
Text("Back to root")
}
.navigationTitle(Text("view3"))
}
}
To go to Root View without using .isDetailLink(false) you need to remove NavigationLink from hierarchy view of Root View
class NavigationLinkStore: ObservableObject {
static let shared = NavigationLinkStore()
#Published var showLink = false
}
struct NavigationLinkView: View {
#ObservedObject var store = NavigationLinkStore.shared
#State var isActive = false
var body: some View {
NavigationView {
VStack {
Text("Main")
Button("Go to View1") {
Task {
store.showLink = true
try await Task.sleep(seconds: 0.1)
isActive = true
}
}
if store.showLink {
NavigationLink(
isActive: $isActive,
destination: { NavigationLink1View() },
label: { EmptyView() }
)
}
}
}
}
}
struct NavigationLink1View: View {
var body: some View {
VStack {
Text("View1")
NavigationLink("Go to View 2", destination: NavigationLink2View())
}
}
}
struct NavigationLink2View: View {
#ObservedObject var store = NavigationLinkStore.shared
var body: some View {
VStack {
Text("View2")
Button("Go to root") {
store.showLink = false
}
}
}
}
Here's a generic approach for complex navigation which combines many approaches described here. This pattern is useful if you have many flows which need to pop back to the root and not just one.
First, set up your environment ObservableObject and for readability, use an enum to type your views.
class ActiveView : ObservableObject {
#Published var selection: AppView? = nil
}
enum AppView : Comparable {
case Main, Screen_11, Screen_12, Screen_21, Screen_22
}
[...]
let activeView = ActiveView()
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(activeView))
In your main ContentView, use buttons with NavigationLink on EmptyView(). We do that to use the isActive parameter of NavigationLink instead of the tag and selection. Screen_11 on main view needs to remain active on Screen_12, and conversely, Screen_21 needs to remain active with Screen_22 or otherwise the views will pop out. Don't forget to set your isDetailLink to false.
struct ContentView: View {
#EnvironmentObject private var activeView: ActiveView
var body: some View {
NavigationView {
VStack {
// These buttons navigate by setting the environment variable.
Button(action: { self.activeView.selection = AppView.Screen_1.1}) {
Text("Navigate to Screen 1.1")
}
Button(action: { self.activeView.selection = AppView.Screen_2.1}) {
Text("Navigate to Screen 2.1")
}
// These are the navigation link bound to empty views so invisible
NavigationLink(
destination: Screen_11(),
isActive: orBinding(b: self.$activeView.selection, value1: AppView.Screen_11, value2: AppView.Screen_12)) {
EmptyView()
}.isDetailLink(false)
NavigationLink(
destination: Screen_21(),
isActive: orBinding(b: self.$activeView.selection, value1: AppView.Screen_21, value2: AppView.Screen_22)) {
EmptyView()
}.isDetailLink(false)
}
}
}
You can use the same pattern on Screen_11 to navigate to Screen_12.
Now, the breakthrough for that complex navigation is the orBinding. It allows the stack of views on a navigation flow to remain active. Whether you are on Screen_11 or Screen_12, you need the NavigationLink(Screen_11) to remain active.
// This function create a new Binding<Bool> compatible with NavigationLink.isActive
func orBinding<T:Comparable>(b: Binding<T?>, value1: T, value2: T) -> Binding<Bool> {
return Binding<Bool>(
get: {
return (b.wrappedValue == value1) || (b.wrappedValue == value2)
},
set: { newValue in } // Don't care the set
)
}
I found a solution that works fine for me. Here is how it works:
A GIF image shows how it works
In the ContentView.swift file:
define a RootSelection class, declare an #EnvironmentObject of RootSelection to record the tag of the current active NavigationLink only in root view.
add a modifier .isDetailLink(false) to each NavigationLink that is not a final detail view.
use a file system hierarchy to simulate the NavigationView.
this solution works fine when the root view has multiple NavigationLink.
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
SubView(folder: rootFolder)
}
}
}
struct SubView: View {
#EnvironmentObject var rootSelection: RootSelection
var folder: Folder
var body: some View {
List(self.folder.documents) { item in
if self.folder.documents.count == 0 {
Text("empty folder")
} else {
if self.folder.id == rootFolder.id {
NavigationLink(item.name, destination: SubView(folder: item as! Folder), tag: item.id, selection: self.$rootSelection.tag)
.isDetailLink(false)
} else {
NavigationLink(item.name, destination: SubView(folder: item as! Folder))
.isDetailLink(false)
}
}
}
.navigationBarTitle(self.folder.name, displayMode: .large)
.listStyle(SidebarListStyle())
.overlay(
Button(action: {
rootSelection.tag = nil
}, label: {
Text("back to root")
})
.disabled(self.folder.id == rootFolder.id)
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(RootSelection())
}
}
class RootSelection: ObservableObject {
#Published var tag: UUID? = nil
}
class Document: Identifiable {
let id = UUID()
var name: String
init(name: String) {
self.name = name
}
}
class File: Document {}
class Folder: Document {
var documents: [Document]
init(name: String, documents: [Document]) {
self.documents = documents
super.init(name: name)
}
}
let rootFolder = Folder(name: "root", documents: [
Folder(name: "folder1", documents: [
Folder(name: "folder1.1", documents: []),
Folder(name: "folder1.2", documents: []),
]),
Folder(name: "folder2", documents: [
Folder(name: "folder2.1", documents: []),
Folder(name: "folder2.2", documents: []),
])
])
.environmentObject(RootSelection()) is required for the ContentView() object in xxxApp.swift files.
import SwiftUI
#main
struct DraftApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(RootSelection())
}
}
}
malhal's answer is definitely the proper one.
I made a wrapper to NavigationLink that allows me to apply any modifiers I need besides the isDetailLink(false) one and capture whatever data I need.
Specifically, it captures the isActive binding or the tag binding so that I can reset those when I want to pop to whatever view declared itself the root.
Setting isRoot = true will store the binding for that view, and the dismiss parameter takes an optional closure in case you need something done when the pop happens.
I copied the basic signatures from the SwiftUI NavigationLinks initializers for simple boolean or tag based navigation so that it is easy to edit existing usages. It should be straightforward to add others if needed.
The wrapper looks like this:
struct NavigationStackLink<Label, Destination> : View where Label : View, Destination : View {
var isActive: Binding<Bool>? // Optionality implies whether tag or Bool binding is used
var isRoot: Bool = false
let link: NavigationLink<Label, Destination>
private var dismisser: () -> Void = {}
/// Wraps [NavigationLink](https://developer.apple.com/documentation/swiftui/navigationlink/init(isactive:destination:label:))
/// `init(isActive: Binding<Bool>, destination: () -> Destination, label: () -> Label)`
/// - Parameters:
/// - isActive: A Boolean binding controlling the presentation state of the destination
/// - isRoot: Indicate if this is the root view. Used to pop to root level. Default `false`
/// - dismiss: A closure that is called when the link destination is about to be dismissed
/// - destination: The link destination view
/// - label: The links label
init(isActive: Binding<Bool>, isRoot : Bool = false, dismiss: #escaping () -> Void = {}, #ViewBuilder destination: #escaping () -> Destination, #ViewBuilder label: #escaping () -> Label) {
self.isActive = isActive
self.isRoot = isRoot
self.link = NavigationLink(isActive: isActive, destination: destination, label: label)
self.dismisser = dismiss
}
/// Wraps [NavigationLink ](https://developer.apple.com/documentation/swiftui/navigationlink/init(tag:selection:destination:label:))
init<V>(tag: V, selection: Binding<V?>, isRoot : Bool = false, dismiss: #escaping () -> Void = {}, #ViewBuilder destination: #escaping () -> Destination, #ViewBuilder label: #escaping () -> Label) where V : Hashable
{
self.isRoot = isRoot
self.link = NavigationLink(tag: tag, selection: selection, destination: destination, label: label)
self.dismisser = dismiss
self.isActive = Binding (get: {
selection.wrappedValue == tag
}, set: { newValue in
if newValue {
selection.wrappedValue = tag
} else {
selection.wrappedValue = nil
}
})
}
// Make sure you inject your external store into your view hierarchy
#EnvironmentObject var viewRouter: ViewRouter
var body: some View {
// Store whatever you need to in your external object
if isRoot {
viewRouter.root = isActive
}
viewRouter.dismissals.append(self.dismisser)
// Return the link with whatever modification you need
return link
.isDetailLink(false)
}
}
The ViewRouter can be whatever you need. I used an ObservableObject with the intent to eventually add some Published values for more complex stack manipulation in the future:
class ViewRouter: ObservableObject {
var root: Binding<Bool>?
typealias Dismiss = () -> Void
var dismissals : [Dismiss] = []
func popToRoot() {
dismissals.forEach { dismiss in
dismiss()
}
dismissals = []
root?.wrappedValue = false
}
}
At first, I was using the solution from the Chuck H that was posted here.
But I was faced with an issue when this solution didn't work in my case. It was connected to the case when the root view is a start point for two or more flows and at some point of these flows the user has the ability to do the pop to root. And in this case this solution didn't work because it has the one common state #Environment(\.rootPresentationMode) private var rootPresentationMode
I made the RouteManager with the additional enum Route which describes some specific flow where the user has the ability to do the pop to root
RouteManager:
final class RouteManager: ObservableObject {
#Published
private var routers: [Int: Route] = [:]
subscript(for route: Route) -> Route? {
get {
routers[route.rawValue]
}
set {
routers[route.rawValue] = route
}
}
func select(_ route: Route) {
routers[route.rawValue] = route
}
func unselect(_ route: Route) {
routers[route.rawValue] = nil
}
}
Route:
enum Route: Int, Hashable {
case signUp
case restorePassword
case orderDetails
}
Usage:
struct ContentView: View {
#EnvironmentObject
var routeManager: RouteManager
var body: some View {
NavigationView {
VStack {
NavigationLink(
destination: SignUp(),
tag: .signUp,
selection: $routeManager[for: .signUp]
) { EmptyView() }.isDetailLink(false)
NavigationLink(
destination: RestorePassword(),
tag: .restorePassword,
selection: $routeManager[for: .restorePassword]
) { EmptyView() }.isDetailLink(false)
Button("Sign Up") {
routeManager.select(.signUp)
}
Button("Restore Password") {
routeManager.select(.restorePassword)
}
}
.navigationBarTitle("Navigation")
.onAppear {
routeManager.unselect(.signUp)
routeManager.unselect(.restorePassword)
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
!! IMPORTANT !!
You should use the unselect method of the RouteManager when the user goes forward to the flow and then goes back by tapping on the back button. In this case, need to reset the state of our route manager for the previously selected flows to avoid undefined (unexpected) behavior:
.onAppear {
routeManager.unselect(.signUp)
routeManager.unselect(.restorePassword)
}
You can find a full demo project here.
It's very hard to achieve with NavigationView and NavigationLink. However, if you are using the UIPilot library, which a tiny wrapper around NavigationView, popping to any destination is very straightforward.
Suppose you have routes,
enum AppRoute: Equatable {
case Home
case Detail
case NestedDetail
}
and you have setup root view like below
struct ContentView: View {
#StateObject var pilot = UIPilot(initial: AppRoute.Home)
var body: some View {
UIPilotHost(pilot) { route in
switch route {
case .Home: return AnyView(HomeView())
case .Detail: return AnyView(DetailView())
case .NestedDetail: return AnyView(NestedDetail())
}
}
}
}
And you want to pop to Home from the NestedDetail screen. Just use the popTo function.
struct NestedDetail: View {
#EnvironmentObject var pilot: UIPilot<AppRoute>
var body: some View {
VStack {
Button("Go to home", action: {
pilot.popTo(.Home) // Pop to home
})
}.navigationTitle("Nested detail")
}
}
I created a solution that "just works" and am very happy with it. To use my magic solutions, there are only a few steps you have to do.
It starts out with using rootPresentationMode that's used elsewhere in this thread. Add this code:
// Create a custom environment key
struct RootPresentationModeKey: EnvironmentKey {
static let defaultValue: Binding<RootPresentationMode> = .constant(RootPresentationMode())
}
extension EnvironmentValues {
var rootPresentationMode: Binding<RootPresentationMode> {
get { self[RootPresentationModeKey.self] }
set { self[RootPresentationModeKey.self] = newValue }
}
}
typealias RootPresentationMode = Bool
extension RootPresentationMode: Equatable {
mutating func dismiss() {
toggle()
}
}
Next comes the magic. It has two steps.
Create a view modifier that monitors changes to the rootPresentationMode variable.
struct WithRoot: ViewModifier {
#Environment(\.rootPresentationMode) private var rootPresentationMode
#Binding var rootBinding: Bool
func body(content: Content) -> some View {
content
.onChange(of: rootBinding) { newValue in
// We only care if it's set to true
if newValue {
rootPresentationMode.wrappedValue = true
}
}
.onChange(of: rootPresentationMode.wrappedValue) { newValue in
// We only care if it's set to false
if !newValue {
rootBinding = false
}
}
}
}
extension View {
func withRoot(rootBinding: Binding<Bool>) -> some View {
modifier(WithRoot(rootBinding: rootBinding))
}
}
Add an isPresented to all NavigationViews
struct ContentView: View {
// This seems.. unimportant, but it's crucial. This variable
// lets us pop back to the root view from anywhere by adding
// a withRoot() modifier
// It's only used indirectly by the withRoot() modifier.
#State private var isPresented = false
var body: some View {
NavigationView {
MyMoneyMakingApp()
}
// rootPresentationMode MUST be set on a NavigationView to be
// accessible from everywhere
.environment(\.rootPresentationMode, $isPresented)
}
To use it in (any) subviews, all you have to do is
struct MyMoneyMakingApp: View {
#State private var isActive = false
var body: some View {
VStack {
NavigationLink(destination: ADeepDeepLink(), isActive: $isActive) {
Text("go deep")
}
}
.withRoot(rootBinding: $isActive)
}
}
struct ADeepDeepLink: View {
#Environment(\.rootPresentationMode) private var rootPresentationMode
var body: some View {
VStack {
NavigationLink(destination: ADeepDeepLink()) {
Text("go deeper")
}
Button(action: {
rootPresentationMode.wrappedValue.dismiss()
}) {
Text("pop to root")
}
}
}
}
The answer from #malhal really helped out, but in my situation I needed functionality when each button was pressed before navigating. If you are in that same boat try this code out!
// ContentView.swift
// Navigation View Buttons
//
// Created by Jarren Campos on 9/10/22.
//
import SwiftUI
struct ContentView: View {
var body: some View{
VStack{
ContentView1()
}
}
}
struct ContentView1: View {
#State var isActive : Bool = false
var body: some View {
NavigationView {
VStack{
Button {
isActive = true
} label: {
Text("To 2")
}
}
.background{
NavigationLink(
destination: ContentView2(rootIsActive: self.$isActive),
isActive: self.$isActive) {}
.isDetailLink(false)
}
.navigationBarTitle("One")
}
}
}
struct ContentView2: View {
#Binding var rootIsActive : Bool
#State var toThirdView: Bool = false
var body: some View {
VStack{
Button {
toThirdView = true
} label: {
Text("to 3")
}
}
.background{
NavigationLink(isActive: $toThirdView) {
ContentView3(shouldPopToRootView: self.$rootIsActive)
} label: {}
.isDetailLink(false)
}
.navigationBarTitle("Two")
}
}
struct ContentView3: View {
#Binding var shouldPopToRootView : Bool
var body: some View {
VStack {
Text("Hello, World #3!")
Button {
self.shouldPopToRootView = false
} label: {
Text("Pop to root")
}
}
.navigationBarTitle("Three")
}
}
I don't have exactly the same issue but I do have code that changes the root view from one that doesn't support a navigation stack to one that does. The trick is that I don't do it in SwiftUI - I do it in the SceneDelegate and replace the UIHostingController with a new one.
Here's a simplified extract from my SceneDelegate:
func changeRootToOnBoarding() {
guard let window = window else {
return
}
let onBoarding = OnBoarding(coordinator: notificationCoordinator)
.environmentObject(self)
window.rootViewController = UIHostingController(rootView: onBoarding)
}
func changeRootToTimerList() {
guard let window = window else {
return
}
let listView = TimerList()
.environmentObject(self)
window.rootViewController = UIHostingController(rootView: listView)
}
Since the SceneDelegate put itself in the environment any child view can add
/// Our "parent" SceneDelegate that can change the root view.
#EnvironmentObject private var sceneDelegate: SceneDelegate
and then call public functions on the delegate. I think if you did something similar that kept the View but created a new UIHostingController for it and replaced window.rootViewController it might work for you.