Change on ObservableObject in #Published array does not update the view - swift

I've been struggling for hours on an issue with SwiftUI.
Here is a simplified example of my issue :
class Parent: ObservableObject {
#Published var children = [Child()]
}
class Child: ObservableObject {
#Published var name: String?
func loadName() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// Async task here...
self.objectWillChange.send()
self.name = "Loaded name"
}
}
}
struct ContentView: View {
#ObservedObject var parent = Parent()
var body: some View {
Text(parent.children.first?.name ?? "null")
.onTapGesture {
self.parent.objectWillChange.send()
self.parent.children.first?.loadName() // does not update
}
}
}
I have an ObservableObject (Parent) storing a #Published array of ObservableObjects (Child).
The issue is that when the name property is changed via an async task on one object in the array, the view is not updated.
Do you have any idea ?
Many thanks
Nicolas

I would say it is design issue. Please find below preferable approach that uses just pure SwiftUI feature and does not require any workarounds. The key idea is decomposition and explicit dependency injection for "view-view model".
Tested with Xcode 11.4 / iOS 13.4
class Parent: ObservableObject {
#Published var children = [Child()]
}
class Child: ObservableObject {
#Published var name: String?
func loadName() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// Async task here...
self.name = "Loaded name"
}
}
}
struct FirstChildView: View {
#ObservedObject var child: Child
var body: some View {
Text(child.name ?? "null")
.onTapGesture {
self.child.loadName()
}
}
}
struct ParentContentView: View {
#ObservedObject var parent = Parent()
var body: some View {
// just for demo, in real might be conditional or other UI design
// when no child is yet available
FirstChildView(child: parent.children.first ?? Child())
}
}

Make sure your Child model is a struct! Classes doesn't update the UI properly.

this alternative approach works for me:
class Parent: ObservableObject {
#Published var children = [Child()]
}
class Child: ObservableObject {
#Published var name: String?
func loadName(handler: #escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// Async task here...
self.name = UUID().uuidString // just for testing
handler()
}
}
}
struct ContentView8: View {
#ObservedObject var parent = Parent()
var body: some View {
Text(parent.children.first?.name ?? "null").padding(10).border(Color.black)
.onTapGesture {
self.parent.children.first?.loadName(){
self.parent.objectWillChange.send()
}
}
}
}

Related

How to observer a property in swift ui

How to observe property value in SwiftUI.
I know some basic publisher and observer patterns. But here is a scenario i am not able to implement.
class ScanedDevice: NSObject, Identifiable {
//some variables
var currentStatusText: String = "Pending"
}
here CurrentStatusText is changed by some other callback method that update the status.
Here there is Model class i am using
class SampleModel: ObservableObject{
#Published var devicesToUpdated : [ScanedDevice] = []
}
swiftui component:
struct ReviewView: View {
#ObservedObject var model: SampleModel
var body: some View {
ForEach(model.devicesToUpdated){ device in
Text(device.currentStatusText)
}
}
}
Here in UI I want to see the real-time status
I tried using publisher inside ScanDevice class but sure can to use it in 2 layer
You can observe your class ScanedDevice, however you need to manually use a objectWillChange.send(),
to action the observable change, as shown in this example code.
class ScanedDevice: NSObject, Identifiable {
var name: String = "some name"
var currentStatusText: String = "Pending"
init(name: String) {
self.name = name
}
}
class SampleViewModel: ObservableObject{
#Published var devicesToUpdated: [ScanedDevice] = []
}
struct ReviewView: View {
#ObservedObject var viewmodel: SampleViewModel
var body: some View {
VStack (spacing: 33) {
ForEach(viewmodel.devicesToUpdated){ device in
HStack {
Text(device.name)
Text(device.currentStatusText).foregroundColor(.red)
}
Button("Change \(device.name)") {
viewmodel.objectWillChange.send() // <--- here
device.currentStatusText = UUID().uuidString
}.buttonStyle(.bordered)
}
}
}
}
struct ContentView: View {
#StateObject var viewmodel = SampleViewModel()
var body: some View {
ReviewView(viewmodel: viewmodel)
.onAppear {
viewmodel.devicesToUpdated = [ScanedDevice(name: "device-1"), ScanedDevice(name: "device-2")]
}
}
}

Unable to get click event with observedObject in SwiftUI

I have tried below code. However, I am unable to get click event in ObservedObject. Did I made any mistake.
struct ContentView: View {
#StateObject var network = Network()
var body: some View {
VStack {
SecondView(network: network)
Text(self.network.networkObserver.sucess?.description ?? "Nil")
}
}
}
SecondView Code:- Here is the code when I need to have click happened then revert to main content view.
public struct AdsView: View {
#State private var banner: Model?
#State private var image: UIImage?
#State private var scale: Double = 1.0
#ObservedObject var network: Network
public var body: some View {
Group {
if let image = image {
Text("AdSDK mockup. Click on image")
Image(uiImage: image)
.resizable()
.scaledToFit()
.scaleEffect(scale)
.gesture (
TapGesture()
.onEnded { _ in
self.scale -= 0.1
network.networkObserver.sucess = network.networkObserver.sucess ?? false ? false : true
}
)
} else {
Rectangle()
.background(Color.red)
}
}
}
Note:- Network class are in my custom library.
public class Network: ObservableObject {
#Published public var adImage: UIImage?
#Published public var networkObserver = NetworkObserver()
public init() {
}
public func getImage(for imageURL: String) async throws {
}
}
And here is my ObservableObject
public class NetworkObserver {
public var sucess: Bool?
public var error: RequestError?
public init() {
}
}
If you need more information please let me know.
Thank you.
As #workingdogsupportUkraine suggest in comment I need to change my NetworkObserver class to struct.
Add this class somewhere in your code:
#MainActor class DelayedUpdater: ObservableObject {
#Published var value = 0
init() {
for i in 1...10 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i)) {
self.value += 1
}
}
}
}
To use that, we just need to add a #StatedObject property in ContentView, then show the value in our body, like this:
struct ContentView: View {
#StateObject var updater = DelayedUpdater()
var body: some View {
Text("Value is: \(updater.value)")
}
}
We can fix this by sending the change notifications manually using the objectWillChange property I mentioned earlier. This lets us send the change notification whenever we want, rather than relying on #Published to do it automatically.
Try changing the value property to this:
var value = 0 {
willSet {
objectWillChange.send()
}
}

How to inject a Model from the Environment into a ViewModel in SwiftUI

I am trying to MVVM my SwiftUI app, but am unable to find a working solution for injecting a shared Model from #EnvironmentObject into the app's various Views' ViewModels.
The simplified code below creates a Model object in the init() of an example View, but I feel like I am supposed to be creating the model at the top of the app so that it can be shared among multiple Views and will trigger redraws when Model changes.
My question is whether this is the correct strategy, if so how to do it right, and if not what do I have wrong and how do I do it instead. I haven't found any examples that demonstrate this realistically beginning to end, and I can't tell if I am just a couple of property wrappers off, or it I am approaching this completely wrong.
import SwiftUI
#main
struct DIApp: App {
// This is where it SEEMS I should be creating and sharing Model:
// #StateObject var dataModel = DataModel()
var body: some Scene {
WindowGroup {
ListView()
// .environmentObject(dataModel)
}
}
}
struct Item: Identifiable {
let id: Int
let title: String
}
class DataModel: ObservableObject {
#Published var items = [Item]()
init() {
items.append(Item(id: 1, title: "First Item"))
items.append(Item(id: 2, title: "Second Item"))
items.append(Item(id: 3, title: "Third Item"))
}
func addItem(_ item: Item) {
items.append(item)
print("DM adding \(item.title)")
}
}
struct ListView: View {
// Creating the StateObject here compiles, but it will not work
// in a realistic app with other views that need to share it.
// It should be an app-wide ObservableObject created elsewhere
// and accessible everywhere, right?
#StateObject private var vm: ViewModel
init() {
_vm = StateObject(wrappedValue: ViewModel(dataModel: DataModel()))
}
var body: some View {
NavigationView {
List {
ForEach(vm.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
vm.addRandomItem()
}
}
extension ListView {
class ViewModel: ObservableObject {
#Published var items: [Item]
let dataModel: DataModel
init(dataModel: DataModel) {
self.dataModel = dataModel
items = dataModel.items
}
func addRandomItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
// The line below causes Model to be successfully updated --
// dataModel.addItem print statement happens -- but Model change
// is not reflected in View.
dataModel.addItem(newItem)
// The line below causes the View to redraw and reflect additions, but the fact
// that I need it means I am not doing doing this right. It seems like I should
// be making changes to the Model and having them automatically update View.
items.append(newItem)
}
}
}
There are a few different issues here and multiple strategies to handle them.
From the top, yes, you can create your data model at the App level:
#main
struct DIApp: App {
var dataModel = DataModel()
var body: some Scene {
WindowGroup {
ListView(dataModel: dataModel)
.environmentObject(dataModel)
}
}
}
Notice that I've passed dataModel explicitly to ListView and as an environmentObject. This is because if you want to use it in init, it has to be passed explicitly. But, perhaps subviews will want a reference to it as well, so environmentObject will get it sent down the hierarchy automatically.
The next issue is that your ListView won't update because you have nested ObservableObjects. If you change the child object (DataModel in this case), the parent doesn't know to update the view unless you explicitly call objectWillChange.send().
struct ListView: View {
#StateObject private var vm: ViewModel
init(dataModel: DataModel) {
_vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel))
}
var body: some View {
NavigationView {
List {
ForEach(vm.dataModel.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
vm.addRandomItem()
}
}
extension ListView {
class ViewModel: ObservableObject {
let dataModel: DataModel
init(dataModel: DataModel) {
self.dataModel = dataModel
}
func addRandomItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
dataModel.addItem(newItem)
self.objectWillChange.send()
}
}
}
An alternate approach would be including DataModel on your ListView as an #ObservedObject. That way, when it changes, the view will update, even if ViewModel doesn't have any #Published properties:
struct ListView: View {
#StateObject private var vm: ViewModel
#ObservedObject private var dataModel: DataModel
init(dataModel: DataModel) {
_dataModel = ObservedObject(wrappedValue: dataModel)
_vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel))
}
var body: some View {
NavigationView {
List {
ForEach(vm.dataModel.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
vm.addRandomItem()
}
}
extension ListView {
class ViewModel: ObservableObject {
let dataModel: DataModel
init(dataModel: DataModel) {
self.dataModel = dataModel
}
func addRandomItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
dataModel.addItem(newItem)
}
}
}
Yet another object would be using Combine to automatically send objectWilLChange updates when items is updated:
struct ListView: View {
#StateObject private var vm: ViewModel
init(dataModel: DataModel) {
_vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel))
}
var body: some View {
NavigationView {
List {
ForEach(vm.dataModel.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
vm.addRandomItem()
}
}
import Combine
extension ListView {
class ViewModel: ObservableObject {
let dataModel: DataModel
private var cancellable : AnyCancellable?
init(dataModel: DataModel) {
self.dataModel = dataModel
cancellable = dataModel.$items.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
func addRandomItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
dataModel.addItem(newItem)
}
}
}
As you can see, there are a few options (these, and others). You can pick the design pattern that works best for you.
You are probably unable to find a working solution because it is not a valid approach. In SwiftUI we do not use MVVM pattern of view model objects. The View data structs are already the view model that SwiftUI uses to create and update actual views like UILabels, etc. on the screen. You should also be aware that when you use property wrappers like #State it makes our super efficient View data struct behave like an object, but without the memory hog of an actual heap object. If you create extra objects then you are slowing SwiftUI down and will lose the magic like dependency tracking etc.
Here is your fixed code:
import SwiftUI
#main
struct DIApp: App {
#StateObject var dataModel = DataModel()
var body: some Scene {
WindowGroup {
ListView()
.environmentObject(dataModel)
}
}
}
struct Item: Identifiable {
let id: Int
let title: String
}
class DataModel: ObservableObject {
#Published var items = [Item]()
init() {
items.append(Item(id: 1, title: "First Item"))
items.append(Item(id: 2, title: "Second Item"))
items.append(Item(id: 3, title: "Third Item"))
}
func addItem(_ item: Item) {
items.append(item)
print("DM adding \(item.title)")
}
}
struct ListView: View {
#EnvironmentObject private var dataModel: DataModel
var body: some View {
NavigationView {
List {
// ForEach($dataModel.items) { $item in // if you want write access
ForEach(dataModel.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
dataModel.addItem(newItem)
}
}

SwiftUI+Combine - Dynamicaly subscribing to a dict of publishers

In my project i hold a large dict of items that are updated via grpc stream. Inside the app there are several places i am rendering these items to UI and i would like to propagate the realtime updates.
Simplified code:
struct Item: Identifiable {
var id:String = UUID().uuidString
var name:String
var someKey:String
init(name:String){
self.name=name
}
}
class DataRepository {
public var serverSymbols: [String: CurrentValueSubject<Item, Never>] = [:]
// method that populates the dict
func getServerSymbols(serverID:Int){
someService.fetchServerSymbols(serverID: serverID){ response in
response.data.forEach { (name,sym) in
self.serverSymbols[name] = CurrentValueSubject(Item(sym))
}
}
}
// background stream that updates the values
func serverStream(symbols:[String] = []){
someService.initStream(){ update in
DispatchQueue.main.async {
self.serverSymbols[data.id]?.value.someKey = data.someKey
}
}
}
}
ViewModel:
class SampleViewModel: ObservableObject {
#Injected var repo:DataRepository // injection via Resolver
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
func getItem(item:String) -> Item {
guard let item = repo.serverSymbols[item] else { return Item(name:"N/A")}
return ItemPublisher(item: item).data
}
}
class ItemPublisher: ObservableObject {
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:CurrentValueSubject<Item, Never>){
item
.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
Main View with subviews:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item: viewModel.getItem(item: item))
}
}
}
}
struct FavoriteCardView: View {
var item:Item
var body: some View {
VStack {
Text(item.name)
Text(item.someKey) // dynamic value that should receive the updates
}
}
}
I must've clearly missed something or it's a completely wrong approach, however my Item cards do not receive any updates (i verified the backend stream is active and serverSymbols dict is getting updated). Any advice would be appreciated!
I've realised i've made a mistake - in order to receive the updates i need to pass down the ItemPublisher itself. (i was incorrectly returning ItemPublisher.data from my viewModel's method)
I've refactored the code and make the ItemPublisher provide the data directly from my repository using the item key, so now each card is subscribing individualy using the publisher.
Final working code now:
class SampleViewModel: ObservableObject {
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
}
MainView and CardView:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item)
}
}
}
}
struct FavoriteCardView: View {
var itemName:String
#ObservedObject var item:ItemPublisher
init(_ itemName:String){
self.itemName = itemName
self.item = ItemPublisher(item:item)
}
var body: some View {
let itemData = item.data
VStack {
Text(itemData.name)
Text(itemData.someKey)
}
}
}
and lastly, modified ItemPublisher:
class ItemPublisher: ObservableObject {
#Injected var repo:DataRepository
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:String){
self.data = Item(name:item)
if let item = repo.serverSymbols[item] {
self.data = item.value
item.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
}

ObservableObject has a different instantiation in my class

I have an ObservableObject that stores the current country the user is wanting information on and I want this to be shared across all views and classes. I properly declared it in the scene delegate so there is no issue with that.
import Foundation
class GlobalData: ObservableObject {
#Published var whichCountry: String = "italy"
}
This is my main view of where I call an environment object to get whichCountry. When the users click the button it will open ListOfCountriesView() and pass that EnvironemtnObject through it to update the new country the users want.
import SwiftUI
struct InDepthView: View {
#State var showList = false
#EnvironmentObject var globalData: GlobalData
#ObservedObject var data = getDepthData(globalData: GlobalData())
var body: some View {
VStack(alignment: .leading) {
Button(action: { self.showList.toggle() }) {
VStack(alignment: .leading) {
HStack {
Text("\(self.data.globalDatam.whichCountry.uppercased())")
}
}
}
.sheet(isPresented: $showList) {
ListOfCountriesView().environmentObject(GlobalData())
}
}
}
}
import SwiftUI
struct ListOfCountriesView: View {
#EnvironmentObject var globalData: GlobalData
var body: some View {
ScrollView {
VStack(spacing: 15) {
Text("List of Countries")
.padding(.top, 25)
Button(action: {
self.globalData.whichCountry = "usa"
self.presentationMode.wrappedValue.dismiss()
}) {
VStack {
Text("\(self.globalData.whichCountry)")
.font(.system(size: 25))
Divider()
}
}
}
}
}
}
struct ListOfCountriesView_Previews: PreviewProvider {
static var previews: some View {
ListOfCountriesView().environmentObject(GlobalData())
}
}
When the user changes the country I want this class which is inside my InDepthView.swift file to be updated with the new string. But for some reason, it is still displaying "italy" when it should have changed to "usa" based on what happened in ListOfCountriesView(). So I know that there is two instantiations of GlobalData but I'm not sure how to fix this issue. Any help would be greatly appreciated as I have been spending the past two days trying to fix this issue!
class getDepthData: ObservableObject {
#Published var data : Specific!
#Published var countries : HistoricalSpecific!
var globalDatam: GlobalData
init(globalData: GlobalData) {
globalDatam = globalData
print(globalDatam.whichCountry + " init()")
updateData()
}
func updateData() {
let url = "https://corona.lmao.ninja/v2/countries/" // specific country
let session = URLSession(configuration: .default
session.dataTask(with: URL(string: url+"\(self.globalDatam.whichCountry)")!) { (data, _, err) in
if err != nil {
print((err?.localizedDescription)!)
return
}
let json = try! JSONDecoder().decode(Specific.self, from: data!)
DispatchQueue.main.async {
self.data = json
}
}.resume()
}
}
///////
I added this to the code like you mentioned. but recieving an error
import SwiftUI
struct InDepthView: View {
#State var showList = false
#State var pickerSelectedItem = 1
#EnvironmentObject var globalData: GlobalData
#ObservedObject var data: getDepthData
init() {
self.data = getDepthData(globalData: self.globalData)
}
ERRROR : self' used before all stored properties are initialized
You're creating a second GlobalData instance when you call
#ObservedObject var data = getDepthData(globalData: GlobalData())
Edit: Removed example that was passing the environment object in as an argument. That doesn't work and it crashes.
You will need to refactor a bit to either structure your app a bit differently altogether, or you could remove the environment object, and instead initialise GlobalData() in your first view and then just pass it into each subsequent view as an #ObservedObject, rather than as #EnvironmentObject via scene delegate.
The following is pseudocode but I hope clarifies what I mean:
struct ContentView: View {
#ObservedObject var globalData = GlobalData()
var body: some View {
...
NavigationLink("Linky link", destination: SecondView(globalData: globalData, data: getDepthData(globalData: globalData))
}
}
struct SecondView: View {
#ObservedObject var globalData: GlobalData
#ObservedObject var data: getDepthData
init(globalData: GlobalData, data: getDepthData) {
self.globalData = globalData
self.data = getDepthData
}
...
}