Chapters

Hide chapters

RxSwift: Reactive Programming with Swift

Fourth Edition · iOS 13 · Swift 5.1 · Xcode 11

13. Intermediate RxCocoa
Written by Shai Mishali

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

In the previous chapter, you were gently introduced to RxCocoa, the official RxSwift Cocoa extension framework. If you haven’t gone through that chapter, it would be a good idea to read through it so you’re ready to tackle this one.

In this chapter, you’ll learn about some advanced RxCocoa integrations and how to create custom wrappers around existing UIKit components.

Note: This chapter won’t discuss RxSwift architecture, nor will it cover the best way to structure a RxSwift/RxCocoa project. This will be covered in Chapter 23, “MVVM with RxSwift.”

Getting started

This chapter continues from the previous project.

Installing project dependencies

Open Terminal, navigate to the root of the project and run pod install to fetch the required dependencies. Once that’s completed, open Wundercast.xcworkspace to get started.

Getting an OpenWeatherMap API Key

To set up the project, you will need a valid OpenWeatherMap key. If you already have one, skip to the end of this section.

private let apiKey = "Your Key"

Showing an activity while searching

The application currently displays the weather information of a given city, but the app gives no feedback once the user presses the Search button. It’s a good practice to display an activity indicator while the app is busy making network requests.

let searchInput = searchCityName.rx
  .controlEvent(.editingDidEndOnExit)g
  .map { self.searchCityName.text ?? "" }
  .filter { !$0.isEmpty }
let search = searchInput
  .flatMapLatest { text in
    ApiController.shared
      .currentWeather(for: text)
      .catchErrorJustReturn(.dummy)
  }
  .asDriver(onErrorJustReturn: .dummy)
let running = Observable.merge(
    searchInput.map { _ in true },
    search.map { _ in false }.asObservable()
  )
  .startWith(true)
  .asDriver(onErrorJustReturn: false)

running
  .skip(1)
  .drive(activityIndicator.rx.isAnimating)
  .disposed(by: bag)
running
  .drive(tempLabel.rx.isHidden)
  .disposed(by: bag)

running
  .drive(iconLabel.rx.isHidden)
  .disposed(by: bag)

running
  .drive(humidityLabel.rx.isHidden)
  .disposed(by: bag)

running
  .drive(cityNameLabel.rx.isHidden)
  .disposed(by: bag)

Extending CLLocationManager to get the current position

RxCocoa is not only about UI components; it comes with some convenient classes to wrap official Apple frameworks in a simple, customizable and powerful way.

Creating the extension

The first step to integrating the CoreLocation framework is to create the necessary wrapper around it. Open the file under Extensions named CLLocationManager+Rx.swift. This is the file where the extension will be created.

/// Extend NSObject with `rx` proxy.
extension NSObject: ReactiveCompatible { }

extension CLLocationManager: HasDelegate {}

class RxCLLocationManagerDelegateProxy: DelegateProxy<CLLocationManager, CLLocationManagerDelegate>, DelegateProxyType, CLLocationManagerDelegate {

}
weak public private(set) var locationManager: CLLocationManager?

public init(locationManager: ParentObject) {
  self.locationManager = locationManager
  super.init(parentObject: locationManager,
             delegateProxy: RxCLLocationManagerDelegateProxy.self)
}
static func registerKnownImplementations() {
  register { RxCLLocationManagerDelegateProxy(locationManager: $0) }
}
public extension Reactive where Base: CLLocationManager {
  var delegate: DelegateProxy<CLLocationManager, CLLocationManagerDelegate> {
    RxCLLocationManagerDelegateProxy.proxy(for: base)
  }
}
var didUpdateLocations: Observable<[CLLocation]> {
  delegate.methodInvoked(#selector(CLLocationManagerDelegate.locationManager(_:didUpdateLocations:)))
    .map { parameters in
      parameters[1] as! [CLLocation]
    }
}

Using the button to get the current position

Now that you’ve created the extension, you’ll be able to use the location button in the bottom left corner:

import CoreLocation
private let locationManager = CLLocationManager()
geoLocationButton.rx.tap
  .subscribe(onNext: { [weak self] _ in
    guard let self = self else { return }

    self.locationManager.requestWhenInUseAuthorization()
    self.locationManager.startUpdatingLocation()
  })
  .disposed(by: bag)
locationManager.rx.didUpdateLocations
  .subscribe(onNext: { locations in
    print(locations)
  })
  .disposed(by: bag)

func currentWeather(at: CLLocationCoordinate2D) -> Observable<Weather>

Unifying authorization and location, reactively

You currently have a two-stepped mechanism to get the user’s location — you request authorization for their location, while simultaneously having a second subscription waiting for the locations to arrive.

var authorizationStatus: Observable<CLAuthorizationStatus> {
  delegate.methodInvoked(#selector(CLLocationManagerDelegate.locationManager(_:didChangeAuthorization:)))
    .map { parameters in
      CLAuthorizationStatus(rawValue: parameters[1] as! Int32)!
    }
    .startWith(CLLocationManager.authorizationStatus())
}
func getCurrentLocation() -> Observable<CLLocation> {
  let location = authorizationStatus
    .filter { $0 == .authorizedWhenInUse || $0 == .authorizedAlways } // 1
    .flatMap { _ in self.didUpdateLocations.compactMap(\.first) } // 2
    .take(1) // 3

  return location // 4
}
base.requestWhenInUseAuthorization()
base.startUpdatingLocation()
.do(onDispose: { [weak base] in base?.stopUpdatingLocation() })
func getCurrentLocation() -> Observable<CLLocation> {
  let location = authorizationStatus
    .filter { $0 == .authorizedWhenInUse || $0 == .authorizedAlways }
    .flatMap { _ in self.didUpdateLocations.compactMap(\.first) }
    .take(1)
    .do(onDispose: { [weak base] in base?.stopUpdatingLocation() })

  base.requestWhenInUseAuthorization()
  base.startUpdatingLocation()
  return location
}

Updating the weather with the current data

Now that you have your getCurrentLocation() reactive extension, it’s time to put it to use. Delete the two subscriptions to geoLocationButton.rx.tap and locationManager.rx.didUpdateLocations you’ve added in the previous section.

let geoSearch = geoLocationButton.rx.tap
  .flatMapLatest { _ in self.locationManager.rx.getCurrentLocation() }
  .flatMapLatest { location in
    ApiController.shared
      .currentWeather(at: location.coordinate)
      .catchErrorJustReturn(.dummy)
  }
let textSearch = searchInput.flatMap { city in
  ApiController.shared
    .currentWeather(for: city)
    .catchErrorJustReturn(.dummy)
}
let search = Observable
  .merge(geoSearch, textSearch)
  .asDriver(onErrorJustReturn: .dummy)
let running = Observable.merge(
  searchInput.map { _ in true },
  geoLocationButton.rx.tap.map { _ in true },
  search.map { _ in false }.asObservable()
)
.startWith(true)
.asDriver(onErrorJustReturn: false)

Extending a UIKit view

Now it’s time to explore how to extend a UIKit component and go beyond what RxCocoa offers.

Extending UIKit’s MKMapView

To start extending MKMapView, you will start with the exact same pattern you used to extend CLLocationManager: create a delegate proxy RxMKMapViewDelegateProxy and extend Reactive for the MKMapView base class.

extension MKMapView: HasDelegate {}

class RxMKMapViewDelegateProxy: DelegateProxy<MKMapView, MKMapViewDelegate>, DelegateProxyType, MKMapViewDelegate {

}

public extension Reactive where Base: MKMapView {
}
weak public private(set) var mapView: MKMapView?

public init(mapView: ParentObject) {
  self.mapView = mapView
  super.init(parentObject: mapView,
             delegateProxy: RxMKMapViewDelegateProxy.self)
}
static func registerKnownImplementations() {
  register { RxMKMapViewDelegateProxy(mapView: $0) }
}
var delegate: DelegateProxy<MKMapView, MKMapViewDelegate> {
  RxMKMapViewDelegateProxy.proxy(for: base)
}

mapButton.rx.tap
  .subscribe(onNext: {
    self.mapView.isHidden.toggle()
  })
  .disposed(by: bag)

Displaying overlays in the map

The map is now ready to receive and display data, but you’ll need to do a bit of work first to add the weather overlays. To add overlays to the map, you’ll implement one of its delegate methods:

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer

public static func installForwardDelegate(_ forwardDelegate: AnyObject, retainDelegate: Bool, onProxyForObject object: AnyObject) -> Disposable
func setDelegate(_ delegate: MKMapViewDelegate) -> Disposable {
  RxMKMapViewDelegateProxy.installForwardDelegate(
    delegate,
    retainDelegate: false,
    onProxyForObject: self.base
  )
}
mapView.rx
  .setDelegate(self)
  .disposed(by: bag)
extension ViewController: MKMapViewDelegate {
  func mapView(_ mapView: MKMapView,
               rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    guard let overlay = overlay as? ApiController.Weather.Overlay else {
      return MKOverlayRenderer()
    }

    return ApiController.Weather.OverlayView(overlay: overlay,
                                             overlayIcon: overlay.icon)
  }
}
var overlay: Binder<MKOverlay> {
  Binder(base) { mapView, overlay in
    mapView.removeOverlays(mapView.overlays)
    mapView.addOverlay(overlay)
  }
}

Using your new binding

It’s now time to use the new Binder you’ve created. I bet you can’t wait to see it in action!

func overlay() -> Overlay { ... }
search
  .map { $0.overlay() }
  .drive(mapView.rx.overlay)
  .disposed(by: bag)

Observing for map drag events

After extending MKMapView with a binding property, it’s time to see how to implement the more conventional notification mechanism for delegates. There’s nothing different than what you did for CLLocationManager, so you can simply follow the same pattern.

func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool)
var regionDidChangeAnimated: ControlEvent<Bool> {
  let source = delegate
    .methodInvoked(#selector(MKMapViewDelegate.mapView(_:regionDidChangeAnimated:)))
    .map { parameters in
      return (parameters[1] as? Bool) ?? false
    }

  return ControlEvent(events: source)
}

Reacting to region change events

The information about the dragging is provided, and an observation mechanism using RxSwift is in place. The only missing part is to use the previously created ControlEvent.

let mapInput = mapView.rx.regionDidChangeAnimated
  .skip(1)
  .map { _ in
    CLLocation(latitude: self.mapView.centerCoordinate.latitude,
               longitude: self.mapView.centerCoordinate.longitude)
  }
let geoInput = geoLocationButton.rx.tap
  .flatMapLatest { _ in self.locationManager.rx.getCurrentLocation() }
let geoSearch = Observable.merge(geoInput, mapInput)
  .flatMapLatest { location in
    ApiController.shared
      .currentWeather(at: location.coordinate)
      .catchErrorJustReturn(.dummy)
  }
let running = Observable.merge(
  searchInput.map { _ in true },
  geoInput.map { _ in true },
  mapInput.map { _ in true },
  search.map { _ in false }.asObservable()
)

Where to go from here?

In these two chapters on RxCocoa, you got a glimpse of some of the most interesting parts of this amazing extension on top of RxSwift. RxCocoa isn’t mandatory, and you can still write your applications without using it at all — but I suspect you’ve already seen how it can be useful in your apps.

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 reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now