Skip to content

Instantly share code, notes, and snippets.

@mbrandonw
Last active August 16, 2023 11:09
Show Gist options
  • Save mbrandonw/572b526b6a94cc778cfe71bed3b69080 to your computer and use it in GitHub Desktop.
Save mbrandonw/572b526b6a94cc778cfe71bed3b69080 to your computer and use it in GitHub Desktop.
WWDC 2023 Feedbacks

Cannot use #if canImport with @ObservationIgnored

Description

It would be nice to be able to use canImport(Observation) to sprinkle in bits of observation code when supported, but unfortunately it doesn't seem to play nicely with some of the accessor macros.

Steps to reproduce

Past the following code into a Swift project:

#if canImport(Observation)
  @Observable
#endif
class Feature {
  #if canImport(Observation)
    @ObservationIgnored
  #endif
  var unobservedValue: Int = 0
}

However, the @ObservationIgnored macro generates the following invalid Swift code:

#if canImport(Observation)
    @ObservationIgnored
  #endif @ObservationIgnored private var _unobservedValue: Int = 0

Expected behavior

I would hope the macro would generate the following valid Swift code:

#if canImport(Observation)
  @ObservationIgnored
#endif
private var _unobservedValue: Int = 0

Environment

  • swift-driver version: 1.82.2 Apple Swift version 5.9 (swiftlang-5.9.0.114.6 clang-1500.0.27.1)
  • Xcode 15.0
  • Build version 15A5160n

Granular animations with @Observable objects

FB12403312

It is not possible to mutate two different pieces of state on an @Observable model with two different animations. The following code has two counts where one is incremented without animation and the other incremented with. However, neither animate:

import Observation
import SwiftUI

struct State {
  var count1 = 0
  var count2 = 0
}
@Observable
class CounterModel {
  var state = State()

  func increment() {
    self.state.count1 += 1
    withAnimation {
      self.state.count2 += 1
    }
  }
}
struct CounterView: View {
  let model = CounterModel()

  var body: some View {
    VStack {
      HStack {
        Text("\(self.model.state.count1)")
          .padding()
        Text("\(self.model.state.count2)")
          .padding()
      }
      .font(.system(size: 100))

      Button("Increment") { self.model.increment() }
    }
    .padding()
  }
}
#Preview("Counter") {
  CounterView()
}

The equivalent code using ObservableObject works as I would expect:

class LegacyCounterModel: ObservableObject {
  @Published var state = State()
  func increment() {
    self.state.count1 += 1
    withAnimation {
      self.state.count2 += 1
    }
  }
}

struct LegacyContentView: View {
  @ObservedObject var legacyModel = LegacyCounterModel()

  var body: some View {
    VStack {
      HStack {
        Text("\(self.legacyModel.state.count1)")
          .padding()
        Text("\(self.legacyModel.state.count2)")
          .padding()
      }
      .font(.system(size: 100))

      Button("Increment") { self.legacyModel.increment() }
    }
    .padding()
  }
}
#Preview("Legacy counter") {
  LegacyContentView()
}

Programmatic navigation in NavigationStack does not work with @Observable

FB12969309

The code below shows a simple navigation stack whose path is controlled by an @Observable class. If you use a NavigationLink(state:) to push onto the path, things work fine. But if you use a Button to append data to the path, it does not work at all.

Paste the following code into a SwiftUI project to test:

import SwiftUI

@Observable
class Feature {
  var path: [Int] = []
}

struct ContentView: View {
  @State var model = Feature()

  var body: some View {
    let _ = print(self.model.path)
    NavigationStack(path: self.$model.path) {
      Form {
        NavigationLink(value: 1) {
          Text("This works")
        }
        Button {
          self.model.path.append(2)
        } label: {
          Text("This does not work")
        }
      }
      .navigationDestination(for: Int.self) { int in
        Text("\(int)")
      }
    }
  }
}

#Preview {
  ContentView()
}

✅ Fixed in Xcode 15 beta 3

Runtime crash in Equatable conformance of @Observable when used with @State

FB12292331.md, Swift bug

If you conform an @Observable class to the Equatable protocol, you will get a runtime crash in the implementation of == if you access any of the type's fields if you use the model with @State in the view.

To reproduce, run the following in a simulator or in a preview:

import Observation
import SwiftUI
@Observable
class Feature: Equatable {
  var count = 0
  var name = ""
  static func == (lhs: Feature, rhs: Feature) -> Bool {
    lhs.count == rhs.count
      && lhs.name == rhs.name
  }
}
struct FeatureView: View {
  @State var model: Feature
  var body: some View {
    Text("Feature")
  }
}
#Preview {
  FeatureView(model: Feature())
}
@main
struct FeedbacksApp: App {
  var body: some Scene {
    WindowGroup {
      FeatureView(model: Feature())
    }
  }
}

It instantly crashes when lhs.count is accessed.

This only happens when using @State with the observable model. Commenting that out:

struct FeatureView: View {
  /*@State*/ var model: Feature}

…runs without crashing.

It appears the reason for this is that SwiftUI is doing some strange runtime/dynamic hackery to completely change the type of value passed to ==. By the contract of the Equatable protocol the types should be Feature, but at runtime it is actually DeferredValue<Feature>. This seems like a pretty series soundness problem in the Swift compiler.

✅ Fixed in Xcode 15 beta 5

Breakpoints set in macro-generated code don't work

FB12324005

The documentation suggests it should be possible to breakpoint in macro-generated code. From "Inspect an Expanded Macro":

you can set breakpoints inside code generated by a macro

However, if you:

  1. Copy and paste this into a fresh SwiftUI iOS project's "ContentView.swift" file.
  2. Right-click to expand the User class's @Observable macro.
  3. Expand the name property's @ObservationTracked macro.
  4. Set a breakpoint in the body of the name property's computed get.
  5. Run the application.

The application will not stop on the breakpoint. Further, the breakpoint will be grayed out with the text:

Xcode won't pause at this breakpoint because it has not been resolved.

Resolving it requires that:

  • The line at the breakpoint is compiled.
  • The compiler generates debug information that is not stripped out (check the Build Settings).
  • The library for the breakpoint is loaded.
import SwiftUI
import Observation

@Observable final class User {
  var name = "Blob"
}

struct ContentView: View {
  let user = User()

  var body: some View {
    VStack {
      Text("Hello, \(user.name)!")
    }
    .padding()
  }
}

#Preview {
  ContentView()
}

ℹ️ Works as expected

Accessing field after initialization in @Observable loses observation

FB12292196.md

If you provide an initializer for an @Observable class, and access one of the fields after setting all the fields, then observation appears to be broken.

To reproduce run the following code in the simulator, tap the "Present" button, and see that nothing happens. A sheet should appear (note: it does seem to work in a preview):

import SwiftUI

@Observable
class Feature {
  var isPresented = false
  init(isPresented: Bool = false) {
    self.isPresented = isPresented
    _ = self.isPresented  // 👈 Doing this 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())
    }
  }
}

If you remove access to isPresented from the initialize, the application will work:

init(isPresented: Bool = false) {
  self.isPresented = isPresented
  //_ = self.isPresented  // 👈 Doing this breaks observation
}

The behavior also gets weird if you put the view in a navigation stack and drill down to the view:

@main
struct FeedbacksApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationStack {
        NavigationLink("Go") {
          FeatureView(model: Feature())
        }
      }
    }
  }
}

When you run this, tap the "Go" button, then tap the "Present" button, and you will see that a sheet comes up but then is immediately dismissed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment