EnvironmentObject causes unrelated ObservedObject to reset - swiftui

I am not quite sure I understand what is going on here as I am experimenting with an EnvironmentObject in SwiftUI.
I recreated my problem with a small example below, but to summarize: I have a ContentView, ContentViewModel, and a StateController. The ContentView holds a TextField that binds with the ContentViewModel. This works as expected. However, if I update a value in the StateController (which to me should be completely unrelated to the ContentViewModel) the text in the TextField is rest.
Can someone explain to me why this is happening, and how you could update a state on an EnvironmentObject without having SwiftUI redraw unrelated parts?
App.swift
#main
struct EnvironmentTestApp: App {
#ObservedObject var stateController = StateController()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(stateController)
}
}
}
ContentView.swift
struct ContentView: View {
#ObservedObject private var viewModel = ContentViewModel()
#EnvironmentObject private var stateController: StateController
var body: some View {
HStack {
TextField("Username", text: $viewModel.username)
Button("Update state") {
stateController.validated = true
}
}
}
}
ContentViewModel.swift
class ContentViewModel: ObservableObject {
#Published var username = ""
}
StateController.swift
class StateController: ObservableObject {
#Published var validated = false
}

Like lorem-ipsum pointed out, you should use #StateObject.
A good rule of thumb is to use #StateObject every time you init a viewModel, but use #ObservedObject when you are passing in a viewModel that has already been init.

Related

SwiftUI connecting #Binding to #Published directly in ChildView

We needed to bind to the #Published in a ParentView's #StateObject being used as a ViewModel. I told my coworker we needed to pass in the entire ViewModel to an #ObservedObject in the ChildView, since everything I've read about SwiftUI describes the parent-child data relationships like this:
Parent View
Child View
#State
#Binding
#StateObject
#ObservedObject
#Published (within the #StateObject)
DOESN'T EXIST
He insisted we could just pass in the #Published alone to the ChildView's #Binding and it would work. Lo and behold, he was right. In the example code below is the documented way to use #Binding, along with passing the entire #StateObject down into an #ObservedObject and binding to the #Published directly (which I thought was the only way you could/should bind to an #Published), and passing the #Published directly into the #Binding, which surprisingly does work. You can see in the sample pic that when you type in any of the 3 TextField's, the updates appear immediately in the parent view, proving that the #Binding works with all 3 approaches.
Is this a fluke about binding to the #Published directly? Or an undocumented-but-valid approach? Why isn't this taught as a standard approach?
import SwiftUI
class MyViewModel: ObservableObject {
#Published var publishedStr1 = "I am publishedStr1"
#Published var publishedStr2 = "I am publishedStr2"
}
struct ParentView: View {
#State var stateStr = "I am stateStr"
#StateObject var myViewModel = MyViewModel()
var body: some View {
VStack {
ChildView(stateStr: $stateStr,
myViewModel: myViewModel,
// Why don't I ever see examples using this technique
// of passing the #Published directly to an #Binding?
// It clearly works.
publishedStr2: $myViewModel.publishedStr2)
Text(stateStr)
Text(myViewModel.publishedStr1)
Text(myViewModel.publishedStr2)
}
}
}
struct ChildView: View {
#Binding var stateStr: String
#ObservedObject var myViewModel: MyViewModel
#Binding var publishedStr2: String
var body: some View {
VStack {
TextField("Binding to State", text: $stateStr)
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("Binding to ObservedObject",
text: $myViewModel.publishedStr1)
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("Binding to ObservedObject's Published",
text: $publishedStr2)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
}
#StateObject is only when we need a reference type for state. In your case you don't so just use #State, e.g.
struct ParentViewConfig {
var publishedStr1 = "I am publishedStr1"
var publishedStr2 = "I am publishedStr2"
var stateStr = "I am stateStr"
mutating func someFunc() {
}
}
struct ParentView: View {
#State var config = ParentViewConfig()

Is this the right way for using #ObservedObject and #EnvironmentObject?

Cow you give me some confirmation about my understanding about #ObservedObject and #EnvironmentObject?
In my mind, using an #ObservedObject is useful when we send data "in line" between views that are sequenced, just like in "prepare for" in UIKit while using #EnvironmentObject is more like "singleton" in UIKit. My question is, is my code making the right use of these two teniques? Is this the way are applied in real development?
my model used as brain for funcions (IE urls sessions, other data manipulations)
class ModelClass_ViaObservedObject: ObservableObject {
#Published var isOn: Bool = true
}
class ModelClass_ViaEnvironment: ObservableObject {
#Published var message: String = "default"
}
my main view
struct ContentView: View {
//way to send data in views step by step
#StateObject var modelClass_ViaObservedObject = ModelClass_ViaObservedObject()
//way to share data more or less like a singleton
#StateObject var modelClass_ViaEnvironment = ModelClass_ViaEnvironment()
var myBackgroundColorView: Color {
if modelClass_ViaObservedObject.isOn {
return Color.green
} else {
return Color.red
}
}
var body: some View {
NavigationView {
ZStack {
myBackgroundColorView
VStack {
NavigationLink(destination:
SecondView(modelClass_viaObservedObject: modelClass_ViaObservedObject)
) {
Text("Go to secondary view")
.padding()
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(.black, lineWidth: 1)
)
}
Text("text received from second view: \(modelClass_ViaEnvironment.message)")
}
}
.navigationTitle("Titolo")
.navigationBarTitleDisplayMode(.inline)
}
.environmentObject(modelClass_ViaEnvironment)
}
}
my second view
struct SecondView: View {
#Environment(\.dismiss) var dismiss
#ObservedObject var modelClass_viaObservedObject: ModelClass_ViaObservedObject
//global data in environment, not sent step by step view by view
#EnvironmentObject var modelClass_ViaEnvironment: ModelClass_ViaEnvironment
var body: some View {
VStack(spacing: 5) {
Text("Second View")
Button("change bool for everyone") {
modelClass_viaObservedObject.isOn.toggle()
dismiss()
}
TextField("send back", text: $modelClass_ViaEnvironment.message)
Text(modelClass_ViaEnvironment.message)
}
}
}
No, we use #State for view data like if a toggle isOn, which can either be a single value itself or a custom struct containing multiple values and mutating funcs. We pass it down the View hierarchy by declaring a let in the child View or use #Binding var if we need write access. Regardless of if we declare it let or #Binding whenever a different value is passed in to the child View's init, SwiftUI will call body automatically (as long as it is actually accessed in body that is).
#StateObject is for when a single value or a custom struct won't do and we need a reference type instead for view data, i.e. if persisting or syncing data (not using the new async/await though because we use .task for that). The object is init before body is called (usually before it is about to appear) and deinit when the View is no longer needed (usually after it disappears).
#EnvironmentObject is usually for the store object that holds model structs in #Published properties and is responsible for saving or syncing,. The difference is the model data is not tied to any particular View, like #State and #StateObject are for view data. This object is usually a singleton, one for the app and one with sample data for when previewing, because it should never be deinit. The advantage of #EnvironmentObject over #ObservedObject is we don't need to pass it down through each View as a let that don't need the object when we only need it further down the hierarchy. Note the reason it has to be passed down as a let and not #ObservedObject is then body would be needlessly called in the intermediate Views because SwiftUI's dependency tracking doesn't work for objects only value types.
Here is some sample code:
struct MyConfig {
var isOn = false
var message = ""
mutating func reset() {
isOn = false
message = ""
}
}
struct MyView: View {
#State var config = MyConfig() // grouping vars into their struct makes use of value semantics to track changes (a change to any of its properties is detected as a change to the struct itself) and offers testability.
var body: some View {
HStack {
ViewThatOnlyReads(config: config)
ViewThatWrites(config: $config)
}
}
}
struct ViewThatOnlyReads: View {
let config: MyConfig
var body: some View {
Text(config.isOn ? "It's on" : "It's off")
}
}
struct ViewThatWrites: View {
#Binding var config: MyConfig
var body: some View {
Toggle("Is On", isOn: $config.isOn)
}
}

SwiftUI: Passing an environmentObject to a sheet causes update problems

If you want to pass an #EnvironmentObject to a View presented as a sheet, you'll notice that this sheet gets recreated every single time any #Published property in the #EnvironmentObject is updated.
Minimum example that demonstrates the problem:
import SwiftUI
class Store: ObservableObject {
#Published var name = "Kevin"
#Published var age = 38
}
struct ContentView: View {
#EnvironmentObject private var store: Store
#State private var showProfile = false
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Edit profile") {
self.showProfile = true
}
}
.sheet(isPresented: $showProfile) {
ProfileView()
.environmentObject(self.store)
}
}
}
struct ProfileView: View {
#EnvironmentObject private var store: Store
#ObservedObject private var viewModel = ViewModel()
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Change age") {
self.store.age += 1
}
}
}
}
class ViewModel: ObservableObject {
init() {
print("HERE")
}
}
If you run this code, you'll notice that "HERE" gets logged every single time you press the button in the sheet, meaning that the ViewModel got recreated. This can be a huge problem as you might imagine, I expect the ViewModel to not get recreated but retain its state. It's causing huge problems in my app.
As far as I am aware, what I am doing in my code is the normal way to pass the #EnvironmentObject to a sheet. Is there a way to prevent the ProfileView from getting recreated any time something in the Store changes?
This is because the view gets recreated when a state variable changes. And in your view you instantiate the viewModel as ViewModel().
Try passing the observed object as a param and it won't hit "HERE" anymore:
struct ContentView: View {
#EnvironmentObject private var store: Store
#State private var showProfile = false
#ObservedObject private var viewModel = ViewModel()
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Edit profile") {
self.showProfile = true
}
}
.sheet(isPresented: $showProfile) {
ProfileView(viewModel: self.viewModel)
.environmentObject(self.store)
}
}
}
struct ProfileView: View {
#EnvironmentObject private var store: Store
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Change age") {
self.store.age += 1
}
}
}
}
If your Deployment Target is iOS14 and above, have you tried replacing #ObservedObject with #StateObject in ProfileView? This will help in keeping the state, it will only be created once, even if the Model View instantiaton happens inside the View's body.
A very nice article about this issue can be found her.

Bizarre SwiftUI behavior: ViewModel class + #Binding is breaking when using #Environment(\.presentationMode)

I keep finding very strange SwiftUI bugs that only pop up under very specific circumstances 😅. For example, I have a form that is shown as a model sheet. This form has a ViewModel, and shows a UITextView (via UIViewRepresentable and a #Binding - it's all in the code below).
Everything works absolutely fine, you can run the code below and you'll see all the two-way bindings working as expected: type in one field and it changes in the other, and vice-versa. However, as soon as you un-comment the line #Environment(\.presentationMode) private var presentationMode, then the two-way binding in the TextView breaks. You will also notice that the ViewModel prints "HERE" twice.
What the hell is going on? My guess is that as soon as ContentView shows the modal, the value of presentationMode changes, which then re-renders the sheet (so, FormView). That would explain the duplicate "HERE" getting logged. But, why does that break the two-way text binding?
One workaround is to not use a ViewModel, and simply have an #State property directly in the FormView. But that is not a great solution as I have a bunch of logic in my real-world form, which I don't want to move to the form view. So, does anyone have a better solution?
import SwiftUI
import UIKit
struct TextView: UIViewRepresentable {
#Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let uiTextView = UITextView()
uiTextView.delegate = context.coordinator
return uiTextView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = self.text
}
class Coordinator : NSObject, UITextViewDelegate {
var parent: TextView
init(_ view: TextView) {
self.parent = view
}
func textViewDidChange(_ textView: UITextView) {
self.parent.text = textView.text
}
func textViewDidEndEditing(_ textView: UITextView) {
self.parent.text = textView.text
}
}
}
struct ContentView: View {
#State private var showForm = false
//#Environment(\.presentationMode) private var presentationMode
var body: some View {
NavigationView {
Text("Hello")
.navigationBarItems(trailing: trailingNavigationBarItem)
}
.sheet(isPresented: $showForm) {
FormView()
}
}
private var trailingNavigationBarItem: some View {
Button("Form") {
self.showForm = true
}
}
}
struct FormView: View {
#ObservedObject private var viewModel = ViewModel()
var body: some View {
NavigationView {
Form {
Section(header: Text(viewModel.text)) {
TextView(text: $viewModel.text)
.frame(height: 200)
}
Section(header: Text(viewModel.text)) {
TextField("Text", text: $viewModel.text)
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var text = ""
init() {
print("HERE")
}
}
I finally found a workaround: store the ViewModel on the ContentView, not on the FormView, and pass it in to the FormView.
struct ContentView: View {
#State private var showForm = false
#Environment(\.presentationMode) private var presentationMode
private let viewModel = ViewModel()
var body: some View {
NavigationView {
Text("Hello")
.navigationBarItems(trailing: trailingNavigationBarItem)
}
.sheet(isPresented: $showForm) {
FormView(viewModel: self.viewModel)
}
}
private var trailingNavigationBarItem: some View {
Button("Form") {
self.showForm = true
}
}
}
struct FormView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
Form {
Section(header: Text(viewModel.text)) {
TextView(text: $viewModel.text)
.frame(height: 200)
}
Section(header: Text(viewModel.text)) {
TextField("Text", text: $viewModel.text)
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var text = ""
init() {
print("HERE")
}
}
The only thing is that the ViewModel is now instantiated right when the ContentView is opened, even if you never open the FormView. Feels a bit wasteful. Especially when you have a big List, with NavigationLinks to a bunch of detail pages, which now all create their presented-as-a-sheet FormView's ViewModel up front, even if you never leave the List page.
Sadly I can't turn the ViewModel into a struct, as I actually need to (asynchronously) mutate state and then eventually I run into the Escaping closure captures mutating 'self' parameter compiler error. Sigh. So yeah, I am stuck with using a class.
The root of the issue is still that FormView is instantiated twice (because of #Environment(\.presentationMode)), which causes two ViewModels to be created as well (which my workaround solves by passing in one copy to both FormViews basically). But it's still weird that this broke #Binding, since the standard TextFields did work as expected.
There are still a lot of weird gotcha's like this with SwiftUI, I really hope this becomes simpler to manage soon. If anyone can explain the behavior of sheets, ObservableObject classes (viewmodels), #Environment(\.presentationMode) and #Binding put together, I'm all ears.

Change to #Published var in #EnvironmentObject not reflected immediately

In this specific case, when I try to change an #EnvironmentObject's #Published var, I find that the view is not invalidated and updated immediately. Instead, the change to the variable is only reflected after navigating away from the modal and coming back.
import SwiftUI
final class UserData: NSObject, ObservableObject {
#Published var changeView: Bool = false
}
struct MasterView: View {
#EnvironmentObject var userData: UserData
#State var showModal: Bool = false
var body: some View {
Button(action: { self.showModal.toggle() }) {
Text("Open Modal")
}.sheet(isPresented: $showModal, content: {
Modal(showModal: self.$showModal)
.environmentObject(self.userData)
} )
}
}
struct Modal: View {
#EnvironmentObject var userData: UserData
#Binding var showModal: Bool
var body: some View {
VStack {
if userData.changeView {
Text("The view has changed")
} else {
Button(action: { self.userData.changeView.toggle() }) {
Text("Change View")
}
}
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
MasterView().environmentObject(UserData())
}
}
#endif
Is this a bug or am I doing something wrong?
This works if changeView is a #State var inside Modal. It also works if it's a #State var inside MasterView with a #Binding var inside Modal. It just doesn't work with this setup.
A couple of things.
Your setup doesn't work if you move the Button into MasterView either.
You don't have a import Combine in your code (don't worry, that alone doesn't help).
Here's the fix. I don't know if this is a bug, or just poor documentation - IIRC it states that objectWillChange is implicit.
Along with adding import Combine to your code, change your UserData to this:
final class UserData: NSObject, ObservableObject {
var objectWillChange = PassthroughSubject<Void, Never>()
#Published var changeView: Bool = false {
willSet {
objectWillChange.send()
}
}
}
I tested things and it works.
Changing
final class UserData: NSObject, ObservableObject {
to
final class UserData: ObservableObject {
does fix the issue in Xcode11 Beta6. SwiftUI does seem to not handle NSObject subclasses implementing ObservableObject correctly (at least it doesn't not call it's internal willSet blocks it seems).
In Xcode 11 GM2, If you have overridden objectWillChange, then it needs to call send() on setter of a published variable.
If you don't overridden objectWillChange, once the published variables in #EnvironmentObject or #ObservedObject change, the view should be refreshed. Since in Xcode 11 GM2 objectWillChange already has a default instance, it is no longer necessary to provide it in the ObservableObject.