In the Worldwide Developers Conference (WWDC) 2019, Apple not only introduced SwiftUI but also introduced Combine, a perfect companion to SwiftUI for managing the declarative change of state in Swift.
Apple has been improving asynchronous programming for their platforms over the years. They've created several mechanisms you can use, on different system levels, to create and execute asynchronous code.
You’ve probably used most of the following:
Apple has integrated Combine's API deep into the Foundation framework, so Timer, NotificationCenter and core frameworks like Core Data already speak its language.
Just as SwiftUI is a declarative way of describing a User Interface (UI) layout, Combine is a declarative way of describing the flow of changes.
The basic foundation of Combine is the concept of Publisher and Subscriber. A Publisher emits events, pushed by something else that can be a part of the Software Development Kit (SDK) or another part of the app you are implementing. One or more Subscribers can observe that publisher and react when a new event is published. You can consider this mechanism as a sort of Observable/Observer pattern on steroids.
A Publisher sends an event until it completes, either successfully or with an error. After it completes, the stream is closed, and if you want to send more events, you need to create a new stream and the client must subscribe to the new one.
In the Combine world, a Publisher can be considered as a function, with one Input and one Output. The protocol that manages Output, to which a client can subscribe, is the Publisher, whereas the protocol that manages the Input is the Subject. Subject basically provides a send()
function to emit events.
Every publisher can emit multiple events of these three types:
Output
type. Output
is the type of the output values of the publisher. If the publisher is specialized as an Int, it can never emit a String or a Date value.Failure
type. Failure
is the type of error the publisher can throw if it fails. If the publisher can never fail, you specify that by using a Never failure type.A publisher can emit zero or more output values, and if it ever completes, either successfully or due to a failure, it will not emit any other events.
There are two already implemented Subjects:
CurrentValueSubject
has an initial value and maintains the changed value even if it doesn't have any subscriber.PassthroughSubject
doesn't have an initial value and drops changes if nobody is observing it.To send an event, the client has to call the send()
function, as we do for changes of status and location:
statusPublisher.send(status)
The error case is a completion case and the send(cancellation:)
has either .finished
or .failure
as its output:
statusPublisher.send(completion: .failure(.restricted))
Finally, you arrive at the end of the subscription chain: Every subscription ends with a subscriber. Subscribers generally do "something" with the emitted output or completion events.
Currently, Combine provides two built-in subscribers, which make working with data streams straightforward:
sink
subscriber allows you to provide closures with your code that will receive output values and completions. From there, you can do anything your heart desires with the received events.assign
subscriber allows you to, without the need of custom code, bind the resulting output to some property on your data model or on a UI control to display the data directly on-screen via a key path.Now that we have a basic idea of what a publisher and a subscriber look like, let's see how they communicate.
Subscriber
tells the Publisher
that it wants to subscribe. The Publisher
sends back a subscription. The Subscriber
uses that subscription to start requesting elements. The subscriber can request from N to unlimited values.Publisher
is free to send those values over time. The Subscriber
will receive those inputs.Subscriber
, so it is aware that the subscription is over.So, at the heart of Combine is the Publisher
protocol. This protocol defines the requirements for a type to be able to transmit a sequence of values over time to one or more subscribers. In other words, a publisher publishes or emits events that can include values of interest.
let just = Just("Hello world!") _ = just .sink( receiveCompletion: { print("Received completion", $0) }, receiveValue: { print("Received value", $0) })
In addition to sink
, the built-in assign(to:on:)
operator enables you to assign the received value to a KVO-compliant property of an object.”
class SomeObject { var value: String = "" { didSet { print(value) } } } let object = SomeObject() let publisher = ["Hello", "world!"].publisher _ = publisher .assign(to: \.value, on: object)
Here is a practical example of a single publisher sending the values of an array to a subscriber.
import Combine let publisher = [1,2,3,4].publisher let subscriber = publisher.sink { element in print(element) }
The initial array contained the numbers from 1 to 4, and that is what we printed. But what if we just want to print the even numbers? How can we transform the data between the producer and the subscriber? Luckily for us, Combine provides Operators to help us.
An Operator is also a Publisher. It sits between a publisher and a subscriber. An operator subscribes to a publisher ("upstream") and sends results to a subscriber ("downstream"). Operators can also be chained in sequence. Here are some operators with example code: filter, map, reduce, scan, combineLatest, merge, and zip.
Let's see a fundamental example using the filter
operator.
import Combine let publisher = [1,2,3,4].publisher let subscriber = publisher .filter { $0 % 2 == 0} .sink { print($0) }
The map
operator helps us to apply a certain operation to every value of the stream, transforming it into a different type.
let publisher = [1,2,3,4].publisher let subscriber = publisher .map { return Movie(id: $0)} .sink { print($0.title()) }
The reduce
operator returns the result of combining all the values of the stream using a given operation to apply.
let reduceExample = [1,2,3,4].publisher .reduce(1, { $0 * $1 }) .sink(receiveValue: { print ("\($0)", terminator: " ") })
The scan
operator does exactly the same as reduce
but it emits the result at each step.
let scanExample = [1,2,3,4].publisher .scan(1, { $0 * $1 }) .sink(receiveValue: { print ("\($0)", terminator: " ") })
combineLatest
is a publisher that combines the latest values from two other publishers. Both publishers must have the same failure type. The downstream subscriber will receive a tuple of the most recent elements from the upstream publishers when any of them emit a new value.
let chars = PassthroughSubject<String, Never>() let numbers = PassthroughSubject<Int, Never>() let cancellable = chars.combineLatest(numbers) .sink { print("Result: \($0).") } chars.send("a") numbers.send(1) chars.send("b") chars.send("c") numbers.send(2) numbers.send(3)
With merge
, we will aggregate multiple input publishers into a single stream, and the output will be just the latest value from any of them.
let oddNumbers = PassthroughSubject<Int, Never>() let evenNumbers = PassthroughSubject<Int, Never>() let cancellable = oddNumbers.merge(with: evenNumbers) .sink { print("Result: \($0).") } oddNumbers.send(1) evenNumbers.send(2) oddNumbers.send(3)
zip
is a publisher that emits a pair of elements when both input publishers have emitted a new value.
let chars = PassthroughSubject<String, Never>() let numbers = PassthroughSubject<Int, Never>() let cancellable = chars.zip(numbers) .sink { print("Result: \($0).") } chars.send("a") numbers.send(1) // combineLatest output: (a,1) // zip output: (a, 1) chars.send("b") // combineLatest output: (b,1) // zip output: nothing chars.send("c") // combineLatest output: (c,1) // zip output: nothing numbers.send(2) // combineLatest output: (c,2) // zip output: (b,2) numbers.send(3) // combineLatest output: (c,3) // zip output: (c,3)
Check out the comments under each line, representing what combineLatest
and zip
will output every given time. Notice how zip
doesn't send a new pair of values downstream until both of the publishers have emitted a new value.
Subjects are like publishers, but they have a method, send(_:)
, which you can use to inject new elements into their stream. A single Subject
allows multiple subscribers to be connected at the same time.
There are two types of built-in subjects: CurrentValueSubject
and PassthroughSubject
.
CurrentValueSubject
holds an initial value. It broadcasts the current value every time it changes. When a subscriber connects to a CurrentValueSubject
, it will receive the current value, and the next ones when it changes. This means that a CurrentValueSubject
has state.
let currentValueSubject = CurrentValueSubject<String, Never>("first value") let subscriber = currentValueSubject.sink { print("received: \ ($0)") } currentValueSubject.send("second value")
The main difference between PassthroughSubject
and CurrentValueSubject
is that PassthroughSubject
doesn't hold any state.
let passthroughSubject = PassthroughSubject<String, Never>() passthroughSubject.send("first value") let subscriber = passthroughSubject.sink { print("received: \ ($0)")} passthroughSubject.send("second value")
This first value is not received, because there was no subscriber connected yet. However, the second value is displayed in the output because it was sent after the subscription was established.
Following is a useful example how to enable submit button when to fields are valid
@Published var initialEmail: String = "" @Published var repeatedEmail: String = "" // ... var validatedEmail: AnyPublisher<String?, Never> { return Publishers .CombineLatest($initialEmail, $repeatedEmail) .map { (email, repeatedEmail) -> String? in guard email == repeatedEmail, email.contains("@"), email.count > 5 else { return nil } return email } .eraseToAnyPublisher() } var cancellable: AnyCancellable?
Now, add this line of code to the viewDidLoad()
method:
cancellable = validatedEmail.sink { print($0) }
By calling sink
, we are attaching a subscriber to the validatedEmail
publisher, and we store it in our new var property cancellable
. Every time we receive a new value, we will just print it into the console for testing purposes.
The @Published
property wrapper allows us to create a Publisher
from a property variable. We can access the publisher by prefixing $
to the name of the property. It only works on class properties, not on structs.
eraseToAnyPublisher
allows us to erase complex types to work with easier AnyPublisher<Otutput, Failure>
streams. This is very handy when publishing our classes as an API, for example.
We are going to add publish support to CoreLocation. CLLocationManager
will emit status and location updates, which will be observed by a SwiftUI View.
For privacy reasons, every app that needs to access the location of the user must ask permission before accessing it. To allow this, a message should be added for the Privacy – Location When In Use Usage Description key in Info.plist.
We are going to implement three components:
ComponentView
LocationViewModel
class that interacts with the model and updates the viewLocationManager
service that listens to the location changes and sends events when they happenModel
import SwiftUI import CoreLocation import Combine class LocationManager: NSObject { enum LocationError: String, Error { case notDetermined case restricted case denied case unknown } let statusPublisher = PassthroughSubject<CLAuthorizationStatus, LocationError>() let locationPublisher = PassthroughSubject<CLLocation?, Never>() private let locationManager = CLLocationManager() override init() { super.init() self.locationManager.delegate = self self.locationManager.desiredAccuracy = kCLLocationAccuracyBest self.locationManager.requestWhenInUseAuthorization() } func start() { locationManager.startUpdatingLocation() } } extension LocationManager: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { switch status { case .notDetermined: statusPublisher.send(completion: .failure(.notDetermined)) case .restricted: statusPublisher.send(completion: .failure(.restricted)) case .denied: statusPublisher.send(completion: .failure(.denied)) case .authorizedAlways, .authorizedWhenInUse: statusPublisher.send(status) @unknown default: statusPublisher.send(completion: .failure(.unknown)) } } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.last else { return } locationPublisher.send(location) } }
ViewModel
Let's then add two publishers: one for the status and the other for the location updates. As you can see, they are called Subjects:
class LocationViewModel: ObservableObject { @Published private var status: CLAuthorizationStatus = .notDetermined @Published private var currentLocation: CLLocation? @Published var isStartable = true @Published var errorMessage = "" private let locationManager = LocationManager() var thereIsAnError: Bool { !errorMessage.isEmpty } var latitude: String { currentLocation.latitudeDescription } var longitude: String { currentLocation.longitudeDescription } var statusDescription: String { switch status { case .notDetermined: return "notDetermined" case .authorizedWhenInUse: return "authorizedWhenInUse" case .authorizedAlways: return "authorizedAlways" case .restricted: return "restricted" case .denied: return "denied" @unknown default: return "unknown" } } func startUpdating() { locationManager.start() isStartable = false } private var cancellableSet: Set<AnyCancellable> = [] init() { locationManager.statusPublisher .debounce(for: 0.5, scheduler: RunLoop.main) .removeDuplicates() .sink { completion in switch completion { case .finished: break case .failure(let error): self.errorMessage = error.rawValue } } receiveValue: { self.status = $0} .store(in: &cancellableSet) locationManager.locationPublisher .debounce(for: 0.5, scheduler: RunLoop.main) .removeDuplicates(by: lessThanOneMeter) .assign(to: \.currentLocation, on: self) .store(in: &cancellableSet) } private func lessThanOneMeter(_ lhs: CLLocation?, _ rhs: CLLocation?) -> Bool { if lhs == nil && rhs == nil { return true } guard let lhr = lhs, let rhr = rhs else { return false } return lhr.distance(from: rhr) < 1 } } extension Optional where Wrapped == CLLocation { var latitudeDescription: String { guard let self = self else { return "-" } return String(format: "%0.4f", self.coordinate.latitude) } var longitudeDescription: String { guard let self = self else { return "-" } return String(format: "%0.4f", self.coordinate.longitude) } }
The assign()
function is a modifier that puts the event in a property of an object - in this case, the currentLocation
property in the LocationViewModel class.
The sink()
function is similar to the assign()
function we saw previously; it receives the values but also the completion, so it can apply different logic depending on the received value.
View
struct ContentView: View { @ObservedObject var locationViewModel = LocationViewModel() var body: some View { VStack(spacing: 24) { VStack(spacing: 8) { if locationViewModel.thereIsAnError { Text("Location Service terminated with error: \(locationViewModel.errorMessage)") } else { Text("Status: \(locationViewModel.statusDescription)") HStack { Text("Latitude: \(locationViewModel.latitude)") Text("Longitude: \(locationViewModel.longitude)") } } } .padding(.horizontal, 24) if locationViewModel.isStartable { Button { locationViewModel.startUpdating() } label: { Text("Start location updating") .foregroundColor(.white) .padding(.horizontal, 24) .padding(.vertical, 16) .background(Color.green) .cornerRadius(5) } } else { EmptyView() } } } }
Example. Validating a form using Combine
We'll implement a simple signup page with a username text field and two password fields, one for the password and the other for password confirmation.
The username has a minimum number of characters, and the password must be at least eight characters long, comprising mixed numbers and letters, with at least one uppercase letter and a special character. Also, the password and the confirmation password must match. When all the fields are valid, the form is valid, and we can proceed to the next page.
Each field will be a publisher, and the validations are subscribers of those publishers.
The business logic is the validation logic, and it will be encapsulated in a class called SignupViewModel
:
import SwiftUI import Combine class SignupViewModel: ObservableObject { // Input @Published var username = "" @Published var password = "" @Published var confirmPassword = "" // Input @Published var isValid = false @Published var usernameMessage = " " @Published var passwordMessage = " " private var cancellableSet: Set<AnyCancellable> = [] init() { usernameValidPublisher .receive(on: RunLoop.main) .map { $0 ? " " : "Username must be at least 6 characters long" } .assign(to: \.usernameMessage, on: self) .store(in: &cancellableSet) passwordValidPublisher .receive(on: RunLoop.main) .map { passwordCheck in switch passwordCheck { case .invalidLength: return "Password must be at least 8 characters long" case .noMatch: return "Passwords don't match" case .weakPassword: return "Password is too weak" default: return " " } } .assign(to: \.passwordMessage, on: self) .store(in: &cancellableSet) formValidPublisher .receive(on: RunLoop.main) .assign(to: \.isValid, on: self) .store(in: &cancellableSet) } } private extension SignupViewModel { var usernameValidPublisher: AnyPublisher<Bool, Never> { $username .debounce(for: 0.5, scheduler: RunLoop.main) .removeDuplicates() .map { $0.count >= 5 } .eraseToAnyPublisher() } var validPasswordLengthPublisher: AnyPublisher<Bool, Never> { $password .debounce(for: 0.5, scheduler: RunLoop.main) .removeDuplicates() .map { $0.count >= 8 } .eraseToAnyPublisher() } var strongPasswordPublisher: AnyPublisher<Bool, Never> { $password .debounce(for: 0.2, scheduler: RunLoop.main) .removeDuplicates() .map(\.isStrong) .eraseToAnyPublisher() } var matchingPasswordsPublisher: AnyPublisher<Bool, Never> { Publishers.CombineLatest($password, $confirmPassword) .debounce(for: 0.2, scheduler: RunLoop.main) .map { password, confirmedPassword in return password == confirmedPassword } .eraseToAnyPublisher() } var passwordValidPublisher: AnyPublisher<PasswordCheck, Never> { Publishers.CombineLatest3(validPasswordLengthPublisher, strongPasswordPublisher, matchingPasswordsPublisher) .map { validLength, strong, matching in if (!validLength) { return .invalidLength } if (!strong) { return .weakPassword } if (!matching) { return .noMatch } return .valid } .eraseToAnyPublisher() } var formValidPublisher: AnyPublisher<Bool, Never> { Publishers.CombineLatest(usernameValidPublisher, passwordValidPublisher) .map { usernameIsValid, passwordIsValid in return usernameIsValid && (passwordIsValid == .valid) } .eraseToAnyPublisher() } } enum PasswordCheck { case valid case invalidLength case noMatch case weakPassword } extension String { var isStrong: Bool { containsACharacter(from: .lowercaseLetters) && containsACharacter(from: .uppercaseLetters) && containsACharacter(from: .decimalDigits) && containsACharacter(from: CharacterSet.alphanumerics.inverted) } private func containsACharacter(from set: CharacterSet) -> Bool { rangeOfCharacter(from: set) != nil } } struct CustomStyle: ViewModifier { func body(content: Content) -> some View { content .frame(height: 40) .background(Color.white) .cornerRadius(5) } } extension TextField { func custom() -> some View { modifier(CustomStyle()) .autocapitalization(.none) } } extension SecureField { func custom() -> some View { modifier(CustomStyle()) } }
At the end of each publisher, there is the eraseToAnyPublisher()
modifier. The reason is that the composition of the modifiers on the publishers creates some complex nested types, where in the end the subscriber needs only an object of type Publisher
. The function does some eraseToAnyPublisher()
magic to flatten these types and just return a generic Publisher
.
The .map(\.isStrong)
function is a nice shortcut added in Swift 5.2 and it is equivalent to this:
.map { password in return password.isStrong }
View
struct ContentView: View { @ObservedObject private var signupViewModel = SignupViewModel() var body: some View { ZStack { Color.yellow.opacity(0.2) VStack(spacing: 24) { VStack(alignment: .leading) { Text(signupViewModel.usernameMessage).foregroundColor(.red) TextField("Username", text: $signupViewModel.username) .custom() } VStack(alignment: .leading) { Text(signupViewModel.passwordMessage).foregroundColor(.red) SecureField("Password", text: $signupViewModel.password) .custom() SecureField("Repeat Password", text: $signupViewModel.confirmPassword) .custom() } Button { print("Succesfully registered!") } label: { Text("Register") .foregroundColor(.white) .frame(width: 100, height: 44) .background(signupViewModel.isValid ? Color.green : Color.red) .cornerRadius(10) }.disabled(!signupViewModel.isValid) } .padding(.horizontal, 24) } .edgesIgnoringSafeArea(.all) } }
We are going to implement a simple weather app, fetching the current weather and a 5-day forecast from OpenWeather.
The model we need for the weather is really simple:
struct Weather: Decodable, Identifiable { var id: TimeInterval { time.timeIntervalSince1970 } let time: Date let summary: String let icon: String let temperature: Double enum CodingKeys: String, CodingKey { case time = "dt" case weather = "weather" case summary = "description" case main = "main" case icon = "icon" case temperature = "temp" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) time = try container.decode(Date.self, forKey: .time) var weatherContainer = try container.nestedUnkeyedContainer(forKey: .weather) let weather = try weatherContainer.nestedContainer(keyedBy: CodingKeys.self) summary = try weather.decode(String.self, forKey: .summary) icon = try weather.decode(String.self, forKey: .icon) let main = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .main) temperature = try main.decode(Double.self, forKey: .temperature) } } struct ForecastWeather: Decodable { let list: [Weather] }
Let's move now to the WeatherService
class, the ObservableObject
whose goal it is to connect to the service and fetch the data. We are exposing three variables: the current weather, the forecast, and a message if there is an error.
class WeatherService: ObservableObject { @Published var errorMessage: String = "" @Published var current: Weather? @Published var forecast: [Weather] = [] private let apiKey = "KEY" private var cancellableSet: Set<AnyCancellable> = [] func load(latitude: Float, longitude: Float) { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 let currentURL = URL(string: "https://api.openweathermap.org/data/2.5/weather?lat=\(latitude)&lon=\(longitude)&appid=\(apiKey)&units=metric")! URLSession.shared .dataTaskPublisher(for: URLRequest(url: currentURL)) .map(\.data) .decode(type: Weather.self, decoder: decoder) .receive(on: RunLoop.main) .sink { completion in switch completion { case .finished: break case .failure(let error): self.errorMessage = error.localizedDescription } } receiveValue: { self.current = $0 } .store(in: &cancellableSet) let forecastURL = URL(string: "https://api.openweathermap.org/data/2.5/forecast?lat=\(latitude)&lon=\(longitude)&appid=\(apiKey)&units=metric")! URLSession.shared .dataTaskPublisher(for: URLRequest(url: forecastURL)) .map(\.data) .decode(type: ForecastWeather.self, decoder: decoder) .receive(on: RunLoop.main) .sink { completion in switch completion { case .finished: break case .failure(let error): self.errorMessage = error.localizedDescription } } receiveValue: { self.forecast = $0.list } .store(in: &cancellableSet) } } extension String { var weatherIcon: String { switch self { case "01d": return "sun.max" case "02d": return "cloud.sun" case "03d": return "cloud" case "04d": return "cloud.fill" case "09d": return "cloud.rain" case "10d": return "cloud.sun.rain" case "11d": return "cloud.bolt" case "13d": return "cloud.snow" case "50d": return "cloud.fog" case "01n": return "moon" case "02n": return "cloud.moon" case "03n": return "cloud" case "04n": return "cloud.fill" case "09n": return "cloud.rain" case "10n": return "cloud.moon.rain" case "11n": return "cloud.bolt" case "13n": return "cloud.snow" case "50n": return "cloud.fog" default: return "icloud.slash" } } } extension Double { var formatted: String { String(format: "%.0f", self) } } extension Date { var formatted: String { let formatter = DateFormatter() formatter.dateStyle = .long formatter.timeStyle = .medium return formatter.string(from: self) } }
View
struct ContentView: View { @ObservedObject var weatherService = WeatherService() var body: some View { VStack { Text(weatherService.errorMessage) .font(.largeTitle) if weatherService.current != nil { VStack { CurrentWeather(current: weatherService.current!) List { ForEach(weatherService.forecast) { WeatherRow(weather: $0) } } } } else { Button { weatherService.load(latitude: 51.5074, longitude: 0.1278) } label: { Text("Refresh Weather") .foregroundColor(.white) .padding(.horizontal, 24) .padding(.vertical, 16) .background(Color.green) .cornerRadius(5) } } } } } struct CurrentWeather: View { let current: Weather var body: some View { VStack(spacing: 28) { Text("\(current.time.formatted)") HStack { Image(systemName: current.icon.weatherIcon) .font(.system(size: 98)) Text("\(current.temperature.formatted)°") .font(.system(size: 46)) } Text("\(current.summary)") } } } struct WeatherRow: View { let weather: Weather var body: some View { HStack() { Image(systemName: weather.icon.weatherIcon) .frame(width: 40) .font(.system(size: 28)) VStack(alignment: .leading) { Text("\(weather.summary)") Text("\(weather.time.formatted)") .font(.system(.footnote)) } Spacer() Text("\(weather.temperature.formatted)° ") .frame(width: 40) } .padding(.horizontal, 16) } }