43

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))
}
Anton
  • 2,512
  • 2
  • 20
  • 36
  • 1
    in my case it was a stupid issue, i had to put the observedObject being updated on main thread... suddentlly everythiing start working as expected – João Serra Mar 25 '21 at 16:13

8 Answers8

38

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
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thank you, @asperi! The question remains for me of why the `if number.visible` clause surrounding the `Text()` views didn't prevent the invisible numbers from showing. Is it that when the `ForEach` iterates over a constant collection it doesn't even receive the notification that the `numberLine` in its closure has changed? – Anton Mar 31 '20 at 23:08
  • I was having the same problem on iOS 13 with xcode 11.6, but not on iOS 14 beta. I was using indeed the range approach. then I changed to have an identifiable model, but the problem persisted. Adding @published to the property made the trick for me. iOS 14 beta worked all the time. – Pacu Aug 21 '20 at 14:43
  • What is the expected error? When I run the original code by the poster in Xcode 12.1, clicking the button does change the output. So either I'm misunderstanding the error or Swift changed how it handles this case? – cgold Nov 10 '20 at 16:02
  • 2
    Thank you!! I owe a huge debt of thanks to: 1) Anton for taking the time to post this code, 2) @Asperi for knowing the answer and taking the time to write it out, 3) StackOverflow for having created a platform where the two of you could find each other, and 4) Google for miraculously transforming my rather vague query into the _exact_ StackOverflow link that that I needed. It took me all of 2 minutes to debug a thing I was fearing would take hours. Who knew about this behavior of `ForEach` !? – jeremyabannister Nov 11 '20 at 23:59
24

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

Saurabh Bajaj
  • 473
  • 4
  • 7
  • 2
    This change solving the problem has no connection with the problem here. – S. Kaan Özkaya Apr 15 '21 at 13:58
  • 8
    I found this question facing a similar issue regarding async changes not updating an ObservedObject, and indeed this change solved my problem as well. No idea why, but if anyone comes across this thread be sure to give this a try. – Mike McCartin Jun 15 '21 at 18:13
  • An instance of `ObservableObject` wrapped in `@StateObject` is tied to the view's lifecycle, while one in `@ObservedObject` is not. I don't know why it solves anything in this example, though. – Jordan Niedzielski Oct 05 '22 at 10:02
4

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))
}
Anton
  • 2,512
  • 2
  • 20
  • 36
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))
    
}

ios coder
  • 1
  • 4
  • 31
  • 91
  • Thank you, swiftPunk, but your code is solving a different problem from mine. Yours works because it replaces every eliminated number with another, resulting in a constant number of items in the ForEach. As @Asperi pointed out above, mine failed because it required a variable number of items on the screen… but the ForEach variety I was using was the one requiring a constant number of items. He explains it well in his solution, and mine merely streamlines his. – Anton Apr 03 '21 at 17:21
1

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

Amir
  • 349
  • 3
  • 4
0

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.

kaevinio
  • 351
  • 1
  • 7
0

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!
}
hmelende
  • 1
  • 4
0

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
}
William T.
  • 12,831
  • 4
  • 56
  • 53