Mete
Mete

Reputation: 5625

Filtering reactive dataset in RxSwift/RxCocoa with UI controls

(I'm quite new to Reactive programming here, and I'm aware I'm not quite thinking in the "Reactive" way yet so I'm not sure I'm quite doing this right...)

I have a dataset that comes from a REST API to drive a UICollectionView of Photos, with filtering of the collection done with switches in the UI. My current binding graph looks like

[API DataSet] -> unfilteredData: BehaviorRelay<Photo> -map-> collectionView.items

and in my last map stage I'm reading the state of the UI directly (switch.isOn) to apply filtering.

But then I want to drive the filtration with my switch.rx.isOn observable too. I'm doing this by getting unfilteredData to accept its own current value, and thereby re-drive the whole filtering mechanism and redisplay the collection view.

So there are 2 things I'm not super happy with here, as I feel like I'm still stuck in an imperative programming mindset.

  1. I'm reading the state of the UI instead of purely reacting to that state's changes
  2. I'm getting a BehaviorRelay to accept its own value in order to trigger its chain of events.

But since I don't want to reload the data set from the API each time the UI state changes (server calls are expensive) I feel like maybe I can't be purely reactive here?

Here's my simplified code to illustrate (with problem 1 and 2 noted).

private let unfilteredDataSource = BehaviorRelay<[Photo]>(value: [])

/// Refresh data from API
private func refreshData() {
    let photoResponse: Observable<[Photo]> = APICall(method: .GET, path: "photos.json")
    photoResponse
        .bind(to: unfilteredDataSource)
        .disposed(by: disposeBag)
}

/// Setup reactive bindings (on viewDidLoad)
private func setupRx() {
    // unfiltered becomes filtered via map before it's sent to collection items
    unfilteredDataSource
        .observeOn(MainScheduler.asyncInstance)
        .map {
            // filtering by hasLocation if includeAllSwitch is off
            $0.filter{ $0.hasLocation() || self.includeAllSwitch.isOn } //1
        }
        .bind(to: photoCollection.rx.items(cellIdentifier: "MyCell", cellType: PhotoCell.self)) { (row, element, cell) in
            // here I set up the cell...
        }
        .disposed(by: disposeBag)

    // filteringSwitch.isOn changes need to drive filtering too
    filteringSwitch.rx
        .isOn
        .asDriver(onErrorJustReturn: true) // Driver ensures main thread, and no errors (always returns true here)
        .drive(onNext: { [unowned self] switchValue in
            // triggering events using A = A; seems clumsy? And switchValue unused.
            self.unfilteredDataSource.accept(self.unfilteredDataSource.value) //2
        })
        .disposed(by: disposeBag)
}

What's the most "reactive" way of approaching a problem like this?

EDIT As I mention in the comment below "There's just one switch. There's the input JSON data from the server that goes to a collection view, and one switch which changes which photos are shown (toggle between showing all photos, or only a subset of photos that has a location attached in this instance)"

Upvotes: 1

Views: 283

Answers (1)

boa_in_samoa
boa_in_samoa

Reputation: 607

In this case you are presenting two event sequences: the api call, and the value of the switch. What you need is whenever the switch emits a value you want to 'remember' the latest response from your network call, and filter based on the switch value. Here's how to do it:

    let filteredPhotos = Observable.combineLatest(unfilteredDataSource.asObservable(), 
                             filteringSwitch.rx.isOn) { ($0, $1) } // 1
        .map { photos, includeAll in
            photos.filter { $0.hasLocation || includeAll }
        }
        .asDriver(onErrorJustReturn: [])

    filteredPhotos
        .drive(onNext: { ... })
        .disposed(by: disposeBag) 

// 1 - every time one of the observables emits a value, it calls the closure that combines the latest values into a touple. You don't have to worry about making another api call.

A few notable points:

  • combineLatest will wait until each of its observables emit one value, and complete only when the last sequence completes.
  • Also if any of the sequences complete, it uses the last emitted value.
  • You should use RxMarbles whenever you're unsure about an event stream (note that you can drag them around).
  • (almost) Everything can be a sequence.

With this in mind, there is even more room for improvement. I see you have a refreshData() function that can also be shifted to an event stream. You are probably calling this in viewDidLoad or viewDidAppear or maybe from an UIRefreshControll. But they are triggers, so they are a sequence of Void, right? That means you can flatMapLatest into your api call. This way, you also would no longer need the BehaviourRelay.

Upvotes: 1

Related Questions