Using @Observable cause the view model and view to initialise. ObservableObject works correctly

@Observable cause incorrect behaviour.


/**
Using `@Observable` causes this view model to initialise every time the `increment()` is called.
However marking `index` as `@ObservationIgnored` doesn't not cause it to init , which is the expected behaviour.

 Using `@ObservedObject`, `ObservableObject` and `@Published` pattern causes correct behaviour.

 There is also a bug when there is a //comment after the class name declaration that uses `@Observable`, then the macro doesn't compile.
 */
@Observable
//public class Test2328ViewModel /*k <-- uncomment this line's initial comment , works fine*/
//public class Test2328ViewModel //k <-- uncomment this line's initial comment,  for compile failure
public class Test2328ViewModel
{

    typealias T = String//VoltronViewModel
    
//    @Published
    var models : [T] = ["observable causes the view to init every time" , "i don't see this string because of which"]
    
//    @Published // @Published  works fine on `index`
//    @ObservationIgnored // Using this causes correct behavious
    var index : Int = 0
    
//    @Published 
    var pogmodel : T!
    
    init() {
        pogmodel = models[index]
        print("init is called , so the index resets to 0.  But isn't called if index is marked as @ObservationIgnored. Then the code behaves as intended.")
    }

    
    func increment() {
        var x = index + 1
        if x >= models.count {
           x = 0
        }
        index = x
        pogmodel = models[index]
    }
    
}

struct  Test2328View: View {
    
//    @ObservedObject 
    var model : Test2328ViewModel
    
    var body : some View {

        VStack(spacing : 0.0) {
            
            Text("\(model.pogmodel)")
            
            Text("\(model.index)")
            
            Button(action: {
                model.increment()
            }, label: {
                Image(systemName: "arrowtriangle.left.fill").font(.system(size: 20.0))
            })
        }
    }
    
}


struct Test8238ParentView : View {
    
    init() {
        print("super init called")
    }
    
    var body : some View{
        Test2328View(model: .init())
    }
}


#Preview {
    Test8238ParentView()
}

I believe correct way to use it with the old observation machinery would be to use StateObject instead of creating a new model. You are just lucky the view is not recreated.

Corrected app
import SwiftUI
/* import Observation */

/* @Observable */ class TestViewModel: ObservableObject {
    @Published var models = ["one" , "two"]
    @Published /* @ObservationIgnored */ var index: Int = 0
    @Published var pogmodel: String! = nil
    init() {
        pogmodel = models[index]
        print("init!!!")
    }
    func increment() {
        index = (index + 1) % models.count
        pogmodel = models[index]
    }
}

struct TestView: View {
    @StateObject var model = TestViewModel()
    var body: some View {
        VStack(spacing : 0.0) {
            Text("\(model.pogmodel)")
            Text("\(model.index)")
            Button("increment") {
                model.increment()
            }
        }
    }
}

struct ParentView: View {
    var body: some View{
        TestView()
    }
}

#Preview {
    ParentView()
}

@main struct TheApp: App {
    var body: some Scene {
        WindowGroup { ParentView() }
    }
}

Having said that, I don't know the equivalent of StateObject in the new observation machinery.

Well found. This is a separate issue. Minimal example:

@Observable class C //
{}
// 🛑 Error: Expected '>' to complete generic argument

I believe it's just @State. @State private var observable = SomeObservable()

2 Likes
Indeed, that works.
@Observable class TestViewModel /*: ObservableObject*/ {
    /*@Published*/ var models = ["one" , "two"]
    /*@Published*/ /* @ObservationIgnored */ var index: Int = 0
    /*@Published*/ var pogmodel: String! = nil
    init() {
        pogmodel = models[index]
        print("init!!!")
    }
    func increment() {
        index = (index + 1) % models.count
        pogmodel = models[index]
    }
}

struct TestView: View {
    /*@StateObject*/ @State var model = TestViewModel()
    var body: some View {
        VStack(spacing : 0.0) {
            Text("\(model.pogmodel)")
            Text("\(model.index)")
            Button("increment") {
                model.increment()
            }
        }
    }
}

struct ParentView: View {
    var body: some View{
        TestView()
    }
}

#Preview {
    ParentView()
}

@main struct TheApp: App {
    var body: some Scene {
        WindowGroup { ParentView() }
    }
}

I think it is an Observable issue. In the code below I am using pogModel as computed property instead of setting a new property and it works fine (view isn't reinitialised).

public class Test2328ViewModel
{
//    @ObservationIgnored // Using this causes correct behavious
    var index : Int = 0

    var pgmodels : [String] = ["observable causes the view to init every time" , 
                               "i don't see this string because of which"]
    

    var pogmodel : String
    {
        pgmodels[index]
    }
    
    init() {
//        self.pogmodel = pgmodels[index]
        print("init is called , so the index resets to 0.  But isn't called if index is marked as @ObservationIgnored. Then the code behaves as intended.")
    }

    func increment() {
        var x = index + 1
        if x >= pgmodels.count {
           x = 0
        }
        index = x
//        pogmodel = pgmodels[index]
    }
    
}

Think about it this way: views are value types. They could be initialised on a whim whenever SwiftUI wants so (or thinks it wants so) and it could happen even if nothing is changing on the screen. Ditto for the body callouts - SwiftUI tries to call "body" as rare as possible but there is no guarantee.

It would be a fragile fix. You did another seemingly unrelated change and back to square one:

Example
import SwiftUI
import Observation

@Observable public class TestViewModel {
    var index = 0
    var unrelated = 0
    var pgmodels = ["one", "two"]
    
    var pogmodel: String {
        pgmodels[index]
    }
    
    init() {
        unrelated += 1
    }

    func increment() {
        index = (index + 1) % pgmodels.count
        unrelated += 1
    }
}

struct TestView: View {
    var model: TestViewModel
    var body: some View {
        VStack(spacing : 0.0) {
            Text("\(model.pogmodel)")
            Text("\(model.index)")
            Button("increment") {
                model.increment()
            }
        }
    }
}

struct ParentView: View {
    var body: some View{
        TestView(model: TestViewModel())
    }
}

#Preview {
    ParentView()
}

@main struct TheApp: App {
    var body: some Scene {
        WindowGroup { ParentView() }
    }
}

This is a subtle gotcha of @Observable and unfortunately it is working as expected. By accessing an observed field in the object's init, you can unwittingly cause the parent view to observe that state.

Here is a very small reproduction of the problem:

import SwiftUI

@Observable
class Feature {
  var isPresented = false
  init(isPresented: Bool = false) {
    self.isPresented = isPresented
    _ = self.isPresented  // 👈 Accessing any observable field "breaks" observation.
  }
}
struct FeatureView: View {
  @Bindable var model: Feature
  var body: some View {
    Button("Present") {
      self.model.isPresented = true
    }
    .sheet(isPresented: self.$model.isPresented) {
      Text("Hi")
    }
  }
}
@main
struct FeedbacksApp: App {
  var body: some Scene {
    WindowGroup {
      FeatureView(model: Feature())
    }
  }
}

The mere act of accessing an observable field inside Feature's init causes the parent view to observe that field, and so when the field changes the parent re-computes the body, thus leading to a new Feature object being created and passed to FeatureView.

Using @State can fix it because that prevents the parent from passing another object to the child. Another workaround would be to use the underscored properties in the init instead:

init(isPresented: Bool = false) {
  self._isPresented = isPresented
  _ = self._isPresented  // ✅
}

That works because the underscored properties are not observed, hence you do not accidentally cause the parent view to observe that field.

5 Likes