SwiftUI and REST (Episode III)

In this final post, we’ll take a look at one of the latest features introduced at WWDC 23 last June: the new Observation framework.

The idea is that this framework will replace Combine, so for those starting to develop on iOS now, it’s better to start with it – it’s the future. Let’s see how it works using the structures from the first episode SwiftUI and REST (Episode I). Our goal is to download a list of books using the Google Books API.

Just for the lazy ones, I’m reposting the structures here:

struct GoogleBooks: Codable {
    var kind: String = ""
    var totalItems: Int = 0
    var items: Array<GoogleBook> = []
}

struct GoogleBook: Codable {
    var id: String
    var volumeInfo: VolumeInfo?
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(String.self, forKey: .id)
        volumeInfo = try container.decodeIfPresent(VolumeInfo.self, forKey: .volumeInfo)
    }
}

struct VolumeInfo: Codable {
    var title: String
    var subtitle: String?
    var authors: Array<String>?
    var imageLinks: ImagesLink
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
        title = try container.decode(String.self, forKey: .title)
        authors = try container.decodeIfPresent(Array<String>.self, forKey: .authors)
        imageLinks = try container.decode(ImagesLink.self, forKey: .imageLinks)
    }
}

struct ImagesLink: Codable {
    var smallThumbnail: String
}

Just a quick refresher before we dive into the new content: It’s important to remember that in Swift, Codable is a type alias for the Decodable and Encodable protocols. This means it can both decode from and encode to external representations, like JSON. Also, it’s worth noting that decodeIfPresent is crucial for decoding optional fields. This method allows us to safely decode data which might or might not be present, without crashing our app if the field is missing.

Observable

Now, let’s introduce the latest updates:

@Observable
class BookViewModel {
    var books: [GoogleBook] = []
    var state: State = .Loaded
    
    enum State {
        case Loading, Loaded, Error
    }
    
    func loadBooks() async {
        state = .Loading
        guard let url = URL(string: "https://www.googleapis.com/books/v1/volumes?q=intitle:swift") else {
            print("Invalid URL")
            state = .Error
            return
        }
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let decoder = JSONDecoder()
            do {
                let decodedResponse = try decoder.decode(GoogleBooks.self, from: data)
                books = decodedResponse.items
                state = .Loaded
                return
            } catch let DecodingError.dataCorrupted(context) {
                print(context)
            } catch let DecodingError.keyNotFound(key, context) {
                print("Key '\(key)' not found:", context.debugDescription)
                print("codingPath:", context.codingPath)
            } catch let DecodingError.valueNotFound(value, context) {
                print("Value '\(value)' not found:", context.debugDescription)
                print("codingPath:", context.codingPath)
            } catch let DecodingError.typeMismatch(type, context)  {
                print("Type '\(type)' mismatch:", context.debugDescription)
                print("codingPath:", context.codingPath)
            } catch {
                print("error: ", error)
            }
        } catch {
            print("error: ", error)
            
        }
        state = .Error
    }
}

The key aspect of our approach is that the BookViewModel class is defined as Observable. This means that each property of the class is observed, allowing us to catch and respond to any changes. In this model, the books property stores the retrieved books, while the state property keeps track of the status of the GET operation, which can be Loading, Loaded, or Error.

Now, let’s explore how to use this model effectively:

  1. Observing Changes: Since BookViewModel is observable, any changes to its properties will automatically update the views that depend on this data. This is extremely handy for reactive UI updates.
  2. Managing State: The state property of our view model allows us to easily handle different states in our UI. For example, we can show a loading indicator when the state is Loading, display the data when it’s Loaded, or show an error message if it’s Error.
  3. Fetching Data: To fetch data, we can call a method on our view model (loadBooks) which will update the books and state properties based on the result of the API call.
  4. Building the UI: In our SwiftUI view, we can use @State to instantiate our view model. SwiftUI will then re-render the view whenever the observed properties of the view model change.
  5. Error Handling: The Error state in our view model can be used to trigger alerts or error messages in our UI, informing the user if something goes wrong during the data fetching process.

Below the code snippet demonstrating how to integrate BookViewModel into a SwiftUI view.


struct ContentView: View {
    @State var booksModel = BookViewModel()
    var body: some View {
        NavigationStack {
            switch booksModel.state {
            case .Loading:
                ProgressView()
            case .Error:
                Text("Error")
            case .Loaded:
                List {
                    ForEach(booksModel.books, id:\.id) { book in
                        if let volumeInfo = book.volumeInfo {
                            Text(volumeInfo.title)
                        }
                    }
                }
            }
        }.task {
            await booksModel.loadBooks()
        }
    }
}

When the view appears, we initiate loadBooks. Depending on the status, users may see a ProgressView, a list of books, or an error message.

This approach is cleaner compared to what we saw in the previous episode, and I highly recommend it for its clarity and efficiency.

(For those who prefer using Combine, I suggest pairing it with Alamofire. This famous framework is well-known in the developer community and doesn’t need much introduction from me.)

Conclusion

As we reach the end of this short series, I’d like to introduce another new tool: swift-openapi-generator. This tool enables the automatic generation of client and server code using a YAML file that defines an API with the OpenAPI-Specification. This is particularly useful for larger projects where such documentation is essential to define the API, acting as a “contract” between different applications involved (backend, frontend, and mobile). Here is the swift-openapi-generator-tutorial. Keep in mind that it’s not entirely stable yet, and some aspects may still change. In the future, I might write more about it. Stay tuned.

share this post with friends

Picture of Nicola De filippo

Nicola De filippo

I'm a software engineer who adds to the passion for technologies the wisdom and the experience without losing the wonder for the world. I love to create new projects and to help people and teams to improve

Leave a comment

Your email address will not be published. Required fields are marked *

Who I am

I'm a software engineer who adds to the passion for technologies the wisdom and the experience without losing the wonder for the world. I love to create new projects and to help people and teams to improve.

Follow Me Here

Get The Latest Updates

Periodically receive my super contents on coding and programming

join the family;)

Recent Posts