I’ve created a small sample project in Swift Playgrounds to debug an issue I’ve encountered. This sample project contains the a primary ContentView with a single Text field and a button that opens Settings in a modal view.
When I open Settings and change the a setting via a picker, I would like to see the corresponding Text label change in my ContentView. In the current project, I’m using the #ObservableObject Type Alias to track the change, and I see that the setting changes correctly, but the view is not updated. If I restart the preview in Playgrounds, the view is updated with the changed setting. I would expect the Text label to change in real-time.
The code is as follows:
ContentView.swift
import SwiftUI
struct ContentView: View {
#ObservedObject var userSettings = UserSettings()
#State var isModal: Bool = false
var body: some View {
VStack {
Text("Setting: " + userSettings.pickerSetting)
.fontWeight(.semibold)
.font(.title)
Button(action: {
self.isModal = true
}) {
Image(systemName: "gear")
.font(.title)
}
.padding()
.foregroundColor(.white)
.background(Color.gray)
.cornerRadius(40)
.sheet(isPresented: $isModal, content: {
UserSettingsView()
})
.environmentObject(userSettings)
}
}
}
UserSettings.swift
import Foundation
class UserSettings: ObservableObject {
#Published var pickerSetting: String {
didSet {
UserDefaults.standard.set(pickerSetting, forKey: "pickerSetting")
}
}
public var pickerSettings = ["Setting 1", "Setting 2", "Setting 3"]
init() {
self.pickerSetting = UserDefaults.standard.object(forKey: "pickerSetting") as? String ?? "Setting 1"
}
}
UserSettingsView.swift
import SwiftUI
struct UserSettingsView: View {
#ObservedObject var userSettings = UserSettings()
var body: some View {
NavigationView {
Form {
Section(header: Text("")) {
Picker(selection: $userSettings.pickerSetting, label: Text("Picker Setting")) {
ForEach(userSettings.pickerSettings, id: \.self) { setting in
Text(setting)
}
}
}
}
.navigationBarTitle("Settings")
}
}
}
This happening because you have created two instances of UserSettings. One each in ContentView and UserSettingsView.
If you want to keep using .environmentObject(userSettings) the you need to use #EnvironmentObject var userSettings: UserSettings in UserSettingsView.
Otherwise you can drop the .environmentObject and use an #ObservedObject in UserSettingsView.
I'm seeing onChange(of: Bool) action tried to update multiple times per frame warnings when clicking on NavigationLinks in the sidebar for a SwiftUI macOS App.
Here's what I currently have:
import SwiftUI
#main
struct BazbarApp: App {
#StateObject private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}
class ModelData: ObservableObject {
#Published var myLinks = [URL(string: "https://google.com")!, URL(string: "https://apple.com")!, URL(string: "https://amazon.com")!]
}
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
NavigationLink(destination: DetailView(selected: $selected) ) {
Text(url.absoluteString)
}
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
Text("Choose a link")
}
}
}
struct DetailView: View {
#Binding var selected: URL?
var body: some View {
if let selected = selected {
Text("Currently selected: \(selected)")
}
else {
Text("Choose a link")
}
}
}
When I alternate clicking on the second and third links in the sidebar, I eventually start seeing the aforementioned warnings in my console.
Here's a gif of what I'm referring to:
Interestingly, the warning does not appear when alternating clicks between the first and second link.
Does anyone know how to fix this?
I'm using macOS 12.2.1 & Xcode 13.2.1.
Thanks in advance
I think the issue is that both the List(selection:) and the NavigationLink are trying to update the state variable selected at once. A List(selection:) and a NavigationLink can both handle the task of navigation. The solution is to abandon one of them. You can use either to handle navigation.
Since List look good, I suggest sticking with that. The NavigationLink can then be removed. The second view under NavigationView is displayed on the right, so why not use DetailView(selected:) there. You already made the selected parameter a binding variable, so the view will update if that var changes.
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
Text(url.absoluteString)
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
DetailView(selected: $selected)
}
}
}
I can recreate this problem with the simplest example I can think of so my guess is it's an internal bug in NavigationView.
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink("A", destination: Text("A"))
NavigationLink("B", destination: Text("B"))
NavigationLink("C", destination: Text("C"))
}
}
}
}
I have a simple View showing a list of 3 items. When the user taps on an item, it navigates to the next view. This works fine. However, I would like to also perform an action (set a variable in a View Model) when a list item is tapped.
Is this possible? Here's the code:
import SwiftUI
struct SportSelectionView: View {
#EnvironmentObject var workoutSession: WorkoutManager
let sports = ["Swim", "Bike", "Run"]
var body: some View {
List(sports, id: \.self) { sport in
NavigationLink(destination: ContentView().environmentObject(workoutSession)) {
Text(sport)
}
}.onAppear() {
// Request HealthKit store authorization.
self.workoutSession.requestAuthorization()
}
}
}
struct DisciplineSelectionView_Previews: PreviewProvider {
static var previews: some View {
SportSelectionView().environmentObject(WorkoutManager())
}
}
The easiest way I've found to get around this issue is to add an .onAppear call to the destination view of the NavigationLink. Technically, the action will happen when the ContentView() appears and not when the NavigationLink is clicked.. but the difference will be milliseconds and probably irrelevant.
NavigationLink(destination:
ContentView()
.environmentObject(workoutSession)
.onAppear {
// add action here
}
)
Here's a solution that is a little different than the onAppear approach. By creating your own Binding for isActive in the NavigationLink, you can introduce a side effect when it's set. I've implemented it here all within the view, but I would probably do this in an ObservableObject if I were really putting it into practice:
struct ContentView: View {
#State var _navLinkActive = false
var navLinkBinding : Binding<Bool> {
Binding<Bool> { () -> Bool in
return _navLinkActive
} set: { (newValue) in
if newValue {
print("Side effect")
}
_navLinkActive = newValue
}
}
var body: some View {
NavigationView {
NavigationLink(
destination: Text("Dest"),
isActive: navLinkBinding,
label: {
Text("Navigate")
})
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
As detailed here (on an iOS topic), the following code can be used to make a SwiftUI View dismiss itself:
#Environment(\.presentationMode) var presentationMode
// ...
presentationMode.wrappedValue.dismiss()
However, this approach doesn't work for a native (not Catalyst) macOS NavigationView setup (such as the below), where the selected view is displayed alongside the List.
Ideally, when any of these sub-views use the above, the list would go back to having nothing selected (like when it first launched); however, the dismiss function appears to do nothing: the view remains exactly the same.
Is this a bug, or expected macOS behaviour?
Is there another approach that can be used instead?
struct HelpView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination:
AboutAppView()
) {
Text("About this App")
}
NavigationLink(destination:
Text("Here’s a User Guide")
) {
Text("User Guide")
}
}
}
}
}
struct AboutAppView: View {
#Environment(\.presentationMode) var presentationMode
public var body: some View {
Button(action: {
self.dismissSelf()
}) {
Text("Dismiss Me!")
}
}
private func dismissSelf() {
presentationMode.wrappedValue.dismiss()
}
}
FYI: The real intent is for less direct scenarios (such as triggering from an Alert upon completion of a task); the button setup here is just for simplicity.
The solution here is simple. Do not use Navigation View where you need to dismiss the view.
Check the example given by Apple https://developer.apple.com/tutorials/swiftui/creating-a-macos-app
If you need dismissable view, there is 2 way.
Create a new modal window (This is more complicated)
Use sheet.
Following is implimenation fo sheet in macOS with SwiftUI
struct HelpView: View {
#State private var showModal = false
var body: some View {
NavigationView {
List {
NavigationLink(destination:
VStack {
Button("About"){ self.showModal.toggle() }
Text("Here’s a User Guide")
}
) {
Text("User Guide")
}
}
}
.sheet(isPresented: $showModal) {
AboutAppView(showModal: self.$showModal)
}
}
}
struct AboutAppView: View {
#Binding var showModal: Bool
public var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Dismiss Me!")
}
}
}
There is also a 3rd option to use ZStack to create a Modal Card in RootView and change opacity to show and hide with dynamic data.
I was playing around with SwiftUI and want to be able to come back to the previous view when tapping a button, the same we use popViewController inside a UINavigationController.
Is there a provided way to do it so far ?
I've also tried to use NavigationDestinationLink to do so without success.
struct AView: View {
var body: some View {
NavigationView {
NavigationButton(destination: BView()) {
Text("Go to B")
}
}
}
}
struct BView: View {
var body: some View {
Button(action: {
// Trying to go back to the previous view
// previously: navigationController.popViewController(animated: true)
}) {
Text("Come back to A")
}
}
}
Modify your BView struct as follows. The button will perform just as popViewController did in UIKit.
struct BView: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var body: some View {
Button(action: { self.mode.wrappedValue.dismiss() })
{ Text("Come back to A") }
}
}
Use #Environment(\.presentationMode) var presentationMode to go back previous view. Check below code for more understanding.
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
ZStack {
Color.gray.opacity(0.2)
NavigationLink(destination: NextView(), label: {Text("Go to Next View").font(.largeTitle)})
}.navigationBarTitle(Text("This is Navigation"), displayMode: .large)
.edgesIgnoringSafeArea(.bottom)
}
}
}
struct NextView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
ZStack {
Color.gray.opacity(0.2)
}.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: { Image(systemName: "arrow.left") }))
.navigationBarTitle("", displayMode: .inline)
}
}
struct NameRow: View {
var name: String
var body: some View {
HStack {
Image(systemName: "circle.fill").foregroundColor(Color.green)
Text(name)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
With State Variables. Try that.
struct ContentViewRoot: View {
#State var pushed: Bool = false
var body: some View {
NavigationView{
VStack{
NavigationLink(destination:ContentViewFirst(pushed: self.$pushed), isActive: self.$pushed) { EmptyView() }
.navigationBarTitle("Root")
Button("push"){
self.pushed = true
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ContentViewFirst: View {
#Binding var pushed: Bool
#State var secondPushed: Bool = false
var body: some View {
VStack{
NavigationLink(destination: ContentViewSecond(pushed: self.$pushed, secondPushed: self.$secondPushed), isActive: self.$secondPushed) { EmptyView() }
.navigationBarTitle("1st")
Button("push"){
self.secondPushed = true;
}
}
}
}
struct ContentViewSecond: View {
#Binding var pushed: Bool
#Binding var secondPushed: Bool
var body: some View {
VStack{
Spacer()
Button("PopToRoot"){
self.pushed = false
} .navigationBarTitle("2st")
Spacer()
Button("Pop"){
self.secondPushed = false
} .navigationBarTitle("1st")
Spacer()
}
}
}
This seems to work for me on watchOS (haven't tried on iOS):
#Environment(\.presentationMode) var presentationMode
And then when you need to pop
self.presentationMode.wrappedValue.dismiss()
There is now a way to programmatically pop in a NavigationView, if you would like. This is in beta 5.
Notice that you don't need the back button. You could programmatically trigger the showSelf property in the DetailView any way you like. And you don't have to display the "Push" text in the master. That could be an EmptyView(), thereby creating an invisible segue.
(The new NavigationLink functionality takes over the deprecated NavigationDestinationLink)
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
MasterView()
}
}
}
struct MasterView: View {
#State var showDetail = false
var body: some View {
VStack {
NavigationLink(destination: DetailView(showSelf: $showDetail), isActive: $showDetail) {
Text("Push")
}
}
}
}
struct DetailView: View {
#Binding var showSelf: Bool
var body: some View {
Button(action: {
self.showSelf = false
}) {
Text("Pop")
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
It seems that a ton of basic navigation functionality is super buggy, which is disappointing and may be worth walking away from for now to save hours of frustration. For me, PresentationButton is the only one that works. TabbedView tabs don't work properly, and NavigationButton doesn't work for me at all. Sounds like YMMV if NavigationButton works for you.
I'm hoping that they fix it at the same time they fix autocomplete, which would give us much better insight as to what is available to us. In the meantime, I'm reluctantly coding around it and keeping notes for when fixes come out. It sucks to have to figure out if we're doing something wrong or if it just doesn't work, but that's beta for you!
Update: the NavigationDestinationLink API in this solution has been deprecated as of iOS 13 Beta 5. It is now recommended to use NavigationLink with an isActive binding.
I figured out a solution for programmatic pushing/popping of views in a NavigationView using NavigationDestinationLink.
Here's a simple example:
import Combine
import SwiftUI
struct DetailView: View {
var onDismiss: () -> Void
var body: some View {
Button(
"Here are details. Tap to go back.",
action: self.onDismiss
)
}
}
struct MainView: View {
var link: NavigationDestinationLink<DetailView>
var publisher: AnyPublisher<Void, Never>
init() {
let publisher = PassthroughSubject<Void, Never>()
self.link = NavigationDestinationLink(
DetailView(onDismiss: { publisher.send() }),
isDetail: false
)
self.publisher = publisher.eraseToAnyPublisher()
}
var body: some View {
VStack {
Button("I am root. Tap for more details.", action: {
self.link.presented?.value = true
})
}
.onReceive(publisher, perform: { _ in
self.link.presented?.value = false
})
}
}
struct RootView: View {
var body: some View {
NavigationView {
MainView()
}
}
}
I wrote about this in a blog post here.
You can also do it with .sheet
.navigationBarItems(trailing: Button(action: {
self.presentingEditView.toggle()
}) {
Image(systemName: "square.and.pencil")
}.sheet(isPresented: $presentingEditView) {
EditItemView()
})
In my case I use it from a right navigation bar item, then you have to create the view (EditItemView() in my case) that you are going to display in that modal view.
https://developer.apple.com/documentation/swiftui/view/sheet(ispresented:ondismiss:content:)
EDIT: This answer over here is better than mine, but both work: SwiftUI dismiss modal
What you really want (or should want) is a modal presentation, which several people have mentioned here. If you go that path, you definitely will need to be able to programmatically dismiss the modal, and Erica Sadun has a great example of how to do that here: https://ericasadun.com/2019/06/16/swiftui-modal-presentation/
Given the difference between declarative coding and imperative coding, the solution there may be non-obvious (toggling a bool to false to dismiss the modal, for example), but it makes sense if your model state is the source of truth, rather than the state of the UI itself.
Here's my quick take on Erica's example, using a binding passed into the TestModal so that it can dismiss itself without having to be a member of the ContentView itself (as Erica's is, for simplicity).
struct TestModal: View {
#State var isPresented: Binding<Bool>
var body: some View {
Button(action: { self.isPresented.value = false }, label: { Text("Done") })
}
}
struct ContentView : View {
#State var modalPresented = false
var body: some View {
NavigationView {
Text("Hello World")
.navigationBarTitle(Text("View"))
.navigationBarItems(trailing:
Button(action: { self.modalPresented = true }) { Text("Show Modal") })
}
.presentation(self.modalPresented ? Modal(TestModal(isPresented: $modalPresented)) {
self.modalPresented.toggle()
} : nil)
}
}
Below works for me in XCode11 GM
self.myPresentationMode.wrappedValue.dismiss()
instead of NavigationButton use Navigation DestinationLink
but You should import Combine
struct AView: View {
var link: NavigationDestinationLink<BView>
var publisher: AnyPublisher<Void, Never>
init() {
let publisher = PassthroughSubject<Void, Never>()
self.link = NavigationDestinationLink(
BView(onDismiss: { publisher.send() }),
isDetail: false
)
self.publisher = publisher.eraseToAnyPublisher()
}
var body: some View {
NavigationView {
Button(action:{
self.link.presented?.value = true
}) {
Text("Go to B")
}.onReceive(publisher, perform: { _ in
self.link.presented?.value = false
})
}
}
}
struct BView: View {
var onDismiss: () -> Void
var body: some View {
Button(action: self.onDismiss) {
Text("Come back to A")
}
}
}
In the destination pass the view you want to redirect, and inside block pass data you to pass in another view.
NavigationLink(destination: "Pass the particuter View") {
Text("Push")
}