Show different views from NavigationBarItem menu in SwiftUI - ios

I am trying to show a different view based on the option chosen in the NavigationBar menu. I am getting stuck on the best way to do this.
First, based on my current approach (I think it is not right!), I get a message in Xcode debugger when I press the menu item:
SideMenu[16587:1131441] [UILog] Called -[UIContextMenuInteraction
updateVisibleMenuWithBlock:] while no context menu is visible. This
won't do anything.
How do I fix this?
Second, When I select an option from the menu, how do I reset the bool so that it does not get executed unless it is chosen from the menu again. Trying to reset as self.showNewView = false within the if condition gives a compiler error
Here is a full executable sample code I am trying to work with. Appreciate any help in resolving this. Thank you!
struct ContentView: View {
#State var showNewView = false
#State var showAddView = false
#State var showEditView = false
#State var showDeleteView = false
var body: some View {
NavigationView {
GeometryReader { g in
VStack {
if self.showAddView {
AddView()
}
if self.showNewView {
NewView()
}
if self.showEditView {
EditView()
}
if self.showDeleteView {
DeleteView()
}
}.frame(width: g.size.width, height: g.size.height)
}
.navigationTitle("Title")
.navigationBarItems(leading: {
Menu {
Button(action: {showNewView.toggle()}) {
Label("New", systemImage: "pencil")
}
Button(action: {showEditView.toggle()}) {
Label("Edit", systemImage: "square.and.pencil")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}(), trailing: {
Menu {
Button(action: {showAddView.toggle()}) {
Label("Add", systemImage: "plus")
}
Button(action: {showDeleteView.toggle()}) {
Label("Delete", systemImage: "trash")
}
} label: {
Image(systemName: "plus")
}
}())
}
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct NewView: View {
var body: some View {
GeometryReader { g in
Text("This is New View")
}
.background(Color.red)
}
}
struct EditView: View {
var body: some View {
GeometryReader { g in
Text("This is Edit View")
}
.background(Color.green)
}
}
struct AddView: View {
var body: some View {
GeometryReader { g in
Text("This is Add View")
}
.background(Color.orange)
}
}
struct DeleteView: View {
var body: some View {
GeometryReader { g in
Text("This is Delete View")
}
.background(Color.purple)
}
}
Here is what I get when I select each of the menu items. I would like to be able to show only one menu item. Essentially dismiss the other one when a new menu item is selected

A possible solution is to use a dedicated enum for your current view (instead of four #State properties):
enum CurrentView {
case new, add, edit, delete
}
#State var currentView: CurrentView?
Note that you can also extract parts of code to computed properties.
Here is a full code:
enum CurrentView {
case new, add, edit, delete
}
struct ContentView: View {
#State var currentView: CurrentView?
var body: some View {
NavigationView {
GeometryReader { g in
content
.frame(width: g.size.width, height: g.size.height)
}
.navigationTitle("Title")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: leadingBarItems, trailing: trailingBarItems)
}
.navigationViewStyle(StackNavigationViewStyle())
}
#ViewBuilder
var content: some View {
if let currentView = currentView {
switch currentView {
case .add:
AddView()
case .new:
NewView()
case .edit:
EditView()
case .delete:
DeleteView()
}
}
}
var leadingBarItems: some View {
Menu {
Button(action: { currentView = .new }) {
Label("New", systemImage: "pencil")
}
Button(action: { currentView = .edit }) {
Label("Edit", systemImage: "square.and.pencil")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
var trailingBarItems: some View {
Menu {
Button(action: { currentView = .add }) {
Label("Add", systemImage: "plus")
}
Button(action: { currentView = .delete }) {
Label("Delete", systemImage: "trash")
}
} label: {
Image(systemName: "plus")
}
}
}

Related

SwiftUI - Update name of the button to what parent view has on it

I have ImageSettingView which Im using multiple places. When I used this view at multiple place in my project it takes static name all time in the navigationBarItem i.e Text("Image Collab View"). I want to give dynamic name change based on where parent view it comes from.
ImageColorView -> ImageSettingView (Back NavButon text - ImageColor ) ProfilePictureView -> ImageSettingView (Back NavButon text - ProfilePictureView )
Sample code of ImageSettingView :-
struct ImageSettingView : View {
var body: some View {
GeometryReader { geometry in
VStack {
Form {
Text("Image View")
}
.navigationBarBackButtonHidden(true)
.navigationBarItems(
leading:
Button(action: {
onUserImage()
}) {
HStack {
Image(systemName: "backward")
Text("Image")
// I want to use change above text based on what name of the parent view was
}
},
trailing:
Button(action: {
onDelete()
}) {
HStack {
Text("Delete")
}
}
)
}
}
}
Sample image of mock up
Since you always know what the parent view is, try this approach, passing the name of the parent view to your ImageSettingView.
struct ImageSettingView: View {
let parentName: String // <-- here
//...
Image(systemName: "backward")
Text(parentName) // <-- here
//...
and call it like this:
ImageSettingView(parentName: "TestView")
or
ImageSettingView(parentName: "ProfilePictureView")
or
ImageSettingView(parentName: "\(Self.self)")
EDIT-1: this is the test code I used to show my answer works:
struct ContentView: View {
var body: some View {
NavigationStack {
VStack(spacing: 50) {
Text("This is ContentView")
NavigationLink("To ProfileView", destination: ProfileView(parentName: "ContentView"))
}
}
}
}
struct ProfileView: View {
let parentName: String
var body: some View {
VStack(spacing: 50) {
Text("This is ProfileView")
ImageSettingView(parentName: parentName)
NavigationLink("To Next View ", destination: NextView(parentName: "\(Self.self)"))
}
}
}
struct NextView: View {
let parentName: String
var body: some View {
VStack(spacing: 50) {
Text("This is NextView")
ImageSettingView(parentName: parentName)
}
}
}
struct ImageSettingView: View {
#Environment(\.dismiss) var dismiss
let parentName: String // <--here
var body: some View {
GeometryReader { geometry in
VStack {
Form {
Text("Image View")
}
.navigationBarBackButtonHidden(true)
.navigationBarItems(
leading:
Button(action: {
// onUserImage()
dismiss()
}) {
HStack {
Image(systemName: "backward")
Text(parentName) // <--here
}
},
trailing:
Button(action: {
// onDelete()
}) {
HStack {
Text("Delete")
}
}
)
}
}
}
}

Presenting a modal view sheet from a Sub view

I am trying to present a sheet from a sub view selected from the menu item on the navigation bar but the modal Sheet does does not display. I spent a few days trying to debug but could not pin point the problem.
I am sorry, this is a little confusing and will show a simplified version of the code to reproduce. But in a nutshell, the problem seems to be a sheet view that I have as part of the main view. Removing the sheet code from the main view displays the sheet from the sub view. Unfortunately, I don't have the freedom to change the Mainview.swift
Let me show some code to make it easy to understand....
First, before showing the code, the steps to repeat the problem:
click on the circle with 3 dots in the navigation bar
select the second item (Subview)
click on the "Edit Parameters" button and the EditParameters() view will not display
ContentView.swift (just calls the Mainview()). Included code to copy for reproducing issue :-)
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
Mainview()
}
}
}
}
Mainview.swift. This is a simplified version of the actual App which is quite complex and I don't have leeway to change much here unfortunately!
fileprivate enum CurrentView {
case summary, sub
}
enum OptionSheet: Identifiable {
var id: Self {self}
case add
}
struct Mainview: View {
#State private var currentView: CurrentView? = .summary
#State private var showSheet: OptionSheet? = nil
var body: some View {
GeometryReader { g in
content.frame(width: g.size.width, height: g.size.height)
.navigationBarTitle("Main", displayMode: .inline)
}
//Removing the below sheet view will display the sheet from the subview but with this sheet here, it the sheet from subview does not work. This is required as these action items are accessed from the second menu item (circle and arrow) navigation baritem
.sheet(item: $showSheet, content: { mode in
sheetContent(for: mode)
})
.toolbar {
HStack {
trailingBarItems
actionItems
}
}
}
var actionItems: some View {
Menu {
Button(action: {
showSheet = .add
}) {
Label("Add Elements", systemImage: "plus")
}
} label: {
Image(systemName: "cursorarrow.click").resizable()
}
}
var trailingBarItems: some View {
Menu {
Button(action: {currentView = .summary}) {
Label("Summary", systemImage: "list.bullet.rectangle")
}
Button(action: {currentView = .sub}) {
Label("Subview", systemImage: "circle")
}
} label: {
Image(systemName: "ellipsis.circle").resizable()
}
}
#ViewBuilder
func sheetContent(for mode: OptionSheet) -> some View {
switch mode {
case .add:
AddElements()
}
}
#ViewBuilder
var content: some View {
if let currentView = currentView {
switch currentView {
case .summary:
SummaryView()
case .sub:
SubView()
}
}
}
}
Subview.swift. This is the file that contains the button "Edit Parameters" which does not display the sheet. I am trying to display the sheet from this view.
struct SubView: View {
#State private var editParameters: Bool = false
var body: some View {
VStack {
Button(action: {
editParameters.toggle()
}, label: {
HStack {
Image(systemName: "square.and.pencil")
.font(.headline)
Text("Edit Parameters")
.fontWeight(.semibold)
.font(.headline)
}
})
.padding(10)
.foregroundColor(Color.white)
.background(Color(.systemBlue))
.cornerRadius(20)
.sheet(isPresented: $editParameters, content: {
EditParameterView()
})
.padding()
Text("Subview....")
}
.padding()
}
}
EditParameters.swift. This is the view it should display when the Edit Parameters button is pressed
struct EditParameterView: View {
var body: some View {
Text("Edit Parameters...")
}
}
Summaryview.swift. Nothing special here. just including for completeness
struct SummaryView: View {
var body: some View {
Text("Summary View")
}
}
In SwiftUI, you can't have 2 .sheet() modifiers on the same hierarchy. Here, the first .sheet() modifier is on one of the parent views to the second .sheet(). The easy solution is to move one of the .sheets() so it's own hierarchy.
You could either use ZStacks:
var body: some View {
ZStack {
GeometryReader { g in
content.frame(width: g.size.width, height: g.size.height)
.navigationBarTitle("Main", displayMode: .inline)
}
ZStack{ }
.sheet(item: $showSheet, content: { mode in
sheetContent(for: mode)
})
}
.toolbar {
HStack {
trailingBarItems
actionItems
}
}
}
or more elegantly:
var body: some View {
GeometryReader { g in
content.frame(width: g.size.width, height: g.size.height)
.navigationBarTitle("Main", displayMode: .inline)
}
.background(
ZStack{ }
.sheet(item: $showSheet, content: { mode in
sheetContent(for: mode)
})
)
.toolbar {
HStack {
trailingBarItems
actionItems
}
}
}

Presenting Sheet modally in SwiftUI

I am trying to present a modal sheet upon selecting the menu item in the navigation bar. But, the sheet is not displayed. Upon debugging I noticed that the state variable showSheet is not getting updated and I am sort of lost as to why it is not updating.
Any help is very much appreciated. Thank you!
There is another post (#State not updating in SwiftUI 2) that has a similar issue. Is this a bug in SwiftUI?
Below is a full sample
I have a fileprivate enum that defines two cases for the views - add and edit
fileprivate enum SheetView {
case add, edit
}
Below is the ContentView. The ContentView declares two #State variables that are set based on the menu item selected
The menu items (var actionItems) are on the NavigationView and has menu with 2 buttons - Add and Edit. Each button has an action set to toggle the showSheetView and the showSheet variables. The content is presented based on which item is selected. Content is built using #ViewBuilder
struct ContentView: View {
#State private var showSheetView = false
#State private var showSheet: SheetView? = nil
var body: some View {
GeometryReader { g in
NavigationView {
Text("Main Page")
.padding()
.navigationBarTitle("Main Page")
.navigationBarItems(trailing: actionItems)
}
}.sheet(isPresented: $showSheetView) {
content
}
}
var actionItems: some View {
Menu {
Button(action: {
showSheet = .add
showSheetView.toggle()
}) {
Label("Add Asset", systemImage: "plus")
}
Button(action: {
showSheet = .edit
showSheetView.toggle()
}) {
Label("Edit Asset", systemImage: "minus")
}
} label: {
Image(systemName: "dot.circle.and.cursorarrow").resizable()
}
}
#ViewBuilder
var content: some View {
if let currentView = showSheet {
switch currentView {
case .add:
AddAsset(showSheetView: $showSheetView)
case .edit:
EditAsset(showSheetView: $showSheetView)
}
}
}
}
Below are the two Views - AddAsset and EditAsset
struct AddAsset: View {
#Binding var showSheetView: Bool
var body: some View {
NavigationView {
Text("Add Asset")
.navigationBarTitle(Text("Add"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
print("Dismissing sheet view...")
self.showSheetView = false
}) {
Text("Done").bold()
})
}
}
}
struct EditAsset: View {
#Binding var showSheetView: Bool
var body: some View {
NavigationView {
Text("Edit Asset")
.navigationBarTitle(Text("Edit"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
print("Dismissing sheet view...")
self.showSheetView = false
}) {
Text("Done").bold()
})
}
}
}
The solution is to use sheet(item: variant.
Here is fixed code (there are many changes so all components included). Tested with Xcode 12.1 / iOS 14.1
enum SheetView: Identifiable {
var id: Self { self }
case add, edit
}
struct ContentView: View {
#State private var showSheet: SheetView? = nil
var body: some View {
GeometryReader { g in
NavigationView {
Text("Main Page")
.padding()
.navigationBarTitle("Main Page")
.navigationBarItems(trailing: actionItems)
}
}.sheet(item: $showSheet) { mode in
content(for: mode)
}
}
var actionItems: some View {
Menu {
Button(action: {
showSheet = .add
}) {
Label("Add Asset", systemImage: "plus")
}
Button(action: {
showSheet = .edit
}) {
Label("Edit Asset", systemImage: "minus")
}
} label: {
Image(systemName: "dot.circle.and.cursorarrow").resizable()
}
}
#ViewBuilder
func content(for mode: SheetView) -> some View {
switch mode {
case .add:
AddAsset(showSheet: $showSheet)
case .edit:
EditAsset(showSheet: $showSheet)
}
}
}
struct AddAsset: View {
#Binding var showSheet: SheetView?
var body: some View {
NavigationView {
Text("Add Asset")
.navigationBarTitle(Text("Add"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
print("Dismissing sheet view...")
self.showSheet = nil
}) {
Text("Done").bold()
})
}
}
}
struct EditAsset: View {
#Binding var showSheet: SheetView?
var body: some View {
NavigationView {
Text("Edit Asset")
.navigationBarTitle(Text("Edit"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
print("Dismissing sheet view...")
self.showSheet = nil
}) {
Text("Done").bold()
})
}
}
}

Show leading navigationBarItems button only if shown as a modal

I have a view that can be shown either as a modal, or simply pushed onto a navigation stack. When it's pushed, it has the back button in the top left, and when it's shown as a modal, I want to add a close button (many of my testers were not easily able to figure out that they could slide down the modal and really expected an explicit close button).
Now, I have multiple problems.
How do I figure out if a View is shown modally or not? Or alternatively, if it's not the first view on a navigation stack? In UIKit there are multiple ways to easily do this. Adding a presentationMode #Environment variable doesn't help, because its isPresented value is also true for pushed screens. I could of course pass in a isModal variable myself but it seems weird that's the only way?
How do I conditionally add a leading navigationBarItem? The problem is that if you give nil, even the default back button is hidden.
Code to copy and paste into Xcode and play around with:
import SwiftUI
struct ContentView: View {
#State private var showModal = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.showModal = true
}
NavigationLink("Push", destination: DetailView(isModal: false))
}
.navigationBarTitle("Home")
}
.sheet(isPresented: $showModal) {
NavigationView {
DetailView(isModal: true)
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) private var presentationMode
let isModal: Bool
var body: some View {
Text("Hello World")
.navigationBarTitle(Text("Detail"), displayMode: .inline)
.navigationBarItems(leading: closeButton, trailing: deleteButton)
}
private var closeButton: some View {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Image(systemName: "xmark")
.frame(height: 36)
}
}
private var deleteButton: some View {
Button(action: { print("DELETE") }) {
Image(systemName: "trash")
.frame(height: 36)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If I change closeButton to return an optional AnyView? and then return nil when isModal is false, I don't get a back button at all. I also can't call navigationBarItems twice, once with a leading and once with a trailing button, because the latter call overrides the first call. I'm kinda stuck here.
Okay, I managed it. It's not pretty and I am very much open to different suggestions, but it works 😅
import SwiftUI
extension View {
func eraseToAnyView() -> AnyView {
AnyView(self)
}
public func conditionalNavigationBarItems(_ condition: Bool, leading: AnyView, trailing: AnyView) -> some View {
Group {
if condition {
self.navigationBarItems(leading: leading, trailing: trailing)
} else {
self
}
}
}
}
struct ContentView: View {
#State private var showModal = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.showModal = true
}
NavigationLink("Push", destination: DetailView(isModal: false))
}
.navigationBarTitle("Home")
}
.sheet(isPresented: $showModal) {
NavigationView {
DetailView(isModal: true)
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) private var presentationMode
let isModal: Bool
var body: some View {
Text("Hello World")
.navigationBarTitle(Text("Detail"), displayMode: .inline)
.navigationBarItems(trailing: deleteButton)
.conditionalNavigationBarItems(isModal, leading: closeButton, trailing: deleteButton)
}
private var closeButton: AnyView {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Image(systemName: "xmark")
.frame(height: 36)
}.eraseToAnyView()
}
private var deleteButton: AnyView {
Button(action: { print("DELETE") }) {
Image(systemName: "trash")
.frame(height: 36)
}.eraseToAnyView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I don't see any trouble, just add Dismiss button to your navigation bar. You only have to rearrange your View hierarchy and there is no need to pass any binding to your DetailView
import SwiftUI
struct DetailView: View {
var body: some View {
Text("Detail View")
}
}
struct ContentView: View {
#State var sheet = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.sheet = true
}
NavigationLink("Push", destination: DetailView())
}.navigationBarTitle("Home")
}
.sheet(isPresented: $sheet) {
NavigationView {
DetailView().navigationBarTitle("Title").navigationBarItems(leading: Button(action: {
self.sheet.toggle()
}, label: {
Text("Dismiss")
}))
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You still can dismiss it with swipe down, you can add some buttons (as part of DetailView declaration) ... etc.
When pushed, you have default back button, if shown modaly, you have dismiss
button indeed.
UPDATE (based od discussion)
.sheet(isPresented: $sheet) {
NavigationView {
GeometryReader { proxy in
DetailView().navigationBarTitle("Title")
.navigationBarItems(leading:
HStack {
Button(action: {
self.sheet.toggle()
}, label: {
Text("Dismiss").padding(.horizontal)
})
Color.clear
Button(action: {
}, label: {
Image(systemName: "trash")
.imageScale(.large)
.padding(.horizontal)
})
}.frame(width: proxy.size.width)
)
}
}
}
finally I suggest you to use
extension View {
#available(watchOS, unavailable)
public func navigationBarItems<L, T>(leading: L?, trailing: T) -> some View where L : View, T : View {
Group {
if leading != nil {
self.navigationBarItems(leading: leading!, trailing: trailing)
} else {
self.navigationBarItems(trailing: trailing)
}
}
}
}
Whenever we provide .navigationBarItems(leading: _anything_), ie anything, the standard back button has gone, so you have to provide your own back button conditionally.
The following approach works (tested with Xcode 11.2 / iOS 13.2)
.navigationBarItems(leading: Group {
if isModal {
closeButton
} else {
// custom back button here calling same dismiss
}
}, trailing: deleteButton)
Update: alternate approach might be as follows (tested in same)
var body: some View {
VStack {
if isModal {
Text("Hello")
.navigationBarItems(leading: closeButton, trailing: deleteButton)
} else {
Text("Hello")
.navigationBarItems(trailing: deleteButton)
}
}
.navigationBarTitle("Test", displayMode: .inline)
}

SwiftUI How to push to next screen when tapping on Button

I can navigate to next screen by using NavigationButton (push) or present with PresentationButton (present) but i want to push when i tap on Buttton()
Button(action: {
// move to next screen
}) {
Text("See More")
}
is there a way to do it?
You can do using NavigationLink
Note: Please try in real device. in simulator sometimes not work properly.
struct MasterView: View {
#State var selection: Int? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailsView(), tag: 1, selection: $selection) {
Button("Press me") {
self.selection = 1
}
}
}
}
}
}
struct DetailsView: View {
#Environment(\.presentationMode) var presentation
var body: some View {
Group {
Button("Go Back") {
self.presentation.wrappedValue.dismiss()
}
}
}
}
As you can see to display the new view, add the NavigationLink with isActive: $pushView using <.hidden()> to hide the navigation "arrow".
Next add Text("See More") with tapGesture to make the text respond to taps. The variable pushView will change (false => true) when you click "See More" text.
import SwiftUI
struct ContentView: View {
#State var pushView = false
var body: some View {
NavigationView {
List {
HStack{
Text("test")
Spacer()
NavigationLink(destination: NewView(), isActive: $pushView) {
Text("")
}.hidden()
.navigationBarTitle(self.pushView ? "New view" : "default view")
Text("See More")
.padding(.trailing)
.foregroundColor(Color.blue)
.onTapGesture {
self.pushView.toggle()
}
}
}
}
}
}
struct NewView: View {
var body: some View {
Text("New View")
}
}
ContentView picture
NewView picture
To tap on button and navigate to next screen,You can use NavigationLink like below
NavigationView{
NavigationLink(destination: SecondView()) {
Text("Login")
.padding(.all, 5)
.frame(minWidth: 0, maxWidth: .infinity,maxHeight: 45, alignment: .center)
.foregroundColor(Color.white)
}
}
You can use NavigationLink to implement this:
struct DetailsView: View {
var body: some View {
VStack {
Text("Hello world")
}
}
}
struct ContentView: View {
#State var selection: Int? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailsView(), tag: 1, selection: $selection) {
Button("Press me") {
self.selection = 1
}
}
}
}
}
}

Resources