I added a Watch app that communicates with iPhone app using the Watch Connectivity framework. The next step is to integrate the apps with CloudKit.
The purpose of this demo is to show the basic aspects of MVVM Architecture using SwiftUI & Combine framework.
As I was looking for a nice design for this demo, I found this beautiful template. I exported some basic assets/icons. I made the rest of the design through SwiftUI (Views and Modifiers).
The Model-View-ViewModel (MVVM) pattern is a UI design pattern. It’s a member of a larger family of patterns collectively known as MV*, these include Model View Controller (MVC), Model View Presenter (MVP) and a number of others.
Each of these patterns addresses separating UI logic from business logic in order to make apps easier to develop and test.
One of my favorite design patterns is Flux/Redux. Take a look at another sample I have made here.
MVVM programming with View Models is the new pattern that Apple is recommending developers follow after WWDC this year.
ViewModel, is a special type of model that represents the UI state of the app. For example, it holds the current text of a text field or the items of a List.
MVVM pattern is following these strict rules:
- The View has a reference to the ViewModel, but not vice-versa.
- The ViewModel has a reference to the Model, but not vice-versa.
- The View has no reference to the Model or vice-versa.
Let’s consider a quick example of the MVVM module for a SwiftUI app. We will create a view with a text field and a List. Users would type a food name in the text field and the results would be shown in the List.
We will start with the model layer and move upwards to the UI.
struct Food: Codable {
name: String
}
// 1
class AddMealViewModel: ObservableObject {
// 2
@Published var searchText = ""
@Published var foodResults: [Food] = []
private var searchCancellable: AnyCancellable? {
willSet {
searchCancellable?.cancel()
}
}
private var disposables = Set<AnyCancellable>()
init() {
//3
$searchText
.dropFirst(1) //4
//5
.debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
.sink(receiveValue: searchAction(forFood:)) //6
.store(in: &disposables) //7
}
func searchAction(forFood query: String) {
searchCancellable = FDCClient
.searchFoods(query: query) //8
.replaceError(with: [Food]())
.map{$0.foods}
.subscribe(on: DispatchQueue.global())
.receive(on: DispatchQueue.main) //9
.assign(to: \.foodResults, on: self) //10
}
}
Let's get started with a quick look on Apple documentation about Combine.
According to the Apple documentation:
The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events. Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers.
Source code explanation:
-
ObservableObject
is a protocol that’s part of the Combine framework. It is used within a custom class/model to keep track of the state. -
@Published
is one of the most useful property wrappers in SwiftUI, allowing us to create observable objects that automatically announce when changes occur. Because the property is marked@Published
, the compiler automatically synthesizes a publisher for it. SwiftUI subscribes to that publisher and and re-invoke thebody
property of any views that rely on the data. -
The
searchText
property uses the@Published
property wrappers so it acts like any otherPublisher
. This means it can be observed and can also make use of any other method that is available toPublisher
. -
When you create the observation,
$searchText
emits its first value. Since the first value is an empty string, you should skip it to avoid an unintended network call. -
Use
debounce(for:scheduler:)
to provide a better user experience. Without it thesearchAction
function would make a new HTTP request for every letter typed.debounce
works by waiting half a second (0.5
) until the user stops typing and finally sending a value. -
The
searchText
changes been observed bysink(receiveValue:)
and thesearchAction(forFood:)
function executes an API call. -
Think of
disposables
as a collection of references to requests. Without keeping these references, the network requests you’ll make won’t be kept alive, preventing you from getting responses from the server. -
searchFoods(query:)
method makes the API Call. See details below. -
Fetching data from the server, or parsing a blob of JSON, happens on a background queue, updating the UI must happen on the main queue.
-
Takes the results it receives from the publisher chain and assigns them to the
foodResults
array.
struct AddMealView: View {
//1
@ObservedObject var viewModel: AddMealViewModel
var body: some View {
VStack {
//2
TextField("Search food", text: $viewModel.searchText)
List(viewModel.foodResults) { food in
Text(food.name)
}
}
}
}
-
ObservedObject is a property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes. In other words, when the
AddMealViewModel
changes, the view will observe its changes. -
$viewModel.searchText
establishes a connection between the values you’re typing in theTextField
and theAddMealViewModel
‘ssearchText
property. Using$
allows you to turn thesearchText
property into aBinding<String>
. This is only possible becauseAddMealViewModel
conforms toObservableObject
and is declared with the@ObservedObject
property wrapper.
For nutrient data we are going to use FoodData Central database. Its an integrated data system that provides expanded nutrient profile data from U.S. DEPARTMENT OF AGRICULTURE. The FoodData Central API provides REST access. The API spec is also available on SwaggerHub here
We will start by defining a protocol with the API actions.
protocol FDCActions {
static func searchFoods(query: String) -> AnyPublisher<FDCSearchFoodResponce, APIError>
static func getFoodDetail(id: Int) -> AnyPublisher<FoodDetail, APIError>
}
We wil use the first method to search for foods and the second in order to get details when the user taps on a specific food. The result type of each method is a Publisher. The first parameter refers to the type it returns if the computation is successful and the second refers to the type if it fails.
struct FDCClient: FDCActions {
private static let jsonDecoder = JSONDecoder()
public static func searchFoods(query: String) -> AnyPublisher<FDCSearchFoodResponce, APIError> {
let baseURL = URL(string: "https://api.nal.usda.gov/fdc/v1/foods/search")!
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true)
components!.queryItems = [
URLQueryItem(name: "api_key", value: "xxxx"),
URLQueryItem(name: "dataType", value: "SR Legacy"),
URLQueryItem(name: "query", value: query),
]
//1
return URLSession.shared.dataTaskPublisher(for: components!.url!)
.map { $0.data } //2
.decode(type: FDCSearchFoodResponce.self, decoder: jsonDecoder)//3
.mapError{ APIError.parseError(reason: "\($0)") } //4
.eraseToAnyPublisher() //5
}
}
- Uses the new
URLSession
methoddataTaskPublisher(for:)
to fetch the data. This method takes an instance ofURLRequest
and returns either a tuple(Data, URLResponse)
or aURLError
. - Map the returned
Data
- The returned data is in JSON format. We use
JSONDecoder
to decode the response to an Object of typeFDCSearchFoodResponce
. This type conforms toCodable
. So we are able to decode the response to our custom type. - If an error occurred , we use
mapError
method to handle it. eraseToAnyPublisher()
exposes an instance ofAnyPublisher
to the downstream subscriber, rather than this publisher’s actual type.
If you are interested for one more powerfull design pattern, take a look here.