Paginated API Calls with RxSwift

Broco picture Broco · Sep 20, 2016 · Viewed 7.9k times · Source

I am starting my first RxSwift project for an iOS app and learning about reactive programming.

So far, the idea is quite simple, the user searches for films matching the search bar text, this fires a request that populates a UITableView with the results. Using tutorials and examples found online I've managed to implement this bit without too much trouble.

The tricky part comes when I'm trying to load the next page of results triggered by scrolling the bottom of the table view.

Here is the code used so far:

public final class HomeViewModel: NSObject {

    // MARK: - Properties

    var searchText: Variable<String> = Variable("")
    var loadNextPage: Variable<Void> = Variable()

    lazy var pages: Observable<PaginatedList<Film>> = self.setupPages()

    // MARK: - Reactive Setup

    fileprivate func setupPages() -> Observable<PaginatedList<Film>> {
        return self.searchText
            .asObservable()
            .debounce(0.3, scheduler: MainScheduler.instance)
            .distinctUntilChanged()
            .flatMapLatest { (query) -> Observable<PaginatedList<Film>> in
                return TMDbAPI.Films(withTitle: query, atPage: 0)
            }
            .shareReplay(1)
    }
}

Here is what I have so far: The observable pages is binded to my table view in HomeViewController and its search bar text is binded to searchText.

I'm using Alamofire to perform API calls behind the scenes, and TMDbAPI.Films(withTitle: query) returns an Observable of paginated lists.

Here is my model structure PaginatedList

public struct PaginatedList<T> {

    // MARK: - Properties

    let page: Int
    let totalResults: Int
    let totalPages: Int
    let results: [T]

    // MARK: - Initializer

    init(page: Int, totalResults: Int, totalPages: Int, results: [T]) {
        self.page = page
        self.totalResults = totalResults
        self.totalPages = totalPages
        self.results = results
    }

    // MARK: - Helper functions / properties

    var count: Int { return self.results.count }

    var nextPage: Int? {
        let nextPage = self.page + 1
        guard nextPage < self.totalPages else { return nil }
        return nextPage
    }

    static func Empty() -> PaginatedList { return PaginatedList(page: 0, totalResults: 0, totalPages: 0, results: []) }
}

extension PaginatedList {

    // MARK: - Subscript

    subscript(index: Int) -> T {
        return self.results[index]
    }
}

I am now looking for a reactive way to hook my loadNextPage variable to the observable of paginated lists in a way that it would trigger a request for the next page. And when the search bar text changes it would reset the pagination to 0.

I believe the use of the operators scan and concat would be needed but I'm still not sure how...

Any suggestions on how to achieve this would be much appreciated...

Answer

Broco picture Broco · Sep 22, 2016

Based on the examples provided with in the RxSwift GitHub repo I managed to do it.

Basically, I'm using a recursive function that returns my stream of PaginatedList items, it calls itself with the loadNextPage trigger for the next page. Here the code I used in my API manager:

class func films(withTitle title: String, startingAtPage page: Int = 0, loadNextPageTrigger trigger: Observable<Void> = Observable.empty()) -> Observable<[Film]> {
    let parameters: FilmSearchParameters = FilmSearchParameters(query: title, atPage: page)
    return TMDbAPI.instance.films(fromList: [], with: parameters, loadNextPageTrigger: trigger)
}

fileprivate func films(fromList currentList: [Film], with parameters: FilmSearchParameters, loadNextPageTrigger trigger: Observable<Void>) -> Observable<[Film]> {

    return self.films(with: parameters).flatMap { (paginatedList) -> Observable<[Film]> in
        let newList = currentList + paginatedList.results
        if let _ = paginatedList.nextPage {
            return [
                Observable.just(newList),
                Observable.never().takeUntil(trigger),
                self.films(fromList: newList, with: parameters.nextPage, loadNextPageTrigger: trigger)
            ].concat()
        } else { return Observable.just(newList) }
    }
}

fileprivate func films(with parameters: FilmSearchParameters) -> Observable<PaginatedList<Film>> {
    guard !parameters.query.isEmpty else { return Observable.just(PaginatedList.Empty()) }
    return Observable<PaginatedList<Film>>.create { (observer) -> Disposable in
        let request = Alamofire
            .request(Router.searchFilms(parameters: parameters))
            .validate()
            .responsePaginatedFilms(queue: nil, completionHandler: { (response) in
                switch response.result {
                case .success(let paginatedList):
                    observer.onNext(paginatedList)
                    observer.onCompleted()
                case .failure(let error):
                    observer.onError(error)
                }
            })
        return Disposables.create { request.cancel() }
    }
}

And then in my view model, this is all I have to have to do:

fileprivate func setupFilms() -> Observable<[Film]> {

    let trigger = self.nextPageTrigger.asObservable().debounce(0.2, scheduler: MainScheduler.instance)

    return self.textSearchTrigger
        .asObservable()
        .debounce(0.3, scheduler: MainScheduler.instance)
        .distinctUntilChanged()
        .flatMapLatest { (query) -> Observable<[Film]> in
            return TMDbAPI.films(withTitle: query, loadNextPageTrigger: trigger)
        }
        .shareReplay(1)
}