`@Observable` in SwiftUI confusion

I currently cannot test the examples yet but I read about some very interesting and highly inconvenient drawback. I think people will run into this issue million of times. On top of that it's very easy to miss and probably hard to debug and drill down to.

So the documentation has the following example and explanation:

A view forms a dependency when its body reads an observable property directly. However, a dependency isn’t formed when a content closure in body reads the property. For example, the following code reads the Book property title inside the content closure of a List. The view’s body doesn’t read this property directly, which means the view won’t update when a book’s title changes.

struct LibraryView: View {
    var books: [Book]

    var body: some View {
        List(books) { book in 
            Text(book.title)
        }
    }
}

To ensure that the display of individual list items update when data changes, refactor the content closure to use a custom view for each list item. For example, the following code displays the book’s title in BookView, which reads the property directly in its body:

struct LibraryView: View {
    var books: [Book]

    var body: some View {
        List(books) { book in 
            BookView(book: book)
        }
    }
}

struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

The WWDC session by @Philippe_Hausler says at around 7:40 something totally different.

"Because SwiftUI tracks access to fields per instance, it means that you can use arrays, optionals, or for that matter, any type that contains your observable models. The donut list view has an array of donut models. Each model itself is '@Observable'. When any of the names of those donuts change, SwiftUI detects the access to that property on that specific instance and tracks it to know when to invalidate the view. So here, when the donut name is changed via the randomize button, the view updates accordingly. This lets you build your models how you want. You can have arrays of models being observed, or even model types that contain other observable model types. The general rule is for Observable, if a property that is used changes, the view will update."

@Observable class Donut {
  var name: String
}

struct DonutList: View {
  var donuts: [Donut]
  var body: some View {
    List(donuts) { donut in
      HStack {
        Text(donut.name)
        Spacer()
        Button("Randomize") {
          donut.name = randomName()
        }
      }
    }
  }
}

Which one is corret?

5 Likes

Okay I finally managed to get Xcode 15 running.

Here's the example code, which I had to further modify.

import SwiftUI
import SwiftData
import Observation

@Observable
class Donut: Identifiable {
  var name: String = "foo"

  init(name: String) {
    self.name = name
  }
}

func randomName() -> String { "\(Int.random(in: 0 ... 100))" }

struct DonutList: View {
  var donuts: [Donut] = [.init(name: "test")]
  var body: some View {
    List(donuts) { donut in
      HStack {
        Text(donut.name)
        Spacer()
        Button("Randomize") {
          donut.name = randomName()
        }
      }
    }
  }
}

struct ContentView: View {
  var body: some View {
    DonutList()
  }
}

#Preview {
  ContentView()
}

The view does update when I press the button! :thinking: Apple docs says it shouldn't. Are the docs outdated?

Further observations.

  1. The original Donut class produces multiple errors:
    • "Class 'Donut' has no initializers" - trivial and easy to fix
    • "@Observable requires property 'name' to have an initial value (from macro 'Observable')"
      • Why is that a requirement when I provide the initial value through the above init?
      • Is this a bug?
  2. The Donut class needs to be Identifiable to work in SwiftUI's list. This is easy to fix and unrelated to Observable.
1 Like

Highly likely, this is very fast moving stuff; remember we had some major discussions / changes to observability on this forum just a couple of weeks before WWDC.

PS. makes me grin every time I hear them saying "this platform" as a placeholder for visionOS :sweat_smile: Apparently the name wasn't finalised at the time of the recording.

2 Likes

Fitting code on slides sometimes requires some things to be elided.

The initializer part is something I think is reasonable to contain within the final version (because it is pretty heinous not to have it and rely on the default value definite initialization). Hence why I am a very strong +1 on the init accessors pitch. To me it is clear we need a solution in that space.

The docs should probably be updated. Since that will (with modifications to account for the initializers and identifiable parts) work today.

5 Likes

Totally understandable and wasn't any complain in the first place.

Great, so it's an outdated doc issue here then. :tada:

So if I understand this correctly, the @Observable macro would inject an init accessor to the property in question so that when assigned to through the observable type's init it will properly initialize the backing storage.

Without the concrete specifics from the proposal, the mental model is something like this:

@Observable
class Donut: Identifiable {
  var name: String {
    init { /* init backing store for name */ }
    ...
  }

  init(name: String) {
    self.name = name // goes through `name.init`
  }
}

Is that correct?

Spot on.

However that currently has some problems w/ the DI SILGen that needs to be resolved.

1 Like