Blogg

Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på Twitter

Callista medarbetare Anders Forssell

How to Make Codable Work with Swift's New Observation Framework and the @Observable Macro in SwiftUI

// Anders Forssell

With Swift 5.9 and Xcode 15, introduced in this year’s WWDC, there is a new take on how observation is implemented and integrated with SwiftUI. The new macro capability in Swift has enabled the replacement of the protocol ObservableObject and property wrappers like @Published, @ObservedObject and @StateObject with the new @Observable macro. The new Observation framework brings several advantages aiming for simpler usage and improved performance by updating only the views directly affected by changes in the data model.

Check out the documentation for a comprehensive introduction to the Observation framework.

To explore the capabilities of the new framework, I decided to work through the first part of Apple’s SwiftUI tutorial, which is still based on the previous observation approach, and make it work with the new Observation framework. I wanted to see how difficult it would be and identify any clear advantages with the new approach.

The tutorial guides you through the creation of a landmarks app featuring a list of landmarks and a detail page for each landmark where users can mark it as a favorite. Input data is provided in a JSON file.

Implementing the Data Model

The data model is represented by the ModelData class, which conforms to ObservableObject. It includes a @Published property—an array of Landmark structs. Landmark conforms to Codable for easy deserialization from the JSON file.

The first step was to change ModelData to use the @Observable macro and removing @Published which is no longer needed.

@Observable
final class ModelData {
    var landmarks: [Landmark] = load("landmarkData.json")
}

This worked fine, no other changes were needed here.

Next up was the Landmark struct. I wanted this to be @Observable too (for reasons explained below), even though it is not currently an ObservableObject. However, this wasn’t immediately successful, as adding the macro destroys the conformance to Codable and we get a compilation error.

@Observable
struct Landmark: Identifiable, Codable {
/// Compilation ERROR: Type 'Landmark' does not conform to protocol 'Decodable'
/// Compilation ERROR: Type 'Landmark' does not conform to protocol 'Encodable'
    var id: Int
    var name: String
    var park: String
    var state: String
    var description: String
    var isFavorite: Bool
    var imageName: String
    var coordinates: Coordinates
    
    struct Coordinates: Hashable, Codable {
        var latitude: Double
        var longitude: Double
    }
    
    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
           latitude: coordinates.latitude,
           longitude: coordinates.longitude)
    }
    
    var image: Image {
        Image(imageName)
    }
}
    

Why is that? The diagnostic messages doesn’t give much insight into what the problem is about. But taking a deeper look at what this macro does may help. A neat feature in Xcode lets you expand a macro to reveal what code the compiler actually sees.

@Observable
struct Landmark: Identifiable, Codable {
    var id: Int
    {
    init(initialValue) initializes (_id) {
      _id = initialValue
    }

    get {
      access(keyPath: \.id)
      return _id
    }

    set {
      withMutation(keyPath: \.id) {
        _id = newValue
      }
    }
    
    ...
    
    @ObservationIgnored private var _id: Int
    ...
}

I will not go into details about the inner workings of the Observation framework, but for solving the problem with Codable it is worth noting that all stored properties have been transformed into computed properties which have shadow stored properties prefixed by _ (id becomes _id, name becomes _name, etc).

One solution could be to make a full implementation of the Codable protocol, which you need to do when making ObservableObject conform to Codable, as described in this article by Paul Hudson.

But as it turns out, in this case, we can omit the actual encoding and decoding implementation, it’s sufficient to provide coding keys to satisfy conformance. We also want to make Landmark a class rather than a struct so that we can use it with @Bindable later on. The final implementation of Landmark appears as follows:

@Observable
final class Landmark: Identifiable, Codable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var description: String
    var isFavorite: Bool
    var imageName: String
    var coordinates: Coordinates
    
    struct Coordinates: Hashable, Codable {
        var latitude: Double
        var longitude: Double
    }
    
    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
           latitude: coordinates.latitude,
           longitude: coordinates.longitude)
    }
    
    var image: Image {
        Image(imageName)
    }
    
    /// Codable support
    enum CodingKeys: String, CodingKey {
    case _id = "id"
    case _name = "name"
    case _park = "park"
    case _state = "state"
    case _description = "description"
    case _isFavorite = "isFavorite"
    case _imageName = "imageName"
    case _coordinates = "coordinates"
        
    }
}

Using the Data Model

To use the updated data model we just need to make a few changes.

We no longer use @EnvironmentObject (which only works with ObservableObject), rather we use the property wrapper @Environment.

In the view LandmarkList we replace:

    @EnvironmentObject var modelData: ModelData

with:

    @Environment(\.modelData) private var modelData

Finally, we need to make some changes to the LandmarkDetail view. This view gets a single Landmark from its parent view, displays details about that landmark and makes it possible to mark it as a favorite.

To update the favorite status, the original implementation also needed to retrieve the entire model from the environment and then search for the index of the landmark. This seems a bit unsatisfying and not very performant. Ideally, we should only be concerned with the landmark at hand, updating its favorite status and having the changes propagate to dependent views. Fortunately, we can achieve exactly that using the new @Observable macro.

So the existing code:

struct LandmarkDetail: View {
    @EnvironmentObject var modelData: ModelData
    var landmark: Landmark

    var landmarkIndex: Int {
        modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }

    var body: some View {
        ScrollView {
            MapView(coordinate: landmark.locationCoordinate)
                .ignoresSafeArea(edges: .top)
                .frame(height: 300)
            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)
            VStack(alignment: .leading) {
                HStack {
                    Text(landmark.name)
                        .font(.title)
                    FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
                }
                
                ...
                
}

is replaced with:

struct LandmarkDetail: View {
    
    @Bindable var landmark: Landmark
    
    var body: some View {
         ScrollView {
            MapView(coordinate: landmark.locationCoordinate)
                .ignoresSafeArea(edges: .top)
                .frame(height: 300)
            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)
            VStack(alignment: .leading) {
                HStack {
                    Text(landmark.name)
                        .font(.title)
                    FavoriteButton(isSet: $landmark.isFavorite)
                }
                
                ...

Note that we use @Bindable so that we can provide a binding, $landmark.isFavorite, to FavoriteButton. If we intend to merely use or update properties of Landmark in standard code, @Bindable would not be needed. Either way, the model will be updated, and changes will be appropriately propagated to dependent views.

This is also the reason we need to make Landmark a class rather than a struct. The @Observable macro works with structs as well as classes (unlike ObservableObject which is limited to classes). But if you want to use @Bindable with an @Observable type, it has to be a class.

Conclusion

The new observation framework seems like a big step forward, easier to use than ObservableObject and less confusing with fewer property wrappers like @Published @ObservedObject and @StateObject to learn and understand. It also promises superior performance compared to the older model.

Additionally, it has become easier to provide Codable conformance to your model classes and structs, although it would have been convenient if the @Observable macro had included this feature out of the box. Perhaps, in the future, someone (potentially even myself) might develop an @ObservableAndCodable macro to bridge this gap.

Tack för att du läser Callistas blogg.
Hjälp oss att nå ut med information genom att dela nyheter och artiklar i ditt nätverk.

Kommentarer