The Perfect Trio: MVVM, SwiftUI, and Combine
An Introduction to SwiftUI and Combine with the MVVM Design Pattern
Overview
MVVM is a UI pattern that separates the UI logic from the business logic, making it easier to develop and test apps. The main actor is the ViewModel which represents the app’s UI state and has properties that describe the state of each UI control.
The MVVM pattern takes advantage of Combine by using it to update the View. Combine provides streams of data that can emit values and then end in success or error. By using Combine with SwiftUI, it gives more power than using SwiftUI alone for creating bindings between the View and the ViewModel.
About the MVVM model approach
In the MVVM design pattern, there are three primary components: the model, the view, and the view model. Each has a specific function.

At a high level, the connections between the three components of the MVVM work in this way:
- The View has a connection to the ViewModel, but not the other way around.
- The ViewModel has a connection to the Model, but not the other way around.
- The View has no connection to the Model or vice-versa.
The magic comes in how the ViewModel updates the View.
Unlock more power using Combine
It is possible to create bindings using only SwiftUI. However, using Combine provides additional capabilities.
Taking as an example the API from CoinGecko and using QuickType to write the decoding logic. You can find also an example project on my GitHub. We will write the response struct in a way similar to this:
// MARK: - CoinResponse
struct CoinResponse: Codable {
let coins: [Coin]
// MARK: - Coin
struct Coin: Codable {
let id, name, apiSymbol, symbol: String
let marketCapRank: Int?
let thumb, large: String
enum CodingKeys: String, CodingKey {
case id, name
case apiSymbol = "api_symbol"
case symbol
case marketCapRank = "market_cap_rank"
case thumb, large
}
}
}
What you need first is to create a Fetcher for the data from the API. Start with the creation of a protocol
. In Swift, a protocol is a blueprint of methods, properties, and other requirements that a class or struct can conform to. By defining a protocol, you can define a set of requirements that your fetcher must adhere to, ensuring that it behaves consistently and is easily testable.
To fetch the data, define a method that returns an AnyPublisher
, a type-erased version of the Publisher
protocol, used when you don't want to expose the concrete type of a publisher.
Publisher
is a fundamental protocol in the Combine framework, which is used for processing and transforming streams of values. It has two type parameters: the first represents the type returned if the computation succeeds, and the second represents the type returned if it fails.
protocol CoinFetchable {
func coins(with text: String) -> AnyPublisher<CoinResponse, CoinError>
}
After creating the Fetcher class responsible for handling network requests.
class CoinFetcher {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
}
Fetch data from the network and handle any errors that occur, using the recently introduced URLSession
function, dataTaskPublisher(for:)
. This function accepts a URLRequest
object and can yield either a tuple containing (Data, URLResponse)
or a URLError
.
In addition, use .mapError(_:)
to convert the error from URLError
to CoinError
by mapping it, since the method returns AnyPublisher<T, CoinError>
.
To transform the JSON received from the server into a complete object use .flatMap(maxPublishers:_:)
with a helper function for the decoding. As the primary objective is to only obtain the initial value that is sent by the network request, the .max(1)
setting is applied.
extension CoinFetcher: CoinFetchable {
func coins(with text: String) -> AnyPublisher<CoinResponse, CoinError> {
return coinData(with: makeCoinGeckoComponents(with: text))
}
private func coinData<T>(with components: URLComponents) -> AnyPublisher<T, CoinError> where T: Decodable {
guard let url = components.url else {
let error = CoinError.network(description: "Couldn't create URL")
return Fail(error: error).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: URLRequest(url: url))
.mapError { error in
.network(description: error.localizedDescription)
}
.flatMap(maxPublishers: .max(1)) { pair in
decode(pair.data)
}
.eraseToAnyPublisher()
}
}
You can erase the generic types returned by flatMap
using the .eraseToAnyPublisher()
to return the Publisher
as an AnyPublisher
.
This approach enhances the usability of the API, and it is also beneficial because the addition of any new transformation can change the return type and expose implementation details.
ViewModel: the main actor
The View’s data is kept in the ViewModel. By conforming it to ObservableObject
and Identifiable
its properties can be used as bindings.
Using the @Published
to mark a property that needs to be observed, it is possible to let the compiler automatically synthesizes a publisher for it. SwiftUI subscribes to this publisher and updates the screen when the property changes.
To store references to network requests and keep them alive, you can create an empty Set<AnyCancellable>
, representing a collection of AnyCancellable
instances. If you don’t retain these references, the network requests you make will not persist, preventing you from receiving server responses.
class HomeViewModel: ObservableObject, Identifiable {
@Published var searchText: String = ""
private var cancellables = Set<AnyCancellable>()
}
To send a request to the API and update the properties with the returned data. You need to make a new request, then use the map(_:)
function to transform the response in the object that you need.
While data retrieval from the server or JSON parsing occurs on a background queue, UI updates must happen on the main
queue.
Using receive(on:)
, you ensure that the updates take place in the correct location.
The publisher is started with sink(receiveCompletion:receiveValue:)
, where a @Published
property containing data from network requests is updated accordingly.
The .store(in: &)
method is used, with the name of the variable storing the collection of network request references after the &
, to keep the reference alive. Otherwise, the network publisher will terminate immediately.
func fetchCoin(forCoin coin: String) {
coinFetcher.coins(with: coin)
.map { response in
response.coins.map(CoinRowViewModel.init)
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
self.coinsData = []
case .finished:
break
}
},
receiveValue: { [weak self] coin in
guard let self = self else { return }
/// Update `coinsData` when a new coin arrives
self.coinsData = coin
})
.store(in: &cancellables)
}
Diving deep into the HTTP requests
When you initialize the ViewModel, in addition to the fetcher parameter, add a scheduler
parameter to specify the queue used for the HTTP request.
Using the @Published
property of your ViewModel is like using any other Publisher
. It can be observed and can utilize any method available to Publisher
.
The debounce(for:scheduler:)
method can be used to improve the user experience. Without it, a new HTTP request would be made for every letter typed. In this way, elements are only published after a certain amount of time, specified as the for
parameter, has passed between events. The scheduler
argument is also passed, meaning that any emitted value will be on that specific queue.
These events are observed with sink(receiveValue:)
and handled with the previously implemented Fetcher. While the .store(in: &)
, as before, is used to store the collection of network request references
init(
coinFetcher: CoinFetcher,
scheduler: DispatchQueue = DispatchQueue(label: "HomeViewModel")
) {
self.coinFetcher = coinFetcher
$searchText
.debounce(for: .seconds(0.5), scheduler: scheduler)
.sink(receiveValue: fetchCoin(forCoin:))
.store(in: &cancellables)
}
SwiftUI and the View
To create a connection between the View and the ViewModel, the @ObservedObject
property delegate comes in help. This subscribes to an ObservableObject
and causes a View to be invalidated whenever it changes. The view is notified of the change and it is re-rendered accordingly.
@ObservedObject var viewModel: HomeViewModel
init(viewModel: HomeViewModel) {
self.viewModel = viewModel
}
Using $
allows a ViewModel
property to be converted into a Binding
. This is only achievable when the ViewModel
conforms to ObservableObject
and is declared with the @ObservedObject
property wrapper.
The ViewModel
properties can be accessed and used in UI components like Text
without using $
if a binding is not needed.
HStack {
TextField("Search coins", text: $viewModel.searchText)
.padding()
.background(Color.white)
}
Navigation in MVVM
In the MVVM design pattern, developers are responsible for deciding how to navigate between screens and who owns that responsibility.
For more complex apps, FlowControllers or Coordinators can be used to manage routing alongside the ViewModel. Alternatively, a hybrid approach using NavigationLink
or the .navigationDestination
modifier can be employed.
Consider using a Builder that acts as a factory to construct and configure views for navigation.
Conclusion
In conclusion, SwiftUI and Combine have opened up a world of possibilities for developers looking to create modern and intuitive user interfaces in their iOS and macOS applications. With its declarative syntax, SwiftUI simplifies the process of designing and laying out user interfaces, while Combine provides a powerful and flexible framework for managing asynchronous events and data flow.
As a continuous learner, I am excited to learn more about the continued evolution of SwiftUI and Combine and how they will be used to shape the future of app development. With the increasing popularity of these frameworks, it is clear that they will play an important role in the development of cutting-edge iOS and macOS applications for years to come.
Thanks for reading! If you want to talk or take a coffee, contact me at vincenz.pascarella@gmail.com or connect via LinkedIn.