Skip to Content

Advanced Pagination With Epoxy & Datasources

Many developers still avoid using Epoxy. I even hesitated for a long time because of the boilerplate coming a long with epoxy and its models, but after reading this post at the latest, you may consider using epoxy, when paging is needed.

So in this post we gonna build pagination with the jetpack‘s paging library, and epoxy, which has an own controller for paging.

We gonna use reddit as our api, by simply adding .json at the end of a subreddit‘s url.


First we need to implement a Datasource from the architecture components, which will help us later to load our data as a PagedList.

There are (for now) the following options as a Datasource:

  • PageKeyedDataSource: if you have some sort of before and/or after fields in your response, to load the previous/next page

  • ItemKeyedDataSource: if you need the key of your last item to load your next page

  • PositionalDataSource: if you gonna load your data by their positional index in ranges

We will use the PageKeyedDataSource in our guide, because the response of the reddit api provides us the proper before and after fields, but we could also use the ItemKeyedDatasource, where the after field of our last item would be our key for the next load.

class PageKeyedFeedDataSource(private val redditApi: RedditApi) :
  PageKeyedDataSource<String, RedditChildResponse>() { 
// pass key (String) and item type (RedditChildResponse)

// initial loading of the list (first page
// pass the list and before/after fields to the given callback
  override fun loadInitial(
    params: LoadInitialParams<String>, callback: LoadInitialCallback<String, RedditChildResponse>
  ) {
    redditApi.fetchNew(subreddit, limit = 10)
      .subscribeOn(Schedulers.io())
      .map { it.data }
      .subscribe({
        callback.onResult(it.children, it.before, it.after)
      }, {
        Timber.e(it)
        callback.onResult(emptyList(), "", "")
      })
  }

// load next page by the given parameter and pass the new data by the callback
  override fun loadAfter(
    params: LoadParams<String>, callback: LoadCallback<String, RedditChildResponse>
  ) {
    redditApi.fetchNew(subreddit, limit = 10, after = params.key)
      .subscribeOn(Schedulers.io())
      .map { it.data }
      .subscribe({
        callback.onResult(it.children, it.after)
      }, {
        Timber.e(it)
        callback.onResult(emptyList(), "")
      })
  }

// load the previous page by the given parameter (not implemented since we just append pages)
  override fun loadBefore(
    params: LoadParams<String>, callback: LoadCallback<String, RedditChildResponse>
  ) = Unit

  companion object {
    const val subreddit = "androiddev"
  }
}

After implementing our datasource, we need a factory for providing the data to create a LiveData. If you are using Room, you may want to load your data from the Dao already as a Datasource.Factory.

It‘s a short class as a dagger singleton, where we have to implement the create()-function, to provide our datasource in the application.

@Singleton
class RedditFeedDataSourceFactory @Inject constructor(private val redditApi: RedditApi) :
  DataSource.Factory<String, RedditChildResponse>() {
  override fun create(): DataSource<String, RedditChildResponse> {
    return PageKeyedFeedDataSource(redditApi)
  }
}

Since we have prepared the datasource factory, we can inject it to our viewmodel, and initialize our LiveData by the LivePagedListBuilder. This builder can either be used with a PagedList.Config for configuring some loading options, or by simply passing only the loading page size as an integer.

Furthermore you are able to set a BoundaryCallback, if you want to catch some events like the last item was loaded, or zero items were loaded initially.

class FeedViewModel @Inject constructor(sourceFactory: RedditFeedDataSourceFactory) : ViewModel() {

    // observe this for loading data
    val feed: LiveData<PagedList<RedditChildResponse>>

    init {

        // this builder initalizes our LiveData with the given options
        // we could pass also an integer for the page size instead of the PageListConfig
        feed = LivePagedListBuilder<String, RedditChildResponse>(sourceFactory, getPagedListConfig())
        .setBoundaryCallback(object : PagedList.BoundaryCallback<RedditChildResponse>() {
            override fun onItemAtEndLoaded(itemAtEnd: RedditChildResponse) {
            super.onItemAtEndLoaded(itemAtEnd)
            Timber.v("reached end of feed")
            }
        })
        .build()
    }

    //stops further loading
    fun reload() {
        feed.value?.dataSource?.invalidate()
    }

    // setting some loading options
    private fun getPagedListConfig(): PagedList.Config {
        return PagedList.Config.Builder()
        .setEnablePlaceholders(false)
        .setInitialLoadSizeHint(5)
        .setPageSize(20)
        .build()
    }
}

Finally, we need to implement the PagedListEpoxyController.

class FeedController @Inject constructor(private val view: FeedActivity) :
    PagedListEpoxyController<RedditChildResponse>
    (modelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()) {
    // pass a handler to avoid building in main thread!!

    // this is a flag which could be set from the boundary callback when the end is reached
    var endReached = false

    // receives the current position & the nullable item as a parameter, 
    // and wants the epoxy model to be returned
    // ,, you may want to return a placeholder model for a null item
    override fun buildItemModel(currentPosition: Int, item: RedditChildResponse?): EpoxyModel<*> {
        return if (item == null)
            EmptyModel_()
                .id(currentPosition)
        else
            RedditPostModel_()
                .post(item)
                .onClick { view.openLink(it) }
                .id(item.data.permalink)
    }

    // optionally you can override this function if you want to have some headers or footers
    // just add your models to the this controller before (headers) or after (footers) the super call 
    override fun addModels(models: List<EpoxyModel<*>>) {
        super.addModels(models)
        LoadingModel_()
            .id("loading")
            .addIf(!endReached && models.isNotEmpty(), this)
    }
}

We did it. Our LiveData is ready to be observed, and the only thing our epoxy controller needs is the PagedList from our LiveData… So let‘s unite this couple:

feed.observe(this, Observer { pagedlist ->
        controller.submitList(pagedlist)
})

Attention: only one PagedList will be received from the LiveData for each build. So dont wonder (like me), why you dont receive every page here :)

This means we won‘t see the ~ pages ~ from the list in our code. It‘s the work of PagedList & the Epoxycontroller, and not our business anymore.

By the way, if you want to reload the data, just invalidate the datasource of the PagedList.

Check out the sample project on github