Skip to content

Latest commit

 

History

History
196 lines (143 loc) · 9.33 KB

README.md

File metadata and controls

196 lines (143 loc) · 9.33 KB

Update

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 App

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).

MVVM pattern

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.

enter image description here

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 basic rules

MVVM pattern is following these strict rules:

  1. The View has a reference to the ViewModel, but not vice-versa.
  2. The ViewModel has a reference to the Model, but not vice-versa.
  3. 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.

Model

struct Food: Codable {
    name: String
}

View Model

// 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:

  1. 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.

  2. @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 the body property of any views that rely on the data.

  3. The searchText property uses the @Published property wrappers so it acts like any other Publisher. This means it can be observed and can also make use of any other method that is available to Publisher.

  4. 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.

  5. Use debounce(for:scheduler:) to provide a better user experience. Without it the searchAction function would make a new HTTP request for every letter typed. debounceworks by waiting half a second (0.5) until the user stops typing and finally sending a value.

  6. The searchText changes been observed by sink(receiveValue:) and the searchAction(forFood:) function executes an API call.

  7. 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.

  8. searchFoods(query:) method makes the API Call. See details below.

  9. 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.

  10. Takes the results it receives from the publisher chain and assigns them to the foodResults array.

View

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)
            }
        }
    }
}
  1. 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.

  2. $viewModel.searchText establishes a connection between the values you’re typing in the TextField and the AddMealViewModel‘s searchText property. Using $allows you to turn the searchText property into a Binding<String>. This is only possible because AddMealViewModel conforms to ObservableObject and is declared with the @ObservedObject property wrapper.

API

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
    }
    
}
  1. Uses the new URLSession method dataTaskPublisher(for:) to fetch the data. This method takes an instance of URLRequest and returns either a tuple (Data, URLResponse) or a URLError.
  2. Map the returned Data
  3. The returned data is in JSON format. We use JSONDecoder to decode the response to an Object of type FDCSearchFoodResponce. This type conforms to Codable . So we are able to decode the response to our custom type.
  4. If an error occurred , we use mapError method to handle it.
  5. eraseToAnyPublisher() exposes an instance of AnyPublisher to the downstream subscriber, rather than this publisher’s actual type.

Links

What's next

If you are interested for one more powerfull design pattern, take a look here.