Anton
Anton

Reputation: 3257

SwiftUI View not updating based on @ObservedObject

In the following code, an observed object is updated but the View that observes it is not. Any idea why?

The code presents on the screen 10 numbers (0..<10) and a button. Whenever the button is pressed, it randomly picks one of the 10 numbers and flips its visibility (visible→hidden or vice versa).

The print statement shows that the button is updating the numbers, but the View does not update accordingly. I know that updating a value in an array does not change the array value itself, so I use a manual objectWillChange.send() call. I would have thought that should trigger the update, but the screen never changes.

Any idea? I'd be interested in a solution using NumberLine as a class, or as a struct, or using no NumberLine type at all and instead rather just using an array variable within the ContentView struct.

Screenshot

Here's the code:

import SwiftUI

struct ContentView: View {

    @ObservedObject var numberLine = NumberLine()

    var body: some View {
        VStack {
            HStack {
                ForEach(0 ..< numberLine.visible.count) { number in
                    if self.numberLine.visible[number] {
                        Text(String(number)).font(.title).padding(5)
                    }
                }
            }.padding()

            Button(action: {
                let index = Int.random(in: 0 ..< self.numberLine.visible.count)
                self.numberLine.objectWillChange.send()
                self.numberLine.visible[index].toggle()
                print("\(index) now \(self.numberLine.visible[index] ? "shown" : "hidden")")
            }) {
                Text("Change")
            }.padding()
        }
    }
}

class NumberLine: ObservableObject {
    var visible: [Bool] = Array(repeatElement(true, count: 10))
}

Upvotes: 49

Views: 65232

Answers (9)

Phạm Huy Ph&#225;t
Phạm Huy Ph&#225;t

Reputation: 1076

In my case, I was creating multiple instance of ObservableObject class. I had to make it singleton so that the state is shared between views.

Upvotes: 1

William T.
William T.

Reputation: 14321

This answer might not be useful until XCode15/iOS17 is released but I'm using the beta right now.

I'm showing a list of MyItem in a Table and I just want the row to update when the status changes but no matter what I can't get this to happen without some .id() hack on the grid.

I tried conforming to hashable and identifiable and setting .self as the id for the rows, along with a bunch of other things, without getting this to work.

Then I simply tried using the new Observable macro and it works flawlessly.

Original Code:

class MyParentObject: ObservableObject {
    @Published var items = [MyItem]()
}

class MyItem: Identifiable, ObservableObject {
    let id = UUID()
    @Published var status: MyStatusEnum = .ready
}

Working Code:

class MyParentObject: ObservableObject {
    @Published var items = [MyItem]()
}

@Observable class MyItem: Identifiable {
    let id = UUID()
    var status: MyStatusEnum = .ready
}

Upvotes: 8

hmelende
hmelende

Reputation: 1

In my case I came here because I have a Model of a card game, and it is encapsulated inside a View Model. But updates were not happening in the View as the model changed, as expected.

My Model is a class (could be a struct) called X, and the ViewModel is a class called XGame which encapsulates the Model as a private var.

// The View Model
class XGame : ObservableObject {
  private var model = X() // OOPS! This was my mistake here
}

// The View
struct ContentView : View {
  @ObservedObject var game: XGame = XGame()
//etc.
}

So once I fixed the View Model and put @Published in front of the private var model (as per below) then everything started working as expected.

// The View Model
class XGame : ObservableObject {
  @Published private var model = X() // Now it works!
}

Upvotes: 0

kaevinio
kaevinio

Reputation: 520

I was having the same problem and after hours of trial and error it finally worked. My problem was that I setup my views with ForEach in SwiftUI and only referenced the id: \.id. By doing so, views did not update when a @Published var within my @ObservedObject got updated.

The solution is as Amir mentioned: An @ObservedObject needs to conform to Hashable, Identifiable and the ForEach must not use the id to be identified, but instead either explicitly use id: \.self or it also can be omitted in SwiftUI.

Upvotes: 2

Asperi
Asperi

Reputation: 257493

With @ObservedObject everything's fine... let's analyse...

Iteration 1:

Take your code without changes and add just the following line (shows as text current state of visible array)

VStack { // << right below this
    Text("\(numberLine.visible.reduce(into: "") { $0 += $1 ? "Y" : "N"} )")

and run, and you see that Text is updated so observable object works

demo

Iteration 2:

Remove self.numberLine.objectWillChange.send() and use instead default @Published pattern in view model

class NumberLinex: ObservableObject {
    @Published var visible: [Bool] = Array(repeatElement(true, count: 10))
}

run and you see that update works the same as on 1st demo above.

*But... main numbers in ForEach still not updated... yes, because problem in ForEach - you used constructor with Range that generates constant view's group by-design (that documented!).

!! That is the reason - you need dynamic ForEach, but for that model needs to be changed.

Iteration 3 - Final:

Dynamic ForEach constructor requires that iterating data elements be identifiable, so we need struct as model and updated view model.

Here is final solution & demo (tested with Xcode 11.4 / iOS 13.4)

demo2

struct ContentView: View {

    @ObservedObject var numberLine = NumberLine()

    var body: some View {
        VStack {
            HStack {
                ForEach(numberLine.visible, id: \.id) { number in
                    Group {
                        if number.visible {
                            Text(String(number.id)).font(.title).padding(5)
                        }
                    }
                }
            }.padding()

            Button("Change") {
                let index = Int.random(in: 0 ..< self.numberLine.visible.count)
                self.numberLine.visible[index].visible.toggle()
            }.padding()
        }
    }
}

class NumberLine: ObservableObject {
    @Published var visible: [NumberItem] = (0..<10).map { NumberItem(id: $0) }
}

struct NumberItem {
    let id: Int
    var visible = true
}

Upvotes: 40

Amir
Amir

Reputation: 437

The problem is with the function, do not forget to add id: \.self in your ForEach function, and make your Model Hashable, Identifiable.

Upvotes: 2

swiftPunk
swiftPunk

Reputation: 1

There is nothing Wrong with observed object, you should use @Published in use of observed object, but my code works without it as well. And also I updated your logic in your code.


enter image description here


import SwiftUI

struct ContentView: View {
    
    @ObservedObject var model = NumberLineModel()
    @State private var lastIndex: Int?
    
    var body: some View {
        
        VStack(spacing: 30.0) {
            
            HStack {
                
                ForEach(0..<model.array.count) { number in
                    
                    if model.array[number] {
                        Text(String(number)).padding(5)
                    }
                    
                }
                
            }
            .font(.title).statusBar(hidden: true)
            
            Group {
                
                if let unwrappedValue: Int = lastIndex { Text("Now the number " + unwrappedValue.description + " is hidden!") }
                else { Text("All numbers are visible!") }
                
            }
            .foregroundColor(Color.red)
            .font(Font.headline)
            
            
            
            Button(action: {
                
                if let unwrappedIndex: Int = lastIndex { model.array[unwrappedIndex] = true }
                
                let newIndex: Int = Int.random(in: 0...9)
                model.array[newIndex] = false
                lastIndex = newIndex
                
                
            }) { Text("shuffle") }
            
        }
        
    }
}

class NumberLineModel: ObservableObject {
    
    var array: [Bool] = Array(repeatElement(true, count: 10))
    
}

Upvotes: 1

Saurabh Bajaj
Saurabh Bajaj

Reputation: 559

I faced the same issue. For me, replacing @ObservedObject with @StateObject worked.

Upvotes: 32

Anton
Anton

Reputation: 3257

Using your insight, @Asperi, that the problem is with the ForEach and not with the @ObservableObject functionality, here's a small modification to the original that does the trick:

import SwiftUI

struct ContentView: View {

    @ObservedObject var numberLine = NumberLine()

    var body: some View {
        VStack {
            HStack {
                ForEach(Array(0..<10).filter {numberLine.visible[$0]}, id: \.self) { number in
                    Text(String(number)).font(.title).padding(5)
                }
            }.padding()

            Button(action: {
                let index = Int.random(in: 0 ..< self.numberLine.visible.count)
                self.numberLine.visible[index].toggle()
            }) {
                Text("Change")
            }.padding()
        }
    }
}

class NumberLine: ObservableObject {
    @Published var visible: [Bool] = Array(repeatElement(true, count: 10))
}

Upvotes: 5

Related Questions