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...
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)
}
Here's how you could structure it:
// Some kind of page request result. Modify it to be what you're using.
struct SomePageResult {
let content: String
}
// Needs modification to return your actual data
func getPage(query: String, number: UInt) -> SomePageResult {
return SomePageResult(content: "some content for search (\(query)) on page \(number)")
}
// Actual implementation
let disposeBag = DisposeBag()
var loadNextPage = PublishSubject<Void>()
var searchText = PublishSubject<String>()
let currentPage = searchText
.distinctUntilChanged()
.flatMapLatest { searchText in
return loadNextPage.asObservable()
.startWith(())
.scan(0) { (pageNumber, _) -> UInt in
pageNumber + 1
}
.map { pageNumber in
(searchText, pageNumber)
}
}
.map { (searchText, pageNumber) in
getPage(searchText, number: pageNumber)
}
currentPage
.subscribeNext { print($0) }
.addDisposableTo(disposeBag)
searchText.onNext("zebra")
searchText.onNext("helicopter")
loadNextPage.onNext()
searchText.onNext("unicorn")
searchText.onNext("unicorn")
searchText.onNext("ant")
loadNextPage.onNext()
loadNextPage.onNext()
loadNextPage.onNext()
Output:
SomePageResult(content: "some content for search (zebra) on page 1")
SomePageResult(content: "some content for search (helicopter) on page 1")
SomePageResult(content: "some content for search (helicopter) on page 2")
SomePageResult(content: "some content for search (unicorn) on page 1")
SomePageResult(content: "some content for search (ant) on page 1")
SomePageResult(content: "some content for search (ant) on page 2")
SomePageResult(content: "some content for search (ant) on page 3")
SomePageResult(content: "some content for search (ant) on page 4")
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With