SwiftUI - ObservableObject doesn't cause update - swift

I have a class called SocialMealplan that looks like the following:
public class SocialMealplan : Identifiable, ObservableObject {
public var id : String {
return owner.id
}
#Published public var owner : YKUser
#Published public var mealplan : Mealplan
init(owner : YKUser, mealplan : Mealplan) {
self.owner = owner
self.mealplan = mealplan
}
}
I then have the following code:
struct MealPlanView: View {
#ObservedObject var currentMealplan: SocialMealplan = SocialMealplan(owner: YKUser.none, mealplan: Mealplan.none)
var body: some View {
/* ... */
ForEach(self.currentMealplan.mealplan.meals, id: \.self) { (meal) in
VStack {
NavigationLink(destination: SelectRecipeView(completion: self.updatedMealplan, date: meal.date)) {
MealplanRow(meal: .constant(meal))
}
}
}.onAppear {
self.refreshMealplan()
}
/* ... */
}
func refreshMealplan() {
// Get the mealplan from the server
self.currentMealplan.mealplan = newMealplan
}
}
The problem is that when I run this code it gets the mealplan, but when it tries to assign the variable nothing happens. refreshMealplan is called and the variable is assigned, but nothing changes on the UI and the view doesn't refresh to reflect the new data.
(Neither does anything happen when I reassign the owner variable)

A new SocialMealPlan object currentMealPlan is being created and initialized every time the view needs to be redrawn/recreated. So one object triggers the update (by assignment to one of the Published vars), but the new updated view refers to its own new freshly initialized copy of currentMealPlan.
Option 1: Make currentMealPlan a #StateObject (so one copy representing a state is kept and referred to). ie #StateObject var currentMealplan = SocialMealplan(owner: YKUser.none, mealplan: Mealplan.none)
Option 2: Or keep the #ObservedObject, but create it before and outside th view. But if other views also need to refer to the currentMealPlan, create one before the View and pass it as an environment variable. ie MealPlanView().environmentObject(currentMealPlan) and then in the view #EnvironmentObject var currentMealPlan: SocialMealPlan

Related

ObservableObject with many #Published properties redraws everything unnecessarily

Consider the following code:
class Model: ObservableObject {
#Published var property1: Int = 0
#Published var property2: Int = 0
}
struct ObjectBindingTest: View {
#StateObject private var model = Model()
var body: some View {
print("——— top")
return VStack(spacing: 30) {
SomeSimpleComponent(property: $model.property1)
SomeSimpleComponent2(property: $model.property2)
}
.padding(50)
}
}
struct SomeSimpleComponent: View {
#Binding var property: Int
var body: some View {
print("component 1")
return HStack {
Text("\(property)")
Button("Increment", action: { property += 1 })
}
}
}
struct SomeSimpleComponent2: View {
#Binding var property: Int
var body: some View {
print("component 2")
return HStack {
Text("\(property)")
Button("Increment", action: { property += 1 })
}
}
}
Whenever you press on one of the buttons, you will see in console:
——— top
component 1
component 2
Meaning that all body blocks get evaluated.
I would expect that only the corresponding row gets updated: if I press the first button and therefore update property1, the second row shouldn't have to re-evaluate its body because it's only dependent on property2.
This is causing big performance issues in my app. I have a page to edit an object with many properties. I use an ObservableObject with many #Published properties. Every time a property changes (for instance while typing in a field), all the controls in the page get updated, which causes lags and freezes. The performance issues mostly happen in iOS 14; I'm not sure whether they're not happening in iOS 15 or if it's just that the device has more computing power.
How to prevent unnecessary updates coming from ObservableObject, and only update the views that actually watch the updated property?
The behavior you are seeing is expected
By default an ObservableObject synthesizes an objectWillChange publisher that emits the changed value before any of its #Published properties changes.
In other words all the wrappers trigger a single publisher so SwiftUI does not know which was updated.
https://developer.apple.com/documentation/combine/observableobject
You can get a partial performance upgrade by changing from a class to a struct and using #State
struct Model {
var property1: Int = 0
var property2: Int = 0
}
#State private var model = Model()
In certain cases such a ForEach you will get improvements by adding a few protocols.
struct Model: Equatable, Hashable, Identifiable {
let id: UUID = .init()
//More code
Check out Demystify SwiftUI from #wwdc21
https://developer.apple.com/wwdc21/10022 it will provide a greater insight into the why.

Bindable boolean in array that's togglable SwiftUI

I have an array in SwiftUI where it's an array of a struct that contains a boolean value which is bounded by a Toggle.
struct Blah {
#State var enabled = true
}
struct ContentView: View {
#State public var blahs: [Blah] = [
Blah(false)
]
var body : some View {
List(blahs) { blah in
Toggle(isOn: blah.$enabled)
}
}
}
the blahs arrays will have a button that will append more Blah objects. Xcode is telling me this though:
Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.
How should I change this? I don't think I'm applying the concept right.
#State should only be used on a View — it shouldn’t be used inside your model.
Once you’ve removed that, you can use the element binding syntax to get bindings to individual items on the List:
struct Blah : Identifiable {
var id = UUID()
var enabled = true
}
struct ContentView: View {
#State public var blahs: [Blah] = [
Blah(false)
]
var body : some View {
List($blahs) { $blah in
Toggle(isOn: $blah.enabled)
}
}
}

How does a ObservableObject observe the change of Binding value (not value type)

Code:
class AppStore: ObservableObject {
#Published var settings = Settings()
}
struct Settings {
var account = Account()
}
class Account {
#Published username
}
#EnvironmentObject appStore = AppStore()
VStack {
TextField("username", &appStore.settings.username)
Button("Change") {
appStore.settings.username += "a"
}
}
What happened:
appStore.settings.username += "a" can not trigger the update of the view. But input to the TextField can trigger the update of the view.
Question:
I understand why appStore.settings.username += "a" can not trigger the update of the view. The ObservableObject can only observe the change of value, but the account is refrence type. So when we change the property of account, there is no value change in appStore. But I don't know why the second situation can happen

SwiftUI: How to Update NavigationView Detail Screens?

I've got an app that gets a list of vehicles from a REST backend server. It then uses that list to build a list of vehicles that can be tapped to show the details about one of them:
#State private var selectedVehicle: Vehicle?
#Binding var vehicles: [Vehicle]
List {
NavigationView {
ForEach( vehicles ) { vehicle in
NavigationLink( destination: VehicleDetailScreen( vehicle: vehicle ),
tag: vehicle,
selection: self.$selectedVehicle ) {
Text( vehicle.name )
}
}
}
}
struct VehicleDetailScreen: View {
var vehicle: Vehicle
var body: some View {
// Lots of rendering code omitted
}
}
So far, so good. This works nicely. The problem arises when we fetch updated information from the server. Updating the bound vehicles property works great for updating the list. But the detail screen is still showing data that's no longer relevant.
My first thought was just to pop the detail view off of the NavigationView. Unfortunately, SwiftUI doesn't provide any reliable way that I can find to do this in a two-column view on the iPad.
My next thought was that we needed to pass the vehicle in to VehicleDetailScreen as a #Binding too so that we can update it. But this is tough to do as well because we would need a reference to that binding so that we can cram updated values into it. The only way I can think of to do that would be to rework our network and model object code entirely so that it works like CoreData, keeping objects in memory and updating them with new values from the server, rather than generating new objects. This would be a good deal of effort, and obviously isn't something I'm keen to do if there's another option.
So I'm kind of stuck on this. Any thoughts/ideas/suggestions are very welcome!
Perhaps the concept of #Binding is somewhat confusing. From a #State var (parent view), to #Binding var (child view).
A struct Hashable to facilitate and reorder the elements of the array [Vehicle].
Something like this:
struct Vehicle: Hashable {
var name:String
//var otherItem: Any
}
struct ContentView: View {
#State var vehicle: Vehicle //the struct of your REST
#State var vehicles: [Vehicle] // the array of your REST
var body: some View {
List {
NavigationView {
ForEach(vehicles, id:\.self) { item in // loop the array to get every single item conform to the struct
NavigationLink( destination: VehicleDetailScreen(vehicle: self.$vehicle)) { // here to pass the binding
Text("\(self.vehicle.name)")
}
}
}
}
}
}
//detail view
struct VehicleDetailScreen: View {
#Binding var vehicle: Vehicle // here the binding
var body: some View {
Text("\(vehicle.name)")
}
}
If you want your detail views to update when data changes, you will have to make use of bindings.
As far as architecture goes, I would suggest to create so called Stores that hold data which can be used in multiple views. This, in combination with some static provider for Stores, makes it that you can easily access and modify data anywhere, and let your views update automatically.
When using UIKit, you would manually refresh data by calling reloadTable for instance. In SwiftUI this is not done. You could hypothetically manually trigger the view to update, but I would advice against this, as it is not the way SwiftUI was intended.
I've modified your code to show an example of this:
class StoreProvider {
static let carStore = CarStore()
}
class CarStore: ObservableObject {
#Published var vehicles: [Vehicle] = [Vehicle(id: "car01", name: "Porsche", year: 2016), Vehicle(id: "car02", name: "Lamborghini", year: 2002)]
}
struct Vehicle: Identifiable, Hashable {
let id: String
var name: String
var year: Int
}
struct CarOverview: View {
#ObservedObject var store = StoreProvider.carStore
#State var selectedVehicle: Vehicle?
var body: some View {
NavigationView {
List {
ForEach(store.vehicles.indices) { vehicleIndex in
NavigationLink(destination: VehicleDetailScreen(vehicle: self.$store.vehicles[vehicleIndex])) {
Text(self.store.vehicles[vehicleIndex].name)
}.onTapGesture {
self.selectedVehicle = self.store.vehicles[vehicleIndex]
}
}
}
}
}
}
struct VehicleDetailScreen: View {
#Binding var vehicle: Vehicle
func updateValues() {
vehicle.year = Int.random(in: 1990..<2020)
}
var body: some View {
VStack {
Text(vehicle.name)
Text("Year: ") + Text(vehicle.year.description)
}.onTapGesture(perform: updateValues)
}
}

Swift - How to access #Published var from func outside of view?

I'm trying to remove the logic from the view, while keeping the benefits of SwiftUI. Idea 1 works but it makes use of an extra variable than I would want to. Idea 2 gives error: Property wrappers are not yet supported on local properties. The view should return "bar". What is the best way of making this work? Many thanks.
import Combine
import Foundation
import SwiftUI
// Model
enum Model: String, RawRepresentable {
case foo = "foo"
case bar = "bar"
}
// State
var data1: String = Model.foo.rawValue
class State: ObservableObject {
#Published internal var data2: String = data1
}
// Logic
func logic() {
// Idea 1: OK
//data1 = Model.bar.rawValue
//print(State().data2)
// Idea 2: Error Property wrappers are not yet supported on local properties
#EnvironmentObject private var state: State
state.data2 = Model.bar.rawValue
print(state.data2)
}
// View
struct bar: View {
#EnvironmentObject private var state: State
internal var body: some View {
logic()
return Text(verbatim: self.state.data2)
}
}
If you want a function to have access to a view's state, pass the state:
func logic(state: State) {
state.data2 = Model.bar.rawValue
print(state.data2)
}
But what you've done here is an infinite loop. Modifying a view's state causes the view to be re-rendered. So every time the view is rendered, it modifies its state and forces it to be rendered again. That will never resolve. What you may mean here is to change the state when the view first appears, in which case you'd call logic this way:
struct Bar: View {
#EnvironmentObject private var state: State
internal var body: some View {
Text(verbatim: state.data2)
.onAppear{ logic(state: self.state) }
}
}