Manipulating EnvironmentObject inside ObservedObject without getting entire view updated - swiftui

Using a wrapper allows us to pass EnvironmentObject down into ObservedObject. Nice approach..
But what if you want to manipulate the userData inside ViewObject without an entirely new ViewObject being created every time?
In my app entire view is recreated after I change EnvironmentObject and i don't know how to avoid this.
struct MyCoolView: View {
#EnvironmentObject var userData: UserData
var body: some View {
MyCoolInternalView(ViewObject(id: self.userData.UID))
}
}
struct MyCoolInternalView: View {
#EnvironmentObject var userData: UserData
#ObservedObject var viewObject: ViewObject
init(_ viewObject: ViewObject) {
self.viewObject = viewObject
}
var body: some View {
Text("\(self.viewObject.myCoolProperty)")
}
}

But what if you want to manipulate the userData inside ViewObject without an entirely new ViewObject being created every time?
Here is a demo of possible solution - make viewObject as StateObject (this will make it persistent through view refresh) and inject userData into it
Tested with Xcode 12.1 / iOS 14.1
class UserData: ObservableObject {
#Published var UID = "1"
}
class ViewObject: ObservableObject {
var userData: UserData?
#Published var myCoolProperty = "Hello"
}
struct MyCoolView: View {
#EnvironmentObject var userData: UserData
var body: some View {
MyCoolInternalView(self.userData)
}
}
struct MyCoolInternalView: View {
#StateObject private var viewObject = ViewObject()
init(_ userData: UserData) {
self.viewObject.userData = userData
}
var body: some View {
Text("\(self.viewObject.myCoolProperty)")
}
}

Related

How can you make use of a #Binding var state change in a model object?

I have a view with some controls for choosing a date property. I would like to use that date property in a sibling's view model to search in CoreData for a Schedule object for that date, or create one in case there is none - a searchOrCreate method. (I wished I could use #FetchRequest for this but I haven't yet found a way)
I've made the date a #Binding var so I can get the view update in the Child2 view, but I haven't found a way to pass the state changes to its model for it to do a searchOrCreate, as didSet does not work on bindings. Am I not approaching this correctly, and should I just use an #Environment variable or something else?
struct ParentView: View {
#State var day: Date = Date().noon
var body: some View {
VStack() {
Child1View(day: $day)
Child2View(day: day)
}
}
}
struct Child1View: View {
#Binding var day: Date
// code to select day
}
struct Child2View: View {
var day: Date // I do not need to update the day in this view,
//just display it and use it in an object, so I did not use #Binding
#StateObject private var viewModel = Child2ViewModel()
...
}
class Child2ViewModel: ObservableObject {
var day: Date {
didSet {
// call searchOrCreate in CoreData for the Schedule object
}
#Published var schedule: Schedule
}
What I have discovered works is if I make the viewModel be a part of the parent view and pass it down, however it feels like this breaks encapsulation. Ideally I would want Child2View to just get a date and do its thing from there.
struct ParentView: View {
#StateObject var viewModel = Child2ViewModel()
var body: some View {
VStack() {
Child1View(day: $viewModel.day)
Child2View(viewModel: viewModel)
}
}
}
struct Child2View: View {
#ObservedObject private var viewModel: Child2ViewModel
...
}
class Child2ViewModel: ObservableObject {
#Published var day = Date().noon {
didSet {
// call searchOrCreate in CoreData for the Schedule object
}
#Published var schedule: Schedule
}
You can use onReceive to capture the changes of the parent variable and pass it to the ViewModel:
import Combine
import SwiftUI
struct ParentView: View {
#State var day: Date = Date()
var body: some View {
VStack {
Child1View(day: $day)
Child2View(day: day)
}
}
}
struct Child2View: View {
#StateObject private var viewModel = Child2ViewModel()
var day: Date
var body: some View {
Text("Child2View")
.onReceive(Just(day)) { // whenever the parent `day` changes, update the viewModel
guard viewModel.day != $0 else { return }
viewModel.day = $0
}
}
}

how to access #EnvironmentObject in view's init() [duplicate]

I'm not sure if this is an antipattern in this brave new SwiftUI world we live in, but essentially I have an #EnvironmentObject with some basic user information saved in it that my views can call.
I also have an #ObservedObject that owns some data required for this view.
When the view appears, I want to use that #EnvironmentObject to initialize the #ObservedObject:
struct MyCoolView: View {
#EnvironmentObject userData: UserData
#ObservedObject var viewObject: ViewObject = ViewObject(id: self.userData.UID)
var body: some View {
Text("\(self.viewObject.myCoolProperty)")
}
}
Unfortunately I can't call self on the environment variable until after initialization:
"Cannot use instance member 'userData' within property initializer; property initializers run before 'self' is available."
I can see a few possible routes forward, but they all feel like hacks. How should I approach this?
Here is the approach (the simplest IMO):
struct MyCoolView: View {
#EnvironmentObject var userData: UserData
var body: some View {
MyCoolInternalView(ViewObject(id: self.userData.UID))
}
}
struct MyCoolInternalView: View {
#EnvironmentObject var userData: UserData
#ObservedObject var viewObject: ViewObject
init(_ viewObject: ViewObject) {
self.viewObject = viewObject
}
var body: some View {
Text("\(self.viewObject.myCoolProperty)")
}
}

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.

SwiftUI - Updating #State when Global changes

I'd like to update an UI element on an overview view when data on another view is changed.
I looked into #EnvironmentalObject and #Binding. However, an update to either object does not appear to force a view reload. Only changes to #State force renders.
Also, in the case described below, the ChangeView is not a child of OverviewView. Therefore #Binding is not an option anyway.
Data.swift
struct ExampleData : Hashable {
var id: UUID
var name: String
}
var globalVar: ExampleData = ExampleData(id: UUID(), name:"")
OverviewView.swift
struct OverviewView: View {
#State private var data: ExampleData = globalVar
var body: some View {
Text(data.name)
}
}
ChangeView.swift
struct ChangeView: View {
#State private var data: ExampleData = globalVar
var body: some View {
TextField("Name", text: $data.name, onEditingChanged: { _ in
globalVar = data }, onCommit: { globalVar = data })
}
}
Changes within the ChangeView TextField will update the globalVar. However, this will not update the Text on the OverviewView when switching back to the view.
I am aware that using global variables is "ugly" coding. How do I handle data that will be used in a multitude of unrelated views?
Please advise on how to better handle such a situation.
OverviewView and ChangeView hold different copies of the ExampleData struct in their data variables (When assigning a struct to another variable, you're effectively copying it instead of referencing it like an object.) so changing one won't affect the other.
#EnvironmentObject suits your requirements.
Here's an example:
Since, we're using #EnvironmentObject, you need to either convert ExampleData to
a class, or use a class to store it. I'll use the latter.
class ExampleDataHolder: ObservableObject {
#Published var data: ExampleData = ExampleData(id: UUID(), name:"")
}
struct CommonAncestorOfTheViews: View {
var body: some View {
CommonAncestorView()
.environmentObject(ExampleDataHolder())
}
}
struct OverviewView: View {
#EnvironmentObject var dataHolder: ExampleDataHolder
var body: some View {
Text(dataHolder.data.name)
}
}
struct ChangeView: View {
#EnvironmentObject var dataHolder: ExampleDataHolder
var body: some View {
TextField("Name", text: $dataHolder.data.name)
}
}

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.