Blogg
Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn
Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn
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.
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"
}
}
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.
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.