Beginning iOS Programming with Swift and SwiftUI (iOS 16 and Xcode 14)

Chapter 16
Working with Observable Objects and Combine

Failure is an option here. If things are not failing, you are not innovating enough.

– Elon Musk

Previously, we added the Review screen for users to rate a restaurant. The rating buttons are not yet functional. When a rating is selected, the expected behaviour is that the review view dismisses itself and the selected rating will show up in the detail view. In this chapter, we will discuss the implementation.

Figure 16-1. Displaying the rating in the detail view
Figure 16-1. Displaying the rating in the detail view

On top of that, I will give you a brief introduction to Combine, which was a new framework introduced along with SwiftUI. Combine lets you easily monitor a single object and get notified of changes. Working along with SwiftUI, we can trigger an update of a view without writing a line of code. Everything is handled behind the scenes by SwiftUI and Combine.

The Problem with our Existing Design

Now let's see how to handle the rating selection. If you open Restaurant.swift, you should notice that the Restaurant struct doesn't have a property to store the user's rating. We can add a new property named rating in the Restaurant struct like this:

var rating: Rating?

The rating variable is defined as an optional because a user may not give the restaurant a rating. With this change, we also need to update the init method like this:

init(name: String, type: String, location: String, phone: String, description: String, image: String, isFavorite: Bool = false, rating: Rating? = nil) {
    self.name = name
    self.type = type
    self.location = location
    self.phone = phone
    self.description = description
    self.image = image
    self.isFavorite = isFavorite
    self.rating = rating
}

Now let's head back to ReviewView.swift. To detect the user selection, we can attach the .onTapGesture to the HStack view and place it after the .animation modifier:

.onTapGesture {
    self.restaurant.rating = rating
    self.isDisplayed = false
}

Oops! Xcode immediately prompts us the following error:

Figure 16-2. self is immutable error
Figure 16-2. self is immutable error

The reason is that the restaurant variable is immutable, which means we are not allowed to update its values.

So, how can we update the rating property of the restaurant?

Should we annotate the restaurant variable with @State? This can fix the error and update the rating. However, the change is only visible to ReviewView. How can we notify the change of rating to the detail view for displaying the user's selected rating?

Using Observable Object

The Combine framework comes with a protocol called ObservableObject. By adopting this protocol, the object itself can notify other views whenever its property's value is changed.

To use ObservableObject, we need to make some changes to the Restaurant struct. To adopt ObservableObject, we have to declare Restaurant as a class instead of a struct.

Now open Restaurant.swift and replace the content like this:

import Combine

class Restaurant: ObservableObject {

    enum Rating: String, CaseIterable {
        case awesome
        case good
        case okay
        case bad
        case terrible

        var image: String {
            switch self {
            case .awesome: return "love"
            case .good: return "cool"
            case .okay: return "happy"
            case .bad: return "sad"
            case .terrible: return "angry"
            }
        }

    }

    @Published var name: String
    @Published var type: String
    @Published var location: String
    @Published var phone: String
    @Published var description: String
    @Published var image: String
    @Published var isFavorite: Bool = false
    @Published var rating: Rating?

    init(name: String, type: String, location: String, phone: String, description: String, image: String, isFavorite: Bool = false, rating: Rating? = nil) {
        self.name = name
        self.type = type
        self.location = location
        self.phone = phone
        self.description = description
        self.image = image
        self.isFavorite = isFavorite
        self.rating = rating
    }
}

If you compare the code with the original Restaurant struct, they look very similar. The Rating enum and the init method are the same. We just declared Restaurant as a class and adopted the ObservableObject protocol. On top of that, all properties are annotated with @Published.

@Published is a property wrapper that works along with ObservableObject. When a property is prefixed with @Publisher, this indicates that the publisher (i.e. Restaurant) should inform all subscribers (i.e. views) whenever the property's value is changed.

The Restaurant class is so similar to the original struct that you don't need to make any changes to other code. Most importantly, Xcode no longer complains about the following code added in ReviewView:

.onTapGesture {
    self.restaurant.rating = rating
    self.isDisplayed = false
}

Hit the Play button and test the app on a simulator. You should be able to run it without any errors.

Displaying the Rating in Detail View

Now switch over to RestaurantDetailView.swift. We have to update the detail view to display the selected rating. Look for the code for presenting the restaurant name and type:

VStack(alignment: .leading) {
    Text(restaurant.name)
        .font(.custom("Nunito-Regular", size: 35, relativeTo: .largeTitle))
        .bold()
    Text(restaurant.type)
        .font(.system(.headline, design: .rounded))
        .padding(.all, 5)
        .background(Color.black)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .bottomLeading)
.foregroundColor(.white)
.padding()

To display the rating icon, wrap the VStack view with a HStack view. And then we use an Image view to render the rating. Here is the complete code:

HStack(alignment: .bottom) {
    VStack(alignment: .leading) {
        Text(restaurant.name)
            .font(.custom("Nunito-Regular", size: 35, relativeTo: .largeTitle))
            .bold()
        Text(restaurant.type)
            .font(.system(.headline, design: .rounded))
            .padding(.all, 5)
            .background(Color.black)
    }
    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .bottomLeading)
    .foregroundColor(.white)
    .padding()

    if let rating = restaurant.rating, !showReview {
        Image(rating.image)
            .resizable()
            .frame(width: 60, height: 60)
            .padding([.bottom, .trailing])
            .transition(.scale)
    }
}
.animation(.spring(response: 0.2, dampingFraction: 0.3, blendDuration: 0.3), value: restaurant.rating)

We use if let to verify if restaurant.rating has a value before presenting the image view. If there is no rating, we will not display the image view. To touch up the appearance of the rating image, we added the animation modifier and apply the spring animation.

This is the power of ObservableObject. Recall that the rating property of Restaurant is annotated with @Published, whenever there is any value changes, the object informs all related views.

Run the app in a simulator or in the preview pane. Once you choose a rating in the review screen, the detail view displays the corresponding image with a nice animation.

Figure 16-3. Displaying the rating icon
Figure 16-3. Displaying the rating icon

Summary

In this chapter, I gave you a brief introduction to the Combine framework. With ObservableObject, we can easily monitor value change of an object. There are a lot more to cover for the framework. If you want to dive deeper into Combine, I recommend you to check out these videos:

For reference, you can download the complete Xcode project from http://www.appcoda.com/resources/swift57/swiftui-foodpin-observableobject.zip. The solution to the exercises is included in the project.