Combine and SwiftUI
Swift was introduced at the WWDC 2014 Developers Conference. Apple recommends using the MVC architecture. As practice has shown, the usual MVC architecture is not enough for complex applications. At WWDC 2019, developers are shown SwiftUI and Combine, which should make an iOS developer's life better. Apple now recommends using the MVVM architecture. And now is the time to start understanding these technologies.
Combine
Main idea is Publishers send data to one or more subscribers, the data goes through Operators to Subscribers. Let's look in more details into terms used in this technology.
Publishers
Publishers send values to one or more Subscribers. They conform to the Publisher protocol and declare the output type and any error they produce.
Subscribers
Subscribers subscribe to one specific instance of the Publisher and receive data until the subscription is canceled. They conform to the Subscriber protocol.
Operators
Operators are needed to change data. For example, filter out nil, format data, etc.
Lets start
I'll show you an example using reactive code. Let's start with a model that we will fill from the API.
import Foundation | |
class TenantModel: NSObject, Codable { | |
var name: String? | |
var code: String? | |
var dateFormat: String? | |
var timeFormat: String? | |
var advanceDaysCount: Int? | |
var historyDaysCount: Int? | |
var features: [String]? | |
var timeSlots: [TimeSlot]? | |
var timezone: String? | |
var country: String? | |
var displayUserNamesInBooking: Bool? | |
var allowMultiSlotBookings: Bool? | |
var allowMultipleBookingsPerUser: Bool? | |
var provideNoteFieldInBookings: Bool? | |
var noteFieldInBookingsIsRequired: Bool? | |
var noteFieldInBookingsHelpText: String? | |
var accessRestrictionStart: String? | |
var accessRestrictionEnd: String? | |
var bookingRestrictionStart: String? | |
var bookingRestrictionEnd: String? | |
} | |
class TimeSlot: NSObject, Codable { | |
let id: Int? | |
let startTime: String? | |
let endTime: String? | |
} |
Network requests
I will use URLSession for requests
func perform<T: Decodable>(_ request: URLRequest) -> AnyPublisher<HTTPResponse<T>, Error> { | |
return URLSession.shared.dataTaskPublisher(for: request) | |
.tryMap { data, response -> HTTPResponse<T> in | |
guard let httpResponse = response as? HTTPURLResponse, | |
200...299 ~= httpResponse.statusCode else { | |
throw APIError.responseError(((response as? HTTPURLResponse)?.statusCode ?? 500, | |
String(data: data, encoding: .utf8) ?? "")) | |
} | |
let data = try JSONDecoder().decode(T.self, from: data) | |
return HTTPResponse(value: data, response: response) | |
} | |
.receive(on: DispatchQueue.main) | |
.eraseToAnyPublisher() | |
} |
func getTenants(_ userID: String) -> AnyPublisher<[TenantModel], Error>? { | |
guard let url = Endpoint.userTenants(userID).absoluteURL else { return nil } | |
var request = URLRequest(url: url) | |
request.httpMethod = HTTPMethod.get.rawValue | |
request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
request.setValue("Bearer \(Token.access ?? "")", forHTTPHeaderField: "Authorization") | |
return HTTPClient.perform(request) | |
.map(\.value) | |
.eraseToAnyPublisher() | |
} |
Note the return type: AnyPublisher & lt; [TenantModel], Error & gt; is our publisher. We subscribe to it and receive data already in the ViewModel
import Foundation | |
import Combine | |
class TenantViewModel: ObservableObject { | |
@Published var tenants: [TenantModel] = [] | |
private var cancellableSet: Set<AnyCancellable> = [] | |
init() {} | |
func getTenants() { | |
UserAPI.getTenants(User.idWithToken)? | |
.sink( | |
receiveCompletion: { [weak self] completion in | |
if case let .failure(error) = completion { | |
switch error { | |
case let apiError as APIError: | |
HTTPClient.apiErrorRefreshToken(apiError) { | |
self?.getTenants() | |
} | |
default: | |
break | |
} | |
} | |
}, | |
receiveValue: { [weak self] tenant in | |
self?.tenants = tenant | |
}) | |
.store(in: &self.cancellableSet) | |
} | |
} |
.sink is a subscriber that receives data. self.tenants = tenants - Assign values to a variable marked with @Publisher. These changes will force the View to be updated
import SwiftUI | |
struct TenantPicker: View { | |
@StateObject var viewModel: TenantViewModel = TenantViewModel() | |
@Binding var selectedTenant: TenantModel? | |
@Binding var showTenantPicker: Bool | |
var label: String | |
var title: String | |
var body: some View { | |
HStack { | |
Button(action: { | |
viewModel.getTenants() | |
showTenantPicker.toggle() | |
}) { HStack(alignment: .center, spacing: 2) { | |
Text(label) | |
.foregroundColor(Color(.label)) | |
Spacer() | |
Text(selectedTenant?.name ?? "Select tenant") | |
.foregroundColor(Color(.secondaryLabel)) | |
} | |
}.sheet(isPresented: $showTenantPicker, content: { | |
NavigationView { | |
ScrollView { | |
CustomList { | |
ForEach(Array(viewModel.tenants.enumerated()), id: \.element) { tenantIndex, tenant in | |
CustomListItem { | |
Button { | |
selectedTenant = tenant | |
CoreDataManager.shared.saveTenant(tenant) | |
showTenantPicker = false | |
} label: { | |
HStack { | |
Text("\(tenant.name ?? "")") | |
.foregroundColor(Color(.label)) | |
Spacer() | |
} | |
} | |
} | |
if viewModel.tenants.count > 1 && | |
viewModel.tenants.count - 1 > tenantIndex { | |
Divider() | |
} | |
} | |
}.cornerRadius(8) | |
} | |
.padding() | |
.fillBy(Color(.systemGray6)) | |
.navigationBarTitle(title, displayMode: .inline) | |
.navigationBarItems(leading: Button(action: { | |
showTenantPicker = false | |
}, label: { | |
HStack(spacing: 8) { | |
Image(systemName: "chevron.left") | |
Text("Back") | |
} | |
})) | |
} | |
}) | |
} | |
} | |
} |
Add the @StateObject wrapper to catch all changes to the ViewModel. Here ForEach (Array (viewModel.tenants ...)) we go through the list, this list will rebuild itself when receiving data over the network.
Here's what we have to obtain:
