SwiftUI NavigationLink isActive from view model - swiftui

I have a MVVM SwiftUI app that will navigate to another view based on the value of a #Published property of a view model:
class ViewModel: ObservableObject {
#Published public var showView = false
func doShowView() {
showView = true
}
}
struct MyView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
MySubView().environmentObject(viewModel)
}
}
}
struct MySubView: View {
#EnvironmentObject private var viewModel: ViewModel
var body: some View {
VStack {
Button(action: {
viewModel.doShowView()
}) {
Text("Button")
}
NavigationLink(
destination: SomeOtherView(),
isActive: $viewModel.showView,
label: {
EmptyView()
})
}
}
}
The problem is sometimes when I run the app it will work only every other time and sometimes it works perfectly as expected.
The cause seems to be that sometimes when the property is set in the view model (in doShowView()) SwiftUI will immediately render my view with the old value of showView and in the working case the view is rendered on the next event cycle with the updated value.
Is this a feature (due to the fact #Published is calling objectWillChange under the hood and the view is rendering due to that) or a bug?
If it is a feature (and I just happen to get lucky when it works as I want it to) what is the best way to guarantee it renders my view after the new value is set?
Note this is only a simple example, I cannot use a #State variable in the button action since in the real code the doShowView() method may or may not set the showView property in the view model.

The issue here is that SwiftUI creates the SomeOtherView beforehand. Then, this view is not related with the viewModel in any way, so it's not re-created when viewModel.showView changes.
A possible solution is to make SomeOtherView depend on the viewModel - e.g. by explicitly injecting the environmentObject:
struct MySubView: View {
#EnvironmentObject private var viewModel: ViewModel
var body: some View {
VStack {
Button(action: {
viewModel.doShowView()
}) {
Text("Button")
}
NavigationLink(
destination: SomeOtherView().environmentObject(viewModel),
isActive: $viewModel.showView,
label: {
EmptyView()
}
)
}
}
}

I came upon a working solution. I did add a #State variable and set it by explictly watching for changes of showView:
class ViewModel: ObservableObject {
#Published public var showView = false
var disposables = Set<AnyCancellable>()
func doShowView() {
showView = true
}
}
struct MyView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
MySubView().environmentObject(viewModel)
}
}
}
struct MySubView: View {
#EnvironmentObject private var viewModel: ViewModel
#State var showViewLink = false
var body: some View {
VStack {
Button(action: {
viewModel.doShowView()
}) {
Text("Button")
}
NavigationLink(
destination: SomeOtherView(),
isActive: $showViewLink,
label: {
EmptyView()
})
}
.onAppear {
viewModel.$showView
.sink(receiveValue: { showView in
showViewLink = showView
})
.store(in: &viewModel.disposables)
}
}
}

Related

SwiftUI publishing an environment change from within view update

The app has a model that stores the user's current preference for light/dark mode, which the user can change by clicking on a button:
class DataModel: ObservableObject {
#Published var mode: ColorScheme = .light
The ContentView's body tracks the model, and adjusts the colorScheme when the model changes:
struct ContentView: View {
#StateObject private var dataModel = DataModel()
var body: some View {
NavigationStack(path: $path) { ...
}
.environmentObject(dataModel)
.environment(\.colorScheme, dataModel.mode)
As of Xcode Version 14.0 beta 5, this is producing a purple warning: Publishing changes from within view updates is not allowed, this will cause undefined behavior. Is there another way to do this? Or is it a hiccup in the beta release? Thanks!
Update: 2022-09-28
Xcode 14.1 Beta 3 (finally) fixed the "Publishing changes from within view updates is not allowed, this will cause undefined behavior"
See: https://www.donnywals.com/xcode-14-publishing-changes-from-within-view-updates-is-not-allowed-this-will-cause-undefined-behavior/
Full disclosure - I'm not entirely sure why this is happening but these have been the two solutions I have found that seem to work.
Example Code
// -- main view
#main
struct MyApp: App {
#StateObject private var vm = ViewModel()
var body: some Scene {
WindowGroup {
ViewOne()
.environmentObject(vm)
}
}
}
// -- initial view
struct ViewOne: View {
#EnvironmentObject private var vm: ViewModel
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Open sheet")
}
.sheet(isPresented: $vm.isPresented) {
SheetView()
}
}
}
// -- sheet view
struct SheetView: View {
#EnvironmentObject private var vm: ViewModel
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Close sheet")
}
}
}
// -- view model
class ViewModel: ObservableObject {
#Published var isPresented: Bool = false
}
Solution 1
Note: from my testing and the example below I still get the error to appear. But if I have a more complex/nested app then the error disappears..
Adding a .buttonStyle() to the button that does the initial toggling.
So within the ContentView on the Button() {} add in a .buttonStyle(.plain) and it will remove the purple error:
struct ViewOne: View {
#EnvironmentObject private var vm: ViewModel
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Open sheet")
}
.buttonStyle(.plain) // <-- here
.sheet(isPresented: $vm.isPresented) {
SheetView()
}
}
}
^ This is probably more of a hack than solution since it'll output a new view from the modifier and that is probably what is causing it to not output the error on larger views.
Solution 2
This one is credit to Alex Nagy (aka. Rebeloper)
As Alex explains:
.. with SwiftUI 3 and SwiftUI 4 the data handling kind of changed. How SwiftUI handles, more specifically the #Published variable ..
So the solution is to have the boolean trigger to be a #State variable within the view and not as a #Published one inside the ViewModel. But as Alex points out it can make your views messy and if you have a lot of states in it, or not be able to deep link, etc.
However, since this is the way that SwiftUI 4 wants these to operate, we run the code as such:
// -- main view
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ViewOne()
}
}
}
// -- initial view
struct ViewOne: View {
#State private var isPresented = false
var body: some View {
Button {
isPresented.toggle()
} label: {
Text("Open sheet")
}
.sheet(isPresented: $isPresented) {
SheetView(isPresented: $isPresented)
// SheetView() <-- if using dismiss() in >= iOS 15
}
}
}
// -- sheet view
struct SheetView: View {
// I'm showing a #Binding here for < iOS 15
// but you can use the dismiss() option if you
// target higher
// #Environment(\.dismiss) private var dismiss
#Binding var isPresented: Bool
var body: some View {
Button {
isPresented.toggle()
// dismiss()
} label: {
Text("Close sheet")
}
}
}
Using the #Published and the #State
Continuing from the video, if you need to still use the #Published variable as it might tie into other areas of your app you can do so with a .onChange and a .onReceive to link the two variables:
struct ViewOne: View {
#EnvironmentObject private var vm: ViewModel
#State private var isPresented = false
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Open sheet")
}
.sheet(isPresented: $isPresented) {
SheetView(isPresented: $isPresented)
}
.onReceive(vm.$isPresented) { newValue in
isPresented = newValue
}
.onChange(of: isPresented) { newValue in
vm.isPresented = newValue
}
}
}
However, this can become really messy in your code if you have to trigger it for every sheet or fullScreenCover.
Creating a ViewModifier
So to make it easier for you to implement it you can create a ViewModifier which Alex has shown works too:
extension View {
func sync(_ published: Binding<Bool>, with binding: Binding<Bool>) -> some View {
self
.onChange(of: published.wrappedValue) { newValue in
binding.wrappedValue = newValue
}
.onChange(of: binding.wrappedValue) { newValue in
published.wrappedValue = newValue
}
}
}
And in use on the View:
struct ViewOne: View {
#EnvironmentObject private var vm: ViewModel
#State private var isPresented = false
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Open sheet")
}
.sheet(isPresented: $isPresented) {
SheetView(isPresented: $isPresented)
}
.sync($vm.isPresented, with: $isPresented)
// .onReceive(vm.$isPresented) { newValue in
// isPresented = newValue
// }
// .onChange(of: isPresented) { newValue in
// vm.isPresented = newValue
// }
}
}
^ Anything denoted with this is my assumptions and not real technical understanding - I am not a technical knowledgeable :/
Try running the code that's throwing the purple error asynchronously, for example, by using DispatchQueue.main.async or Task.
DispatchQueue.main.async {
// environment changing code comes here
}
Task {
// environment changing code comes here
}
Improved Solution of Rebel Developer
as a generic function.
Rebeloper solution
It helped me a lot.
1- Create extension for it:
extension View{
func sync<T:Equatable>(_ published:Binding<T>, with binding:Binding<T>)-> some View{
self
.onChange(of: published.wrappedValue) { published in
binding.wrappedValue = published
}
.onChange(of: binding.wrappedValue) { binding in
published.wrappedValue = binding
}
}
}
2- sync() ViewModel #Published var to local #State var
struct ContentView: View {
#EnvironmentObject var viewModel:ViewModel
#State var fullScreenType:FullScreenType?
var body: some View {
//..
}
.sync($viewModel.fullScreenType, with: $fullScreenType)

SwiftUI NavigationLink behaviour is different when selection is set directly, rather than from viewModel

I've been working with SwiftUI and ran into unexpected behavior.
I have View A and View B and View C. View C has EnviromentObject that changes AppState from View A
View B has ViewModel with selection
If I call function from ViewModel to change the selection then
View C is shown for a few seconds and then it automatically pops back to View B
If I change selection directly from View B (not from ViewModel), everything works as expected.
Also, if I comment out onDissapear, it also works. But, I need to change environmentObject when screen dissapeared
Here is View B and ViewModel
import SwiftUI
class AppState: ObservableObject {
#Published
var shouldHideUserInfo = false
}
struct ContentView: View {
#EnvironmentObject
var appState: AppState
#State
var selection: Int? = nil
var body: some View {
NavigationView {
VStack {
if !appState.shouldHideUserInfo {
Text("USER INFO")
}
NavigationLink(
destination: ViewA(),
tag: 1,
selection: $selection,
label: { EmptyView()})
Button("MOVE TO VIEW A") {
selection = 1
}
}
}
}
}
class ViewAModel: ObservableObject {
#Published
var selection: Int? = nil
func navigate() {
selection = 2 //<- this doesnt
}
}
struct ViewA: View {
#ObservedObject
var viewModel: ViewAModel
init() {
viewModel = ViewAModel()
}
#State
var selection: Int? = nil //<- this works
var body: some View {
VStack
{
Text("VIEW A")
NavigationLink(
destination: ViewB(),
tag: 2,
selection: $viewModel.selection,
label: { EmptyView()})
Button("MOVE TO VIEW B") {
//selection = 2 <-- this works
viewModel.navigate() //<- this doesnt
}
}
}
}
struct ViewB: View {
#EnvironmentObject
var appState: AppState
#State
var selection: Int? = nil
var body: some View {
VStack
{
Text("VIEW B")
}
.onAppear {
appState.shouldHideUserInfo = true
}
}
}
Factory pattern didn't solve the issue:
static func makeViewA(param: Int?) -> some View {
let viewModel = ViewAModel(param: param)
return ViewA(viewModel: viewModel)
}
}
I see... it is a bit different than in post. The issue is because view model is recreated (this is long observed behavior of NavigationView) and thus binding lost.
The quick fix is
struct ViewA: View {
#StateObject
var viewModel: ViewAModel = ViewAModel()
init() {
// viewModel = ViewAModel()
}
// ... other code
}
alternate is to keep ownership of view model outside of ViewA.
Update: SwiftUI 1.0 compatible - here is variant that works everywhere. The reason of the issue is in AppState. The code in ViewB updates appState
.onAppear {
appState.shouldHideUserInfo = true
}
that causes rebuild of ContentView body, which recreates ViewA, which recreates NavigationLink, which drops previous link and ViewB got closed.
To prevent this we need to avoid rebuild ViewA. This can be done by making ViewA is-a Equatable, so SwiftUI check if ViewA needs to be recreated and we will answer NO.
Here is how it goes:
NavigationLink(
destination: ViewA().equatable(), // << here !!
tag: 1,
selection: $selection,
label: { EmptyView()})
and
struct ViewA: View, Equatable {
static func == (lhs: ViewA, rhs: ViewA) -> Bool {
true
}
// .. other code

Missing argument for parameter 'View Call' in call

I am struggle with understanding about why i have to give Popup view dependency named vm while calling this view since it is observable
struct ContentView: View {
#State private var showPopup1 = false
var body: some View {
VStack {
Button(action: { withAnimation { self.showPopup1.toggle()}}){
Text("showPopup1") }
Text("title")
DetailView() /// this line shows error
}
}
}
struct DetailView:View {
#ObservedObject var vm:ViewModel
var body : some View {
Text("value from VM")
}
}
class ViewModel: ObservableObject {
#Published var title:String = ""
}
You have to set your vm property when you init your View. Which is the usual way.
struct ContentView: View {
#State private var showPopup1 = false
var body: some View {
VStack {
Button(action: { withAnimation { self.showPopup1.toggle()}}){
Text("showPopup1") }
Text("title")
DetailView(vm: ViewModel()) // Initiate your ViewModel() and pass it as DetailView() parameter
}
}
}
struct DetailView:View {
var vm: ViewModel
var body : some View {
Text("value from VM")
}
}
class ViewModel: ObservableObject {
#Published var title:String = ""
}
Or you could use #EnvironmentObject. You have to pass an .environmentObject(yourObject) to the view where you want to use yourObject, but again you'll have to initialize it before passing it.
I'm not sure it's the good way to do it btw, as an environmentObject can be accessible to all childs view of the view you declared the .environmentObject on, and you usually need one ViewModel for only one View.
struct ContentView: View {
#State private var showPopup1 = false
var body: some View {
VStack {
Button(action: { withAnimation { self.showPopup1.toggle()}}){
Text("showPopup1") }
Text("title")
DetailView().environmentObject(ViewModel()) // Pass your ViewModel() as an environmentObject
}
}
}
struct DetailView:View {
#EnvironmentObject var vm: ViewModel // you can now use your vm, and access it the same say in all childs view of DetailView
var body : some View {
Text("value from VM")
}
}
class ViewModel: ObservableObject {
#Published var title:String = ""
}

SwiftUI: How can I pop to the root view for watchOS?

I need to pop to the root view from a deep detail view. And while the following solution using isDetailLink and isActive works quite well for iOS, it does not work for watchOS. The isDetailLink command is unavailable in watchOS.
'isDetailLink' is unavailable in watchOS
import SwiftUI
class AppState : ObservableObject {
#Published var showState : Bool = false
}
struct MoreTests: View {
#EnvironmentObject var appState : AppState // injected from SceneDelegate
var body: some View {
NavigationView {
NavigationLink(
destination: MoreView1(),
isActive: $appState.showState, // required to work
label: { Text("Go to MoreView1") }
).isDetailLink(false) // required to work
}.navigationBarTitle("Root")
}
}
struct MoreView1: View {
var body: some View {
NavigationLink(
destination: MoreView2(),
label: { Text("Go to MoreView2") }
)
.navigationBarTitle("MoreView1")
}
}
struct MoreView2: View {
var body: some View {
NavigationLink(
destination: MoreView3(),
label: { Text("Go to MoreView3") }
)
.navigationBarTitle("MoreView2")
}
}
struct MoreView3: View {
#Environment(\.presentationMode) var presentationMode
#EnvironmentObject var appState : AppState
var body: some View {
VStack {
Button(action: {
self.appState.showState = false // required
}) {
Text("Dismiss to root")
}
}.navigationBarTitle("MoreView3")
}
}
The iOS solution came from How can I pop to the Root view using SwiftUI?.
An alternative is to (re)set the id of the NavigationView
Replace AppState with
class AppState : ObservableObject {
#Published var appID = UUID()
}
In MoreTests remove the isActive parameter and add this modifier right before setting the navigationBarTitle
NavigationView {...
}
.id(appState.appID)
In MoreView3 assign a new UUID to the appID
Button(action: {
self.appState.appID = UUID()
}) {
Text("Dismiss to root")
}
Fixed with Xcode 13.4 / watchOS 8.5
Create Project from template.
Just copy-pasted above code
Commented .isDetailLink(false)
Use MoreTests as content of scene
Build & Run

SwiftUI - View not refreshing when a property has changed

I'd appreciate any help with this, I'm just a beginner so I'm not sure if I'm misunderstanding, or have the implementation wrong or if this is a bug.
I'm trying to have my MacOS app segue to the main screen after a successful login. I have an appState to share state with the rest of the app. The AppState is a class conforming to Observable object and I added an observer to print whenever the isLoggedIn property changes:
class AppState : ObservableObject {
#Published var isLoggedIn = false {
didSet {
print("AppState isLoggedin: \(isLoggedIn)")
}
}
}
I also have a MasterView struct to deal with changing the main view.
struct MasterView: View {
#ObservedObject var appState: AppState = AppState()
var body: some View {
return Group {
if appState.isLoggedIn {
NavView()
} else {
LoginView()
}
}.frame(width: 1200, height: 800)
}
}
I have a bunch of code to handle doing the login which I won't post for the sake of brevity, suffice to say that it works, isLoggedIn is set to true and prints to the console after a successful login. The issue is that the view never updates to reflect this so I'm still stuck on the login screen.
Any help is greatly appreciated, I've spent more time on this than I care to admit. Thanks!
Update: I remember having trouble with #EnvironmentObject and so I switched to #ObservableObject and #Published. After re-implementing #EnvironmentObject I now remember why: I have a networking class which causes a crash as it is not an ancestor view. Per Paul Hudson's comment, "Note: Environment objects must be supplied by an ancestor view – if SwiftUI can’t find an environment object of the correct type you’ll get a crash. This applies for previews too, so be careful."
For More Information.
I figured it out, working code below.
AppState:
final class AppState : ObservableObject {
#Published var isLoggedIn = false {
didSet {
print("AppState isLoggedIn: \(isLoggedIn)")
}
}
}
Content View :
struct ContentView: View {
#ObservedObject var appState: AppState
var body: some View {
return Group {
if appState.isLoggedIn {
MainView(appState: appState)
} else {
LoginView(appState: appState)
}
}.frame(maxWidth: 1200, maxHeight: 800)
}
}
Login View:
struct LoginView: View {
#ObservedObject var appState: AppState
var body: some View {
Button(action: {
withAnimation {
self.appState.isLoggedIn.toggle()
}
}) {
Text("Go to Main View")
}.padding()
}
}
Finally, Main View:
struct MainView: View {
#ObservedObject var appState: AppState
var body: some View {
Button(action: {
withAnimation {
self.appState.isLoggedIn.toggle()
}
}) {
Text("Back To Login View")
}.padding()
}
}