ObservableObject with ObservableObject property is not triggering SwiftUI Update - swiftui

// NavigationState.swift
class NavigationState: ObservableObject {
#Published var contentViewNavigation = ContentViewNavigationState()
//#Published var selectedTab = 0
}
// App.swift
#main
struct SwiftUIDeepLinkApp: App {
#StateObject private var navigationAppState = NavigationState()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(navigationAppState)
.onAppear(perform: {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
print("Dispatch called")
navigationAppState.contentViewNavigation.selectedTab = 1
//navigationAppState.selectedTab = 1
}
})
}
}
}
// ContentView.swift
class ContentViewNavigationState: ObservableObject {
#Published var selectedTab = 0
}
struct ContentView: View {
#EnvironmentObject var navigationState: NavigationState
var body: some View {
TabView(selection: $navigationState.contentViewNavigation.selectedTab) {
HomeContainerFlow1()
.tabItem { Text("Flow 1") }
.tag(0)
HomeContainerFlow2()
.embededInNavigation()
.tabItem { Text("Flow 2") }
.tag(1)
}.onReceive(navigationState.contentViewNavigation.$selectedTab, perform: { output in
print("Value changed: \(output)")
})
}
}
As you can see, I'm defining a global state ("NavigationState") to handle the Navigation of the app (i.e Tab Selection). So, I have an ObservableObject (NavigationState) that will have properties for each screen navigation state (i.e ContentViewNavigationState - another ObservableObject).
Actual Behavior
In this example, I'm modifying the state in App.swift file inside a DispatchQueue.asyncAfter, the state is changing (because ContentView's onReceive modifier is displaying the update value) but is not triggering any update in ContentView.
Expected Behavior
The selected tab should be changed based on state.
The interesting part is that if I also modify another property (i.e the commented selectedTab), in that case the update is triggered, but not when only update a contentViewNavigation's property.

A view must observe ObservableObject explicitly if want to be updated if the object has changed.
In your case you observe parent of observable but not inner observable. The contentViewNavigation in your scenario is not changed by itself, because it is just reference to inner object.
So the solution might be as in demo below
struct ContentView: View {
#EnvironmentObject var navigationState: NavigationState
var body: some View {
// separate view which observe inner ObservableObject
MainView(contentNavigation: navigationState.contentViewNavigation)
}
}
struct MainView: View {
#ObservedObject var contentNavigation: ContentViewNavigationState
var body: some View {
TabView(selection: $contentNavigation.selectedTab) {
HomeContainerFlow1()
.tabItem { Text("Flow 1") }
.tag(0)
HomeContainerFlow2()
.embededInNavigation()
.tabItem { Text("Flow 2") }
.tag(1)
}
}
}

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 - TabView/NavigationLink navigation breaks when using a custom binding

I'm having trouble with what I think may be a bug, but most likely me doing something wrong.
I have a slightly complex navigation state variable in my model that I'm using for tracking/setting state between tab and sidebar presentations when multitasking on iPad. That all works fine except in tab mode, once I use a navigation link once I can't seem to use one again, whether the binding is on my tab view or navigation links in a list.
Would really appreciate any thoughts on this,
Cheers!
Example
NavigationItem.swift
enum SubNavigationItem: Hashable {
case overview, user, hobby
}
enum NavigationItem: Hashable {
case home(SubNavigationItem)
case settings
}
Model.swift
final class Model: ObservableObject {
#Published var selectedTab: NavigationItem = .home(.overview)
}
SwiftUIApp.swift
#main
struct SwiftUIApp: App {
#StateObject var model = Model()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
ContentView.swift
struct ContentView: View {
var body: some View {
AppTabNavigation()
}
}
AppTabNavigation.swift
struct AppTabNavigation: View {
#EnvironmentObject private var model: Model
var body: some View {
TabView(selection: $model.selectedTab) {
NavigationView {
HomeView()
}
.tabItem {
Label("Home", systemImage: "house")
}
.tag(NavigationItem.home(.overview))
NavigationView {
Text("Settings View")
}
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(NavigationItem.settings)
}
}
}
HomeView.swift
I created a binding here because selection required an optional <NavigationItem?> not
struct HomeView: View {
#EnvironmentObject private var model: Model
var body: some View {
let binding = Binding<NavigationItem?>(
get: {
model.selectedTab
},
set: {
guard let item = $0 else { return }
model.selectedTab = item
}
)
List {
NavigationLink(
destination: Text("Users"),
tag: .home(.user),
selection: binding
) {
Text("Users")
}
NavigationLink(
destination: Text("Hobbies"),
tag: .home(.hobby),
selection: binding
) {
Text("Hobbies")
}
}
.navigationTitle("Home")
}
}
Second Attempt
I tried making the selectedTab property optional as #Lorem Ipsum suggested. Which means I can remove the binding there. But then the TabView doesn't work with the property. So I create a binding for that and have the same issue but with the tab bar!
Make the selected tab optional
#Published var selectedTab: NavigationItem? = .home(.overview)
And get rid of that makeshift binding variable. Just use the variable
$model.selectedTab
If the variable can never be nil then something is always selected IAW with that makeshift variable it will just keep the last value.

SwiftUI NavigationLink isActive from view model

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)
}
}
}

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

SwiftUI GeometryReader causes memory leak

Consider the following code:
import SwiftUI
class ViewModel: ObservableObject {
}
struct TestView: View {
#ObservedObject var vm = ViewModel()
var body: some View {
// self.sample
GeometryReader { _ in
self.sample
}
}
var sample: some View {
Text("Hello, World!")
}
}
struct Tabs : View {
#State var selection: Int = 0
var body: some View {
TabView(selection: $selection) {
TestView().tabItem {
Text("First Tab")
}
.tag(0)
Text(String(selection))
.tabItem {
Text("Second Tab")
}
.tag(1)
}
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
There are two tabs and selection is referenced in body therefore body will be called when selection is changed.
TestView is using GeometryReader.
When I switch from "First Tab" to "Second Tab" ViewModel is created again and never dereferenced. This is unexpected.
If I switch 100 times I will have 100 ViewModels referenced from SwiftUI internals.
Though if i remove GeometryReader it works as expected.
Did someone experience it? Are there any workarounds?
I simply want this ViewModel lifetime to be bound to TestView lifetime.
UPDATE:
XCode 11.3.1 iOS 13.3
Ok, let's make the following changes in ViewModel
class ViewModel: ObservableObject {
init() {
print(">> inited") // you can put breakpoint here in Debug Preview
}
}
so now it seen that because View is value type
struct TestView: View {
#ObservedObject var vm = ViewModel() // << new instance on each creation
...
and it is originated from
var body: some View {
TabView(selection: $selection) {
TestView().tabItem { // << created on each tab switch
...
so, the solution would be to ViewModel creation out of TestView and inject outer instance either via .environmentObject or via constructor arguments.
Btw, it does not depend on GeometryReader. Tested with Xcode 11.2.1 / iOS 13.2