Paginated list with animated updates (RxFeedback, RxDataSources)

turekj

Jakub Turek

Posted on September 2, 2019

Paginated list with animated updates (RxFeedback, RxDataSources)

RxSwift is an amazing framework that streamlines all of the event handling in the iOS apps and makes them less prone to state related issues. RxSwift simplifies data updates from sources such as the local database, HTTP services or websockets.

At EL Passion we love using RxSwift. In fact we ❤️ it so much that we've held the two editions of the RxSwift workshop for iOS developers.

However, there are some cases where pure RxSwift is not always enough. For example paginating API results requires parts of explicit state management. That's why RxFeedback architecture came to life. It helps modelling system-level interactions with RxSwift abstractions.

In the showcase we present how we've built the paginated list with animated updates in approximately 100 lines of code for our Client.

Case study

The case study comes 100% from a real app built for one of the EL Passion's Clients. You can see it in action below:

Case study
Pagination preview

View models

The app's been built using the MVVM architecture. Every collection cell has it's own view model assigned:

class ModelListItemViewModel: IdentifiableType, Equatable {
    init(model: Model) {
        self.model = model
    }

    // MARK: - Public API

    var image: Driver<UIImage> { /* ... */ }
    var author: Driver<String> { /* ... */ }

    // MARK: - IdentifiableType

    var identity: String {
        return model.id
    }

    // MARK: - Equatable

    public static func == (lhs: ModelListItemViewModel, rhs: ModelListItemViewModel) -> Bool {
        return lhs.model == rhs.model
    } 

    // MARK: - Private

    private let model: Model
}

In order to get the nicely animated updates with near-zero development cost, the app uses RxDataSources library. That's why the view model conforms to both IdentifiableType and Equatable types.

The collection view has a single section modelled as a list of items:

struct ModelSectionViewModel {
    var items: [ModelListItemViewModel]
}

extension ModelSectionViewModel: AnimatableSectionModelType {
    init(original: ModelSectionViewModel, items: [Item]) {
        self = original
        self.items = items
    }

    var identity: String {
        return String(describing: ModelSectionViewModel.self)
    }
}

RxFeedback

In order to build pagination with RxFeedback you need to define:

  • state structure,
  • possible events enum,
  • reduce function which converts the current state and the event to the new state,
  • system description with a list of feedback loops.

State

The state consists of already downloaded models and the next page offset. It also holds a flag to tell whether we should start downloading the next page.

struct ModelListState: Equatable {
    let models: [Model]
    let nextPageOffset: Int?
    let shouldLoadNextPage: Bool

    init(models: [Model] = [], nextPageOffset: Int? = 0, shouldLoadNextPage: Bool = true) {
        self.models = models
        self.nextPageOffset = nextPageOffset
        self.shouldLoadNextPage = shouldLoadNextPage
    }
}

Possible events

Scrolling the list toggles scrolledTo(item:) event. There're also 2 types of events to handle API responses (success & failure).

enum ModelListEvent {
    case scrolledTo(item: IndexPath)
    case response(page: ModelPage)
    case error
}

Reducer

Reducer handles the events as follows:

  • scrolled to an item event - it flips shouldLoadNextPage flag to true when user scrolled down to the bottom item,
  • next page response - it appends the models, updates the next page offset and stops fetching the next page.
  • error - it stops fetching the next page.
extension ModelListState {
    static func reduce(state: ModelListState, event: ModelListEvent) -> ModelListState {
        switch event {
        case let .scrolledTo(indexPath):
            return reduceLoadNextPage(item: indexPath.item, state: state)
        case let .response(page):
            return ModelListState(
                models: state.models + page.models,
                nextPageOffset: page.nextPageOffset,
                shouldLoadNextPage: false
            )
        case .error:
            return ModelListState(original: state, shouldLoadNextPage: false)
        }
    }

    var fetchOffset: Int? {
        return shouldLoadNextPage ? nextPageOffset : nil
    }

    // MARK: - Private

    private static func reduceLoadNextPage(item: Int, state: ModelListState) -> ModelListState {
        guard !state.shouldLoadNextPage, item + 1 >= state.models.count else {
            return state
        }

        return ModelListState(original: state, shouldLoadNextPage: true)
    }
}

Feedback loop

There are 2 feedback loops defined:

  • UI feedback loop - it wires sections' view models to the collection view and receives scroll to item events,
  • react feedback loop - it triggers fetching the next page when fetchOffset property is present for the page.
/// Signal called on will display cell event
let willDisplaySubject: PublishSubject<IndexPath>

/// Observable list of sections that drive UICollectionView
let sectionsRelay: BehaviorRelay<[ModelSectionViewModel]>

/// Creates a view model for a single list item from the API data
let itemViewModel: (Model) -> ModelListItemViewModel

/// Downloads a single page of results from the API
let listProvider: (Int) -> Single<ModelPage>

/// Maps state updates into events stream
let bindings: (Driver<ModelListState>) -> Signal<ModelListEvent> = bind(self) { me, state in
    let sectionSubscription = state
        .map { $0.models.map { me.itemViewModel(for: $0) } }
        .map { [ModelSectionViewModel(items: $0)] }
        .drive(me.sectionsRelay)

    let displayCellEvent = me.willDisplayCellSubject
        .map { ModelListEvent.scrolledTo(item: $0) }
        .asSignal(onErrorJustReturn: .error)

    return Bindings(subscriptions: [sectionSubscription], events: [displayCellEvent])
}

/// Models whole pagination subsystem
Driver.system(
    initialState: ModelListState(),
    reduce: ModelListState.reduce,
    feedback: bindings,
    react(query: { $0.fetchOffset }, effects: { [listProvider] offset in
        listProvider(offset)
            .map { ModelListEvent.response(page: $0) }
            .asSignal(onErrorJustReturn: .error)
        })
    )
.drive()
.disposed(by: disposeBag)

License

Copyright © 2019 EL Passion

💖 💪 🙅 🚩
turekj
Jakub Turek

Posted on September 2, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related