Day 59 - 100 Days of Swift

10 minute read

Consolidation VI

Day 59 is the sixth consolidation day. You look back over a few of the things that you learned in the last three projects and he challenges you to make another app of your own from scratch. You review using DispatchQueue.asynAfter, capture lists in closures and CGAffineTransform. And the challenge he gives you is to make an app that displays a list of countries in a table view, and displays some information about them when you tap on the cell.

He suggests that you get some information from Wikipedia and write some JSON for yourself to store the data about the countries, but I figured someone out there would probably have made an API for exactly that. I did a quick google search and found several and, after looking at a couple, I decided to use this one because it was simple and had all the info I wanted: https://restcountries.eu/

I used the sample JSON provided in their documentation to write my Country class, which ended up looking like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Country: Codable {
    let name: String
    let capital: String
    let population: Int
    let area: Double?
    let currencies: [Currency]
    let languages: [Language]
    let flag: String

struct Currency: Codable{
    let name: String?
}

struct Language: Codable{
    let name: String?
}

My goals for this project were to keep things as simple as possible, to keep things as clean as possible, and to get it done as fast as possible. So I stuck to the simplest Codable implementation that I could even though in a real app I would probably do a little bit of custom decoding to flatten this out a little bit.

After that, I wrote a CountryController class that makes the network call to GET the countries, and holds an array of them. I also made it a singleton to give me a single source of truth, if I ever decide to extend this app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class CountryController {

    private var countries: [Country] = []

    static let shared = CountryController()
    private init() {}

    // MARK: - Networking
    private let baseURL = URL(string: "https://restcountries.eu/rest/v2/")!

    func loadCountries(completion: @escaping () -> Void) {
        let requestURL = baseURL.appendingPathComponent("all")

        URLSession.shared.dataTask(with: requestURL) { (data, _, error) in
            if let error = error {
                NSLog("Error GETting all countries:\n\(error)")
                completion()
                return
            }

            guard let data = data else {
                NSLog("No data was returned.")
                completion()
                return
            }

            do {
                self.countries = try JSONDecoder().decode([Country].self,
                                                          from: data)
            } catch {
                NSLog("Error decoding countries:\n\(error)")
            }
            completion()

        }.resume()
    }

Next, I wanted to CountryController to have an interface for a UITableViewController to get the data it needs, and I also wanted to sort the countries into sections by their first letter, because that allows you to put the little letter scroll bar to the right side of the table view. First I added a few new properties and a method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private var letters: [String] = []
private var sortedCountries: [String: [Country]] = [:]

private func updateLetters() {
    letters = Set(countries
        .compactMap({ $0.name.first })
        .compactMap({ String($0) }))
        .sorted()

    sortedCountries = [:]
    for letter in letters {
        sortedCountries[letter] =
            countries.filter { $0.name.hasPrefix(letter) }
    }
}

letters gives me a an array to hold all of the sections we’ll need and sortedCountries is a dictionary that will hold an array of Country for each letter. updateLetters Makes a Set out of the result of a compactMap that gets the first character of each country’s name, which is then compactMaped again to turn the Character into a String. This results in a Set of all the necessary first letters, which is then sorted into an Array. Then it cycles through each letter, and sets the dictionary value to be all of the countries that start with that letter.

Finally, I call updateLetters() in a didSet observer on countries so that it will stay in sync with all of the countries that we currently have:

1
2
3
private var countries: [Country] = [] {
    didSet { updateLetters() }
}

Next, I needed to build out the interface for the UITableViewController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// MARK: TableView Related
func numberOfSections() -> Int {
    return letters.count
}

func title(for section: Int) -> String {
    return letters[section]
}

func sectionTitles() -> [String] {
    return letters
}

func numberOfRows(in section: Int) -> Int {
    let letter = title(for: section)
    return sortedCountries[letter]?.count ?? 0
}

func country(for indexPath: IndexPath) -> Country {
    let letter = title(for: indexPath.section)
    return (sortedCountries[letter]?[indexPath.row])!
}

With that, all the model side of things was pretty much ready to go, so I started building out the UITableViewController. I added one to Main.storyboard , set its class, embedded it in a UINavigationController, etc. All the normal stuff. And in ViewController I added these methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
let countryController = CountryController.shared

override func viewDidLoad() {
    super.viewDidLoad()

    countryController.loadCountries {
        DispatchQueue.main.async {
            self.tableView.reloadData()
        }
    }
}

override func numberOfSections(in tableView: UITableView) -> Int {
    return countryController.numberOfSections()
}

override func tableView(_ tableView: UITableView,
                        titleForHeaderInSection section: Int) -> String? {
    return countryController.title(for: section)
}

override func sectionIndexTitles(for tableView: UITableView) -> [String]? {
    return countryController.sectionTitles()
}

override func tableView(_ tableView: UITableView,
                        numberOfRowsInSection section: Int) -> Int {
    return countryController.numberOfRows(in: section)
}

override func tableView(_ tableView: UITableView,
                        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "CountryCell",
                                             for: indexPath)
    let country = countryController.country(for: indexPath)

    cell.textLabel?.text = country.name
    let capital = country.capital == "" ? "" : "Capital: \(country.capital)"
    cell.detailTextLabel?.text = capital

    return cell
}

This basically just hooks up the table view to the interface I built out in CountryController.

Next, I added a second UIViewController to the storyboard, to be the detail view, I gave it a bunch of labels and an image view to display the countries flag. (I realized when I started building out the DetailViewController that the flag images are .svg’s, which do not play well with UIImageView, so I swapped it out for a WKWebView instead. It’s not perfect, but it works well enough for this app.) I added outlets for everything and wrote a DetailViewController that looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
var country: Country?

@IBOutlet var webViewContainer: UIView!
var flagImageView: WKWebView!

@IBOutlet var capitalLabel: UILabel!
@IBOutlet var populationLabel: UILabel!
@IBOutlet var areaLabel: UILabel!
@IBOutlet var languagesLabel: UILabel!
@IBOutlet var currenciesLabel: UILabel!

override func viewDidLoad() {
    super.viewDidLoad()

    setupViews()

    updateViews()
}

private func setupViews() {
    flagImageView = WKWebView()

    flagImageView.constrainToFill(webViewContainer)
}

private func updateViews() {
    guard let country = country else { return }

    if let url = URL(string: country.flag) {
        let request = URLRequest(url: url)
        flagImageView.load(request)
    }

    title = country.name
    capitalLabel.text = country.capital
    populationLabel.text = country.formattedPopulation
    areaLabel.text = country.formattedArea
    languagesLabel.text = country.formattedLanguages
    currenciesLabel.text = country.formattedCurrencies
}

This uses my constrainToFill() helper method, which just added constraints to tie the WKWebView to the webViewContainer. It also sets most of the label’s texts to be formatted versions of the data. I think this is similar to how you would do things if you were using a ViewModel (I’m not that familiar with the pattern), but I just added those methods to the model itself and it works great here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// In Country
static let numberFormatter: NumberFormatter = {
    let numberFormatter = NumberFormatter()
    numberFormatter.numberStyle = NumberFormatter.Style.decimal
    return numberFormatter
}()

private let noDataString = "Unknown"

var formattedPopulation: String {
    return Country.numberFormatter
        .string(from: population as NSNumber) ?? noDataString
}

var formattedArea: String {
    guard let area = area else { return noDataString }
    guard let areaString = Country.numberFormatter
        .string(from: area as NSNumber) else { return noDataString }
    return  areaString + " sq. mi"
}

var formattedLanguages: String {
    let allLanguages = languages.compactMap { $0.name }
    return allLanguages.map({ "- \($0)" })
        .joined(separator: "\n")
}

var formattedCurrencies: String {
    let allCurrencies = currencies.compactMap { $0.name }
    return allCurrencies.map({ "- \($0)" })
        .joined(separator: "\n")
}

Finally, I just needed to pass the country to the DetailViewController in prepare(for:sender:):

1
2
3
4
5
6
7
8
9
10
11
12
override func prepare(for segue: UIStoryboardSegue,
                      sender: Any?) {
    switch segue.identifier {
    case "ShowCountrySegue":
        let detailVC = segue.destination as! DetailViewController
        guard let indexPath = tableView.indexPathForSelectedRow else { return }
        let country = countryController.country(for: indexPath)
        detailVC.country = country
    default:
        break
    }
}

This results in an app that looks like this:

Gif of working app.
This is a fairly large gif, it might take some time to load.

You can find my version of this project at the end of day 59 on Github here.