How can I avoid nested Navigation Bars in SwiftUI? - swift

Using SwiftUI, I've built a NavigationView that takes the user to another NavigationView, and finally, to a simple View. When I get to the last view, I can see two back buttons and a very large Navigation Bar.
I'd like to have a navigation structure similar to the iOS Settings app, where one navigation list takes to another and each of them have one back button that goes back to the previous screen.
Does anyone know how to solve this?

You should only have one NavigationView in your view hierarchy, as an ancestor of the menu view. You can then use NavigationLinks at any level of the hierarchy under that.
So, for example, your root view could be defined like this:
struct RootView: View {
var body: some View {
NavigationView {
MenuView()
.navigationBarItems(trailing: profileButton)
}
}
private var profileButton: some View {
Button(action: { }) {
Image(systemName: "person.crop.circle")
}
}
}
Then your menu view has NavigationLinks to the appropriate views:
struct MenuView: View {
var body: some View {
List {
link(icon: "calendar", label: "Appointments", destination: AppointmentListView())
link(icon: "list.bullet", label: "Work Order List", destination: WorkOrderListView())
link(icon: "rectangle.stack.person.crop", label: "Contacts", destination: ContactListView())
link(icon: "calendar", label: "My Calendar", destination: MyCalendarView())
}.navigationBarTitle(Text("Menu"), displayMode: .large)
}
private func link<Destination: View>(icon: String, label: String, destination: Destination) -> some View {
return NavigationLink(destination: destination) {
HStack {
Image(systemName: icon)
Text(label)
}
}
}
}
Your appointment list view also contains NavigationLinks to the appointment detail views:
struct AppointmentListView: View {
var body: some View {
List {
link(destination: AppointmentDetailView())
link(destination: AppointmentDetailView())
link(destination: AppointmentDetailView())
}.navigationBarTitle("Appointments")
}
private func link<Destination: View>(destination: Destination) -> some View {
NavigationLink(destination: destination) {
AppointmentView()
}
}
}
Result:

If you create the Menu View with NavigationLinks but don't declare it inside a NavigationView, you'll get the child view without NavigationBar.

Do not use NavigationView to wrap your list in the "AppointmentView"
e.g.
NavigationView{
List{
}
}
But only use List in the "AppointmentView"
List{
}
you can still use NavigationLink inside that List

Related

Interacting with a confirmationDialog or alert is causing the parent view to pop

When you navigate and open the confirmation dialog. When you select Yes, No or Cancel the page that the app was on is dismissed and it takes you back to the form on the previous page.
We also found this happens with alerts too.
It's a simple enough app structure, top level tabs then a menu which links to sub pages.
Here is a quick demo of the bug:
We put together an example app that demonstrates this.
How can we prevent this from happening while also maintaining the app structure?
import SwiftUI
#main
struct testApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
TabView() {
Form {
NavigationLink(destination: SubPage()) {
Image(systemName: "clock")
Text("Sub Page")
}
// This is where more menu options would be
}
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
struct SubPage: View {
#State private var confirmDialogVisible = false
var body: some View {
VStack {
Button{
confirmDialogVisible = true
} label: {
Text("popup")
}
}
.confirmationDialog("Confirm?", isPresented: $confirmDialogVisible) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
}
We are using XCode 14.1
And running on iOS 16.1
I usually use ViewModifer to keep consistency between tabs. One modifier for the root of the tab and one for the children.
///Modifier that uses `ToolbarViewModifier` and includes a `NavigationView`
struct NavigationViewModifier: ViewModifier{
func body(content: Content) -> some View {
NavigationView{
content
.modifier(ToolbarViewModifier())
}
}
}
///`toolbar` that can be used by the root view of the navigation
///and the children of the navigation
struct ToolbarViewModifier: ViewModifier{
let title: String = "Company Name"
func body(content: Content) -> some View {
content
.toolbar {
ToolbarItem(placement: .principal) {
VStack{
Image(systemName: "sparkles")
Text(title)
}
}
}
}
}
Then the Views use it something like this.
import SwiftUI
struct CustomTabView: View {
var body: some View {
TabView{
ForEach(0..<4){ n in
CustomChildView(title: n.description)
//Each tab gets a `NavigationView` and the shared toolbar
.modifier(NavigationViewModifier())
.tabItem {
Text(n.description)
}
}
}
}
}
struct CustomChildView: View {
let title: String
#State private var showConfirmation: Bool = false
var body: some View {
VStack{
Text(title)
NavigationLink {
CustomChildView(title: "\(title) :: \(UUID().uuidString)")
} label: {
Text("open child")
}
Button("show confirmation") {
showConfirmation.toggle()
}
.confirmationDialog("Confirm?", isPresented: $showConfirmation) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
//Each child uses the shared toolbar
.modifier(ToolbarViewModifier())
}
}
I stuck with NavigationView since that is what you have in your code but
if we take into consideration the new NavigationStack the possibilities of these two modifiers become exponentially better.
You can include custom back buttons that only appear if the path is not empty, return to the root from anywhere, etc.
Apple says that
Tab bars use bar items to navigate between mutually exclusive panes of content in the same view
Make sure the tab bar is visible when people navigate to different areas in your app
https://developer.apple.com/design/human-interface-guidelines/components/navigation-and-search/tab-bars/
Having the NavigationView or NavigationStack on top goes against these guidelines and therefore are the source of endless bugs, Especially when you take into consideration iPadOS.
Simple solution would be to use navigationDestination.
struct testApp: App {
#State var goToSubPage = false
var body: some Scene {
WindowGroup {
NavigationStack {
TabView() {
Form {
VStack {
Image(systemName: "clock")
Text("Sub Page")
}
.onTapGesture {
goToSubPage = true
}
// This is where more menu options would be
}
.navigationDestination(isPresented: $goToSubPage, destination: {
SubPage()
})
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
I tested it and it won't popped off itself anymore.

SwiftUI TabView with nested NavigationView

to summarize my problem:
My application contains a TabView on the root level. The children of the TabView must be searchable (using the apple native .searchable(...) modifier. Additionally at least one of the views must be able to push a third view on the stack which hides the TabBar on the bottom.
The problem with that is that both approaches: Using one single NavigationView in the root view and using additional navigation views inside the child views did not work.
Using only one NavigationView:
TabBar gets overlayed when navigating to another acitivty ✔
Not searchable ❌
Using nested NavigationViews:
TabBar is always visible also in the full screen activity ❌
Desired title + search behavior ✔
Root View:
public enum TestNavItemId: String, CaseIterable, Equatable {
case activity1 = "activity1"
case activity2 = "activity2"
public static func == (lhs: NavItemId, rhs: NavItemId) -> Bool {
lhs.rawValue == rhs.rawValue
}
}
struct DemoView: View {
var body: some View {
NavigationView {
TabView {
Activity1()
.tabItem {
Image(systemName: "house.fill")
Text(LocalizedStringKey(TestNavItemId.activity1.rawValue))
}
.tag(TestNavItemId.activity1)
Text("Nothing to see here")
.tabItem {
Image(systemName: "fire")
Text(LocalizedStringKey(TestNavItemId.activity2.rawValue))
}
.tag(TestNavItemId.activity2)
}
}
}
}
Activity1:
struct Activity1: View {
#State private var query: String = ""
var body: some View {
NavigationView {
VStack {
Text("Content")
NavigationLink(destination: Text("Full Screen View")) {
Text("Open full screen view")
}
}
.searchable(text: $query)
}
}
}
Any help is much appreciated!

How do I prevent the user from switching to certain tabs in a SwiftUI TabView

I am creating sort of a game in swift that gives the user a new clue every time they guess incorrectly. I would like to have a TabView for the clues, but I don’t want the player to have access to every clue at the start. I was thinking either locking the tabs in the TabView until a function is called or making an array of different views in the TabView that can be edited.
One note before starting my reply: in this community you need to be clear about the issue and show examples of your code, error messages, points where you get stuck. Your answer is quite generic, but I will give you some hints.
Each Tab is a view (almost) like any other one, it can be hidden conditionally.
You can just hide the tabs until the user reaches the point in the game where the clues must be shown. In addition, or in alternative, you can switch to different views to see each clue.
See an example below (there's room for improvement, this is the idea):
struct Tabs: View {
#State private var showTab2 = false // This will trigger the tab to be shown
// It can be stored in your view model
var body: some View {
TabView {
Example(showNextClue: $showTab2)
.tabItem {Label("Tab 1", systemImage: "checkmark")}
if showTab2 { // Tab 2 is hidden until you change the state variable
Tab2()
.tabItem {Label("Tab 2", systemImage: "flag")}
}
Example(showNextClue: $showTab2)
.tabItem {Label("Tab 3", systemImage: "trash")}
}
}
}
struct Tab2: View {
#State private var clueIndex = 0 // What clue will you show now?
var body: some View {
VStack {
switch clueIndex { // Show the applicable clue
// You can iterate through an array of views as you proposed, chose the best method
case 0:
Text("Now you can see this clue number 1")
case 1:
Text("Clue number 2")
default:
Text("Clue \(String(clueIndex))")
}
Button {
clueIndex += 1
} label: {
Text("Show next clue")
}
.padding()
}
}
}
struct Example: View {
#Binding var showNextClue: Bool
var body: some View {
VStack {
Text("This is open")
.padding()
Button {
showNextClue.toggle()
} label: {
Text(showNextClue ? "Now you can see the next clue" : "Click here to see your next clue")
}
}
}
}
Unfortunately you cannot disable the standard TabBar selectors. But you can do your own custom ones, e.g. like this:
struct ContentView: View {
#State private var currentTab = "Home"
#State private var activeTabs: Set<String> = ["Home"] // saves the activated tabs
var body: some View {
VStack {
TabView(selection: $currentTab) {
// Tab Home
VStack(spacing: 20) {
Text("Home Tab")
Button("Unlock Hint 1") { activeTabs.insert("Hint 1")}
Button("Unlock Hint 2") { activeTabs.insert("Hint 2")}
}
.tag("Home")
// Tab 1. Hint
VStack {
Text("Your first Hint")
}
.tag("Hint 1")
// Tab 2. Hint
VStack {
Text("Your Second Hint")
}
.tag("Hint 2")
}
// custom Tabbar buttons
Divider()
HStack {
OwnTabBarButton("Home", imageName: "house.fill")
OwnTabBarButton("Hint 1", imageName: "1.circle")
OwnTabBarButton("Hint 2", imageName: "2.circle")
}
}
}
func OwnTabBarButton(_ label: String, imageName: String) -> some View {
Button {
currentTab = label
} label: {
VStack {
Image(systemName: imageName)
Text(label)
}
}
.disabled(!activeTabs.contains(label))
.padding([.horizontal,.top])
}
}

SwiftUI NavigationView adding extra view onto stack when sheet is dismissed

I have a List with ForEach using a NavigationLink that when tapped displays a detail view. The DetailsView includes a sheet to save the detail information into an array. After the save, the sheet is dismissed but an additional DetailsView is put on the navigation stack, so that I need to tap the back link twice to get back to the listing.
I'm likely doing something incorrect as I'm relatively new to swiftui, but can't determine what.
Three things of interest:
In the ListView, I use .navigationViewStyle(StackNavigationViewStyle()). When removed, the issue goes away but the iPad gets messy for the ListView.
I'm using insert(at: 0) to add data in my array because I want the most recent data at the top of the listing. If I use append instead, the issue does go away. Wanting the most recently saved item at the top of the list, I add a sort, however sorting causes the duplicate issue to reappear.
The issue only seems to occur when selecting the first item created in the list (the last in the array) and then saving a new item into the array.
steps:
click Tap Here First, then tap SAVE, enter a name then click Save.
click tab bar item Saved.
click on the list item from step 1 in the Saved Items listing (nav bar should show "< Saved Items").
click SAVE, enter another name then click Save. At this point, the duplicate view appears with "< Back" as the leading nav bar item, clicking it takes you to the original detail view, then clicking "< Saved Items" takes you to the list view.
What am I doing wrong or what should I be doing better?
xcode 12.4/iOS 14.1
Stripped down code to reproduce:
struct TestModel: Identifiable, Codable {
private(set) var id: UUID
var name: String
}
class AppData: ObservableObject {
#Published var testList = [TestModel]()
}
struct NewView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: DetailView(item: TestModel(id: UUID(), name: ""))) {
Text("Tap here first")
}.navigationBarTitle("Main View", displayMode: .inline)
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ListView: View {
#EnvironmentObject var appData: AppData
var body: some View {
NavigationView {
List {
ForEach(appData.testList) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.name)
}
}
}
.navigationBarTitle("Saved Items", displayMode: .inline)
}
.navigationViewStyle(StackNavigationViewStyle()) // remove this and issue goes away, but iPad gets "messy".
}
}
struct DetailView: View {
#State private var isSaveShowing = false
#State var item: TestModel
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .center, spacing: 20) {
Text(item.name)
Button(action: {
isSaveShowing = true
}) {
Text("Save".uppercased())
}.sheet(isPresented: $isSaveShowing) {
SaveView(currentItem: item)
}
}
}
}
}
struct SaveView: View {
var currentItem: TestModel
#State private var name = ""
#EnvironmentObject var appData: AppData
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
Form {
Section(header: Text("Enter Name ".uppercased())
) {
TextField("Name (required)", text: $name)
}
}
.navigationBarItems(
trailing: Button(action: {
// appData.testList.append(TestModel(id: UUID(), name: name)) // using append instead of insert also resolves issue...
appData.testList.insert(TestModel(id: UUID(), name: name), at: 0)
presentationMode.wrappedValue.dismiss()
}) {
Text("Save")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ContentView: View {
var appData = AppData()
var body: some View {
TabView {
NewView().tabItem {
Image(systemName: "rectangle.stack.badge.plus")
Text("Calculate")
}
ListView().tabItem {
Image(systemName: "tray.and.arrow.down")
Text("Saved")
}
}
.environmentObject(appData)
}
}
Apparently, this must have been a bug in SwiftUI.
Running the same code using Xcode 12.5 beta 3 with iOS 14.5 the issue no longer occurs.

NavigationLink on macOS not opening in the same View

I'm currently building a macOS App with SwiftUI (no Catalyst) that is supposed to have a Sidebar and a single View to the right of it.
NavigationView {
List {
...
}
.listStyle(SidebarListStyle())
HomeView()
}
My Home View has a NavigationLink inside of it, pointing to another DetailView.
struct HomeView: View {
var body: some View {
NavigationLink("DetailView", destination: Text("This is the DV"))
}
}
This NavigationLink always appears as disabled and only works when I add another Column to the NavigationView. I don't want another Column, but rather the NavigationLink replacing the HomeView with the DetailView.
Is there any way to achieve this?
So apparently SwiftUI 2 does not support pushing views on to the Navigation Stack on macOS yet. However, I found this package that helps to resolve the issue:
github.com/lbrndnr/StackNavigationView
NavigationLinks always open a detail view from the current "column" of the navigation view, so if your navigation link is rendered as a child view of HomeView then it will try to open as a detail view of HomeView (the missing third column). If you want a view to replace the HomeView the intended way then the user should select something from the List. If you're trying to programmatically select a NavigationLink that already exists in your list then you should do so by changing state within the HomeView of a variable that is bound to the selected item of the list or the selection of the NavigationLink within the list. Here's a complete working example that does both within the ContentView.swift of a new MacOS SwiftUI Xcode project
struct HomeView: View {
#Binding var selectedItem: String?
var body: some View {
Button("Hello") {
selectedItem = "Thing2"
}
}
}
struct ContentView: View {
#State private var selectedItem: String?
private let items = ["Thing1", "Thing2"]
var body: some View {
NavigationView {
List(selection: $selectedItem) {
ForEach(items, id: \.self) {item in
NavigationLink(
destination: Text(item),
tag: item,
selection: $selectedItem
) {
Text(item)
}
}
}
HomeView(selectedItem: $selectedItem)
}
}
}
Here is the application displaying the HomeView
And here is the application displaying the selected thing after pressing the button
If you don't want to show anything selected in the list after navigating the view, but still want to use a navigation link to maintain the integrity of the navigation within the NavigationView then you could technically put a hidden navigation link in the list
NavigationView {
List(selection: $selectedItem) {
ForEach(items, id: \.self) {item in
NavigationLink(
destination: Text(item),
tag: item,
selection: $selectedItem
) {
Text(item)
}
}
NavigationLink(
destination: Text("Sneaky"),
tag: "Sneaky",
selection: $selectedItem
) {
Text("")
}.hidden()
}
HomeView(selectedItem: $selectedItem)
}
In that case make sure your button also selects the sneaky destination link
Button("Hello") {
selectedItem = "Sneaky"
}