Chapters

Hide chapters

RxSwift: Reactive Programming with Swift

Fourth Edition · iOS 13 · Swift 5.1 · Xcode 11

10. Combining Operators in Practice
Written by Florent Pillet

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In the previous chapter, you learned about combining operators and worked through increasingly more detailed exercises on some rather mind-bending concepts. Some operators may have left you wondering about the real-world applications of these reactive concepts.

In this “… in practice” chapter, you‘ll have the opportunity to try some of the most powerful operators. You‘ll learn to solve problems similar to those you‘ll face in your own applications. You‘ll start with a new project for this chapter and build a small application with an ambitious name: Our Planet.

Note: This chapter assumes you’ve already worked your way through Chapter 9, “Combining Operators.” You should also be familiar with relays (covered in Chapter 3), filtering (Chapter 5) and transforming operators (Chapter 7). At this point in the book, it is important that you are familiar with these concepts, so make sure to review these chapters if necessary!

Getting started

The project will tap into the wealth of public data exposed by NASA. You‘ll target EONET, NASA’s Earth Observatory Natural Event Tracker. It is a near real-time, curated repository of natural events of all types occurring on the planet. Check out https://eonet.sci.gsfc.nasa.gov/ to learn more!

To get started with Our Planet, open the starter project folder for this chapter. Install the required CocoaPods (as explained in Chapter 1, “Hello RxSwift”), and open OurPlanet.xcworkspace.

Build and run the starter application; the default screen is an empty table view.

Your tasks with this application are as follows:

  • Gather the event categories from the EONET public API https://eonet.sci.gsfc.nasa.gov/docs/v2.1 and display them on the first screen.
  • Download events and show a count for each category.
  • When your user taps a category, display a list of events for it.

You’ll learn how useful combineLatest can be in several situations, but you’ll also exercise startWith, concat, merge, reduce and scan. Of course, you’ll also rely on operators you are already familiar with, like map(_:) and flatMap(_:).

Preparing the web backend service

Good applications have a clear architecture with well-defined roles. The code that talks with the EONET API shouldn’t live in any of the view controllers. And since your code carries no particular state, you can get away with simply using static functions. For clarity, you’ll put the static functions in a class.

Generic request technique

You’ll start by coding request(endpoint:query:contentIdentifier:). Your goals with this crucial component of your EONET service are:

static func request<T: Decodable>(endpoint: String,
                                  query: [String: Any] = [:],
                                  contentIdentifier: String) -> Observable<T> {
  do {
    guard let url = URL(string: API)?.appendingPathComponent(endpoint),
          var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
      throw EOError.invalidURL(endpoint)
    }
components.queryItems = try query.compactMap { (key, value) in
  guard let v = value as? CustomStringConvertible else {
    throw EOError.invalidParameter(key, value)
  }
  return URLQueryItem(name: key, value: v.description)
}
guard let finalURL = components.url else {
  throw EOError.invalidURL(endpoint)
}
let request = URLRequest(url: finalURL)

return URLSession.shared.rx.response(request: request)
  .map { (result: (response: HTTPURLResponse, data: Data)) -> T in
    let decoder = self.jsonDecoder(contentIdentifier: contentIdentifier)
    let envelope = try decoder.decode(EOEnvelope<T>.self, from: result.data)
    return envelope.content
  }
  } catch {
    return Observable.empty()
  }
}

Fetch categories

To get categories from EONET, you’ll hit the categories API endpoint. Since categories seldom change, you can make them a singleton. But you are fetching them asynchronously, so the best way to expose them is with an Observable<[EOCategory]>.

static var categories: Observable<[EOCategory]> = {
  let request: Observable<[EOCategory]> = EONET.request(endpoint: categoriesEndpoint, contentIdentifier: "categories")

    return request
      .map { categories in categories.sorted { $0.name < $1.name } }
      .catchErrorJustReturn([])
      .share(replay: 1, scope: .forever)
	}()

Categories view controller

The categories view controller presents a sorted list of categories. Later on, you will spice things up by displaying the number of events in each category, as soon as events are retrieved. For now, let’s keep it simple.

let categories = BehaviorRelay<[EOCategory]>(value: [])
let disposeBag = DisposeBag()
return categories.value.count
let category = categories.value[indexPath.row]
cell.textLabel?.text = category.name
cell.detailTextLabel?.text = category.description
let eoCategories = EONET.categories
eoCategories
  .bind(to: categories)
  .disposed(by: disposeBag)
categories
  .asObservable()
  .subscribe(onNext: { [weak self] _ in
    DispatchQueue.main.async {
      self?.tableView?.reloadData()
    }
  })
  .disposed(by: disposeBag)

Adding the event download service

The EONET API exposes two endpoints to download the events: all events, and events per category. Each also differentiates between open and closed events.

private static func events(forLast days: Int, closed: Bool) -> Observable<[EOEvent]> {
  let query: [String: Any] = [
    "days": days,
    "status": (closed ? "closed" : "open")
  ]
  let request: Observable<[EOEvent]> = EONET.request(endpoint: eventsEndpoint, query: query, contentIdentifier: "events")
  return request.catchErrorJustReturn([])
}
static func events(forLast days: Int = 360) -> Observable<[EOEvent]> {
  let openEvents = events(forLast: days, closed: false)
  let closedEvents = events(forLast: days, closed: true)

  return openEvents.concat(closedEvents)
}

Getting events for categories

Head back to CategoriesViewController.swift. In startDownload(), you’ll need a more elaborate categories download mechanism to download the events. You want to fill up each category with events, but downloading takes time. To provide the best user experience possible, you’ll tackle this as follows:

Updating Categories with Events

You first need to replace the code in startDownload() with something more elaborate:

func startDownload() {
  let eoCategories = EONET.categories
  let downloadedEvents = EONET.events(forLast: 360)

}
  let updatedCategories = Observable
    .combineLatest(eoCategories, downloadedEvents) {
      (categories, events) -> [EOCategory] in

      return categories.map { category in
        var cat = category
        cat.events = events.filter {
          $0.categories.contains(where: { $0.id == category.id })
        }
        return cat
      }
    }
eoCategories
  .concat(updatedCategories)
  .bind(to: categories)
  .disposed(by: disposeBag)

Updating the display

Update tableView(_:cellForRowAt:) to display the number of events and a disclosure indicator. Change the cell’s textLabel setup and add the disclosure indicator:

cell.textLabel?.text = "\(category.name) (\(category.events.count))"
cell.accessoryType = (category.events.count > 0) ? .disclosureIndicator : .none

Downloading in parallel

Remember that the EONET API delivers open and closed events separately. Until now, you’ve been using concat(_:) to get them sequentially. It would be a good idea to download them in parallel instead. The cool thing with RxSwift is that you can make this change without any impact on UI code! Since your EONET service class exposes an observable of [EOEvent], it doesn’t matter how many requests your code makes — it’s transparent to the code consuming this observable.

return Observable.of(openEvents, closedEvents)
  .merge()
  .reduce([]) { running, new in
    running + new
  }

Events view controller

You can now complete your UI by populating the Events view controller. Not only are you going to display events, but you’ll also wire up a slider to control how much of the past year appears in the list. This is a good occasion to exercise some operators a bit more.

let events = BehaviorRelay<[EOEvent]>(value: [])
let disposeBag = DisposeBag()
events.asObservable()
  .subscribe(onNext: { [weak self] _ in
    self?.tableView.reloadData()
  })
  .disposed(by: disposeBag)
return events.value.count
let event = events.value[indexPath.row]
cell.configure(event: event)
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  let category = categories.value[indexPath.row]
  tableView.deselectRow(at: indexPath, animated: true)

  guard !category.events.isEmpty else { return }

  let eventsController = storyboard!.instantiateViewController(withIdentifier: "events") as! EventsViewController
  eventsController.title = category.name
  eventsController.events.accept(category.events)
  navigationController!.pushViewController(eventsController, animated: true)
}

Wiring the days selector

Here’s the general approach you’ll use to wire this one up:

let days = BehaviorRelay<Int>(value: 360)
let filteredEvents = BehaviorRelay<[EOEvent]>(value: [])
Observable.combineLatest(days, events) { days, events -> [EOEvent] in
  let maxInterval = TimeInterval(days * 24 * 3600)
    return events.filter { event in
      if let date = event.date {
        return abs(date.timeIntervalSinceNow) < maxInterval
      }
    return true
  }
}
.bind(to: filteredEvents)
.disposed(by: disposeBag)
filteredEvents.asObservable()
  .subscribe(onNext: { _ in
    DispatchQueue.main.async { [weak self] in
      self?.tableView.reloadData()
    }
  })
  .disposed(by: disposeBag)
days.accept(Int(slider.value))
return filteredEvents.value.count
let event = filteredEvents.value[indexPath.row]
days.asObservable()
  .subscribe(onNext: { [weak self] days in
    self?.daysLabel.text = "Last \(days) days"
  })
  .disposed(by: disposeBag)

Splitting event downloads

Your last assignment in this chapter is to split downloads per category. The EONET API lets you either download all events at once, or by category. You’ll download events by category, which will be a bit more complicated due to the simultaneous downloads — but you’re quickly becoming an RxSwift pro and you know you can handle it.

Adding per-category event downloads to EONET

To download events by category, you’ll need to be able to specify the endpoint to use on the API. Update the private events(forLast:closed:) method signature and the first line of code to take the endpoint as a parameter:

private static func events(forLast days: Int, closed: Bool, endpoint: String) -> Observable<[EOEvent]> {
let request: Observable<[EOEvent]> = EONET.request(endpoint: endpoint, query: query, contentIdentifier: "events")
static func events(forLast days: Int = 360, category: EOCategory) -> Observable<[EOEvent]> {
let openEvents = events(forLast: days, closed: false, endpoint: category.endpoint)
let closedEvents = events(forLast: days, closed: true, endpoint: category.endpoint)

Incrementally updating the UI

Downloading events for each category revolves around using flatMap to produce as many event download observables as there are categories, then merge them. You’ve probably guessed where this is all going.

let downloadedEvents = eoCategories
  .flatMap { categories in
    return Observable.from(categories.map { category in
      EONET.events(forLast: 360, category: category)
    })
  }
  .merge()
let updatedCategories = eoCategories.flatMap { categories in
  downloadedEvents.scan(categories) { updated, events in
    return updated.map { category in
      let eventsForCategory = EONET.filteredEvents(events: events, forCategory: category)
      if !eventsForCategory.isEmpty {
        var cat = category
        cat.events = cat.events + eventsForCategory
        return cat
      }
      return category
    }
  }
}

Just one more thing

Say you have 25 categories, which trigger two API requests each. That’s fifty API requests going out simultaneously to the EONET server. You want to limit the number of concurrent outgoing requests so you don’t hit the free-use threshold of the APIs.

.merge(maxConcurrent: 2)

Challenges

Challenge 1

Start from the final project in this chapter. Place an activity indicator in the navigation bar and start its spinning animation when you start fetching the events and hide the spinner once you’ve finished fetching all data from the network.

Challenge 2

The first challenge was cool, but you can do even better. Add a download progress indicator showing during the events download. You’ll have to find the right spot to insert this in your code.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now