MVVM (Model-View-ViewModel) is a UI design pattern created to help us separate logic from our views, in turn this helps us write more succinct, maintainable modules for our apps.

We're going to create a simple app that pulls down a list of Pokemon from https://pokeapi.co/ and display them in a table, simple enough, but i'll show you how to use combine to make network requests, parse the incoming data and render that in a SwiftUI List.

By the end of this post you should be comfortable in the following.

  • Create a ViewModel for you SwiftUI views
  • Use data binding to communicate model changes to SwiftUI views
  • Use Combine to make network requests and handle responses

The complete project for this article can be found on github

Open up Xcode and create a new project, select Empty View and make sure SwiftUI is selected in the dropdown.

You'll be presented with your default ContentView, let's quickly change the name of this from ContentView to PokemonListView. Right click (or cmd click) on the ContentView declaration and click Refactor->Rename, change this to PokemonListView, this will change the name in the file, the filename and the reference in SceneDelegate.swift. The only thing left to do is rename ContentView_Previews at the bottom of the PokemonListView file to PokemonListView_Previews and replace the ContentView inside with PokemonListView.


MVVM Explained

As stated earlier, MVVM stands for Model-View-View Model, and is a UI design pattern geared towards the separation of concerns between our models and views.

As you can see in the above diagram, the ViewModel is responsible for communication between the view and the model, there are many flavours or ViewModel, but the most common scenario uses data bindings to pass updates from the ViewModel to the View. You could use something like Key Value Observation, but this task is made much easier using a framework like combine.


API Layer

We're going to start off defining our networking module, open a new blank Swift file called APIService and add the following code.

import Foundation
import Combine

protocol APIService {
    func request<T: Decodable>(with builder: RequestBuilder) -> AnyPublisher<T, APIError>
}

This is fairly simple, a protocol defining a single function, it accepts a RequestBuilder as it's only parameter which we've yet to define, returns a Publisher of type AnyPublisher with our generic type T and APIError which we also need to define. Open a new file called RequestBuilder and add the following.

protocol RequestBuilder {
    var urlRequest: URLRequest {get}
}

Another simple protocol that requires the conforming type to provide a URLRequest instance.

Lastly we need to define APIError, an enum conforming to swift's Error protocol. Open a new file called APIError and add.

enum APIError: Error {
    case decodingError
    case httpError(Int)
    case unknown
}

We define three error cases here that we'll use later when dealing with the network request. These are all the protocols we need to define next we're going to walk through the implementation.

At this point you may be asking youself what AnyPublisher is, and why is it returned by our APIService. In Combine we have the concept of Subscribers and Publishers; a subscriber listens (or subscribes) to events from a publisher, and in turn a publisher hands off events (publishes) to it's subscribers. This is the foundation on which Combine - and FRP - is built, some other FRP frameworks use different names but the fundamentals are the same. There are many types of publishers apple provides us but we don't necessarily care about the type of publisher we used, we just car about the values, this is where AnyPublisher comes in to play, it uses type erasure to hide implementation details from our API and so we can just deal with the things we care about. If any of this is confusing don't worry, it will hopefully become clearer as we move on.

Create a new file called APISession, this will be responsible for implementing our APIService and performing network operations. If. you're not familiar with combine the implementation may look a little daunting i'll step through it line-by-line.

import Combine

struct APISession: APIService {    
    func request<T>(with builder: RequestBuilder) -> AnyPublisher<T, APIError> where T: Decodable {
        
        // 1
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        
        // 2
        return URLSession.shared
            .dataTaskPublisher(for: builder.urlRequest)
            // 3
            .receive(on: DispatchQueue.main)
            // 4
            .mapError { _ in .unknown }
            // 5
            .flatMap { data, response -> AnyPublisher<T, APIError> in
                if let response = response as? HTTPURLResponse {
                    if (200...299).contains(response.statusCode) {
                    // 6
                    return Just(data)
                        .decode(type: T.self, decoder: decoder)
                        .mapError {_ in .decodingError}
                        .eraseToAnyPublisher()                       
                    } else {
	                // 7
                        return Fail(error: APIError.httpError(response.statusCode))
                            .eraseToAnyPublisher()
                    }
                }
                return Fail(error: APIError.unknown)
                        .eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()
    }
}

The above syntax isn't as scary as it looks, each one of these lines is a function that returns a publisher which is passed down to the next function, which is passed down to the next and so on...until we reach the end of the chain. We then use .eraseToAnyPublisher to type-erase the returned publisher to AnyPublisher. If we don't do this we end up with an incomprehensible chain of wrapped publishers. With that in mind let's step through the code.

  1. Create an instance of JSONDecoder to decode our incoming data. We can use the built in  convertFromSnakeCase keyDecodingStrategy to convert the incoming snake case to camel case.
  2. This is one of those built-in publishers that I mentioned earlier, it takes a URLRequest as a parameter - which we provide via our RequestBuilder instance - performs a network request and returns a publisher of type URLSession.DataTaskPublisher.
  3. This operaor specifies the scheduler we'd like to receive the data on, in this case we want to receive our data on the main thread.
  4. This is an interesting one, if an error occurs mapError will provide an error of type Error that we need to convert - or 'map' - into a type of APIError, after all that's what we've told our publisher to expect in the return type of this function. If something fails at this point we return the type APIError.unknown.
  5. You may be more familiar with this code; flatMap allows us to transform the incoming data from our URLSession.dataTaskPublisher into a new publisher who's type matches the types we expect.
  6. Here's where we do the transformation mentioned above, the Just publisher emits a single value, we pass in our data here which is then handed off to decode which transforms it to our type T (which is of type Decodable), we then use mapError which returns decodingError should something go wrong with the decoding, and finally eraseToAnyPublisher to type erase our complex publisher into something more manageable.
  7. We use the Fail publisher here and return .httpError with the provided http status code from our response. Fail finishes immediately and provides an error of the type we specified in the return type. For example, if we tried to use Fail with an instance of NSError here we'd get a compile time error, as our function expects to return an error of type APIError.

That was a lot to unpack! Combine seems complex when starting out, but I promise it becomes elegantly simple once you're used to dealing with all the modifiers. If you want to dig deeper i'd recommend reading the book Practical Combine by Donny Walls, it's a fantastic book that goes into far more depth on Combine and it's many features.


Integrating the API layer

Now we have a fully functional API layer written in combine - Congratulations 🎉. But that's just half of the equation, next we need to define the modules that integrate with it and provide data back to our view model.

Add a new file called PokemonEndpoint and add the following.

enum PokemonEndpoint {
    case pokemonList
}

extension PokemonEndpoint: RequestBuilder {
    
    var urlRequest: URLRequest {
        switch self {
        case .pokemonList:
            guard let url = URL(string: "https://pokeapi.co/api/v2/pokemon")
                else {preconditionFailure("Invalid URL format")}
            let request = URLRequest(url: url)
            return request
        }
        
    }
}

The PokemonEndpoint enum will define all the possible endpoints we care about for the pokemon API, the extension below adds conformance to our RequestBuilder who's only requirement is an instance of URLRequest. We define urlRequest as a computed property, inside of which we use a switch statement to return a URLRequest instance configured with a URL.

Add another file and call it PokemonService and add the following.

import Foundation
import Combine

protocol PokemonService {
    var apiSession: APIService {get}
    
    func getPokemonList() -> AnyPublisher<PokemonListAPIResponse, APIError>
}

extension PokemonService {
    
    func getPokemonList() -> AnyPublisher<PokemonListAPIResponse, APIError> {
        return apiSession.request(with: PokemonEndpoint.pokemonList)
            .eraseToAnyPublisher()
    }
}

Here we define a single parameter of type APIService and a single function getPokemonList that returns a type of AnyPublisher, but this time we provide a new type - PokemonListAPIResponse - that we're yet to define, and our APIError as the potential error type as normal. We then provide implementation details for the function inside an extension, we use our  apiSession variable and pass in the pokemonList endpoint we defined earlier.

Let's fix that build error and add our PokemonListAPIResponse. Create a new file and add the following.

import Foundation

struct PokemonListAPIResponse: Codable {
    let count: Int
    let next: String
    let previous: String?
    let results: [PokemonListItem]
}

Finally add one more file called PokemonListItem and add.

import Foundation

struct PokemonListItem: Codable, Identifiable {
    let id = UUID()
    let name: String
    let url: String
}

Thanks for sticking with me this long, we're nearly there. The final step brings all of these pieces together in the view model, that's why you're here after all!


The ViewModel

The ViewModel is where everything comes together, we're going to use the API layer we just build to trigger a network request, perform some operations using combine and publish the results to SwiftUI, let's get going...

Create a new file called PokemonListViewModel and add the following code.

import Foundation
import Combine
import SwiftUI

class PokemonListViewModel: ObservableObject, PokemonService {    
    var apiSession: APIService
    @Published var pokemon = [PokemonListItem]()
    
    var cancellables = Set<AnyCancellable>()
    
    init(apiSession: APIService = APISession()) {
        self.apiSession = apiSession
    }
    
    func getPokemonList() {
        let cancellable = self.getPokemonList()
            .sink(receiveCompletion: { result in
                switch result {
                case .failure(let error):
                    print("Handle error: \(error)")
                case .finished:
                    break
                }
                
            }) { (pokemon) in
                self.pokemon = pokemon.results
        }
        cancellables.insert(cancellable)
    }
}

Starting at the top, PokeonListViewModel conforms to ObservableObject so SwiftUI can use it to listen for updates, there really are no requirements for this conformance other than the type be a class. We also conform to PokemonService who's only requirement is that we provide an APIService instance.

Providing dependencies in this way makes our modules much easier to test, for example if I wanted to swap out the implementation of APIService here, I could and the view model wouldn't need to know. I have an active project i'm working on now that uses this method and loads JSON from a file instead of an API.

Next we have the pokemon variable of type [PokemonListItem], this will store the results of our API request. Notice the @Published property wrapper here, this tells SwiftUI that we should inform any subscribers listening when this property changes.

In this example our view model is the subscriber (remember we have both publishers and subscribers); when we subscribe to a publisher we get an instance of AnyCancellable back, this allows us to cancel the subscribtion when we're done with it. We need to store a reference to all of our subscriptions to keep them alive and cancel them when we're done. The cancellables set is how we manage this, we'll keep a record of all our subscriptions until the class's deinit is called, at which time they'll be cancelled.

Finally we have the getPokemonList function, this performs our network request and handles the result. One way to subscribe to a publisher in combine is using the sink function, this takes two closures receiveCompletion, receiveValue and returns the type AnyCancellable that you were introduced to earlier. You can think of sink as the place where all our values end up, and think of the publisher as the taps. receiveCompletion is called when the publisher has finished sending values, this could be because all values were sent or becuase of an error. receiveValue is called whenever the subscriber receives a value from the publisher, it's in here that we update our list of pokemon.


Bringing it all together

Back in PokemonListView, replace the contents with the following.

struct PokemonListView: View {
    @ObservedObject var viewModel = PokemonListViewModel()
    
    var body: some View {
        NavigationView {
            List(self.viewModel.pokemon) { pokemon in
                Text(pokemon.name.capitalized)
            }
            .navigationBarTitle("Pokemon")
        }
        .onAppear {
            self.viewModel.getPokemonList()
        }
    }
}

We create an instance of PokemonListViewModel at the top and use the @ObservedObject property wrapper to listen for updates, this tells SwiftUI to monitor our view model for updates and redraw the UI when we receive any. When the onAppear event happens (think of this as viewDidAppear when using a UIViewController) we call getPokemonList on the viewModel, which triggers the API call and our subscriber chain. We then use a standard List to loop over the pokemon and display the names in a list, the API will send back 20 Pokemon at a time.


Combine is a completely new way of developing apps, don't be put off if any of this is confusing to you, covering the basics of combine was out of scope for this article but there's a lot of content out there that should help get you up to speed. Hopefully this introduction to combine with MVVM and SwiftUI was useful, it's easy to see how these two frameworks complement each other so well and are powerful tools in your swift toolkit.

This article covered the following.

  • Setup SwiftUI views using MVVM and data bindings
  • Make network requests using URLSession's DataTaskPublisher
  • Create a publisher chain and manipulate incoming data streams
  • Subscribe to Publishers and handle incoming data streams

The complete project for this article can be found on github

If you found this useful, please feel free to reach out on Twitter!

Thanks for reading ✌️