Asynchronous programming with Combine iOS 13.06.2021

Introduction

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:

  • NotificationCenter. Executes a piece of code any time an event of interest happens, such as when the user changes the orientation of the device or when the software keyboard shows or hides on the screen.
  • The delegate pattern. Lets you define an object that acts on behalf of, or in coordination with, another object. For example, in your app delegate, you define what should happen when a new remote notification arrives, but you have no idea when this piece of code will be executed or how many times it will execute.
  • Grand Central Dispatch and Operations. Helps you abstract the execution of pieces of work. You can use them to schedule code to be executed sequentially in a serial queue or to run a multitude of tasks concurrently in different queues with different priorities.
  • Closures. Create detached pieces of code that you can pass around in your code, so other objects can decide whether to execute it, how many times, and in what context.

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.

Publishers

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:

  1. An output value of the publisher's generic 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.
  2. A successful completion.
  3. A completion with an error of the publisher's 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))

Subscribers

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:

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

  1. In the first step, the 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.
  2. Now the Publisher is free to send those values over time. The Subscriber will receive those inputs.
  3. In subscriptions that are not expecting unlimited values, a completion event is sent to the Subscriber, so it is aware that the subscription is over.

Hello Publisher

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.

Understanding Operators

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.

Understanding Subject

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.

Example. CoreLocation

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:

  • The status and location view in ComponentView
  • A LocationViewModel class that interacts with the model and updates the view
  • A LocationManager service that listens to the location changes and sends events when they happen

Model

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

Example. Fetching remote data

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