Pagination

A very complete recipe that implements a RecyclerView that pulls in paginated data, using Retrofit, LiveData. It includes example models, adapter, viewholders, and a viewholder for displaying the network state in case of an error.

Using Jetpackā€™s pagination library (at the time of this post v.2.1.0).

Usage

Gradle dependency:

implementation "androidx.paging:paging-runtime-ktx:$pagination_version"

Api call:

@GET("/myPath")
    fun getMyModel(): Call<MyModelResponse>

Example of MyModelResponse:

data class MyModelResponse(
    val data: List<MyModel>,
    val totalCount: Int,
    val lastEvaluatedKey: MyModelLastKey?
)
/**
 * Data class that is necessary for a UI to show a listing and interact w/ the rest of the system
 */
data class PagingListing<T>(
    // the LiveData of paged lists for the UI to observe
    val pagedList: LiveData<PagedList<T>>,
    // represents the network request status to show to the user
    val networkState: LiveData<PagingNetworkState>,
    // represents the refresh status to show to the user. Separate from networkState, this
    // value is importantly only when refresh is requested.
    val refreshState: LiveData<PagingNetworkState>,
    // refreshes the whole data and fetches it from scratch.
    val refresh: () -> Unit,
    // retries any failed requests.
    val retry: () -> Unit
)
// A class to hold the network state
@Suppress("DataClassPrivateConstructor")
data class PagingNetworkState private constructor(
    val status: Status,
    val msg: String? = null
) {
    companion object {
        val LOADED = PagingNetworkState(Status.SUCCESS)
        val LOADING = PagingNetworkState(Status.LOADING)
        fun error(msg: String?) = PagingNetworkState(Status.ERROR, msg)
    }
}
class MyModelPageKeyedDataSource(private val retryExecutor: Executor) :
    PageKeyedDataSource<MyModelLastKey, MyModel>() {

    // keep a function reference for the retry event
    private var retry: (() -> Any)? = null

    /**
     * There is no sync on the state because paging will always call loadInitial first then wait
     * for it to return some success value before calling loadAfter.
     */
    val networkState = MutableLiveData<PagingNetworkState>()
    val initialLoad = MutableLiveData<PagingNetworkState>()

    fun retryAllFailed() {
        val prevRetry = retry
        retry = null
        prevRetry?.let {
            retryExecutor.execute { it.invoke() }
        }
    }

    override fun loadBefore(
        params: LoadParams<MyModelLastKey>,
        callback: LoadCallback<MyModelLastKey, MyModel>
    ) {
        // no-op
    }

    override fun loadInitial(
        params: LoadInitialParams<MyModelLastKey>,
        callback: LoadInitialCallback<MyModelLastKey, MyModel>
    ) {
        val request = Api.service.getMyModel()
        networkState.postValue(PagingNetworkState.LOADING)
        initialLoad.postValue(PagingNetworkState.LOADING)

        // triggered by a refresh, we better execute sync
        try {
            val response = request.execute()
            val originalData = response.body()?.data
            retry = null
            networkState.postValue(PagingNetworkState.LOADED)
            initialLoad.postValue(PagingNetworkState.LOADED)
            callback.onResult(originalData.orEmpty(),
                null, response.body()?.lastEvaluatedKey)
        } catch (exception: IOException) {
            retry = { loadInitial(params, callback) }
            val error = PagingNetworkState.error(exception.message ?: "unknown error")
            networkState.postValue(error)
            initialLoad.postValue(error)
        }
    }

    override fun loadAfter(
        params: LoadParams<MyModelLastKey>,
        callback: LoadCallback<MyModelLastKey, MyModel>
    ) {
        networkState.postValue(PagingNetworkState.LOADING)
        Api.service.getMainContent().enqueue(object : Callback<MyModelResponse> {
            override fun onFailure(call: Call<MyModelResponse>, t: Throwable) {
                retry = { loadAfter(params, callback) }
                networkState.postValue(PagingNetworkState.error(t.message ?: "unknown error"))
            }

            override fun onResponse(
                call: Call<MyModelResponse>,
                response: Response<MyModelResponse>
            ) {
                if (response.isSuccessful) {
                    val data = response.body()?.data
                    retry = null
                    callback.onResult(data.orEmpty(), response.body()?.lastEvaluatedKey)
                    networkState.postValue(PagingNetworkState.LOADED)
                } else {
                    retry = { loadAfter(params, callback) }
                    networkState.postValue(PagingNetworkState.error("error code: ${response.code()} : ${response.errorBody()}"))
                }
            }
        })
    }
}
class MyModelDataSourceFactory (private val retryExecutor: Executor) : DataSource.Factory<MyModelLastKey, MyModel>() {
    val sourceLiveData = MutableLiveData<MyModelPageKeyedDataSource>()

    override fun create(): DataSource<MyModelLastKey, MyModel> {
        val source = MyModelPageKeyedDataSource(retryExecutor)
        sourceLiveData.postValue(source)
        return source
    }
}

Repository:

fun getMyModel(): PagingListing<MyModel> {
        val sourceFactory = MyModelDataSourceFactory(executors.networkExecutorService)
        val livePagedList = sourceFactory.toLiveData(
            pageSize = REQUEST_LIMIT,
            fetchExecutor = executors.networkExecutorService
        )
        val refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) {
            it.initialLoad
        }

        return PagingListing(
            pagedList = livePagedList,
            networkState = Transformations.switchMap(sourceFactory.sourceLiveData) {
                it.networkState
            },
            refreshState = refreshState,
            refresh = {
                sourceFactory.sourceLiveData.value?.invalidate()
            },
            retry = {
                sourceFactory.sourceLiveData.value?.retryAllFailed()
            }
        )
    }

ViewModel:

private val _myModelList : MutableLiveData<PagingListing<MyModel>> = MutableLiveData(repo.getMyModel())

val myModelList = Transformations.switchMap(_myModelList) { it.pagedList }
val networkState = Transformations.switchMap(_myModelList) { it.networkState }

fun refresh() {
    _myModelList.value?.refresh?.invoke()
}

fun retry() {
     _myModelList.value?.retry?.invoke()
}

Fragment:

 viewModel.myModelList.observe(viewLifecycleOwner, Observer {
            adapter.submitList(it)
})

Adapter:

class MyModelAdapter(
    private val onMyModelItemClickListener: ((myModel: MyModel) -> Unit),
    private val retryCallback: () -> Unit
) :
    PagedListAdapter<MyModel, RecyclerView.ViewHolder>(
        COMPARATOR_NOTIFICATIONS
    ) {

    companion object {
        private val COMPARATOR_MY_MODEL = object : DiffUtil.ItemCallback<MyModel>() {
            override fun areContentsTheSame(oldItem: MyModel, newItem: MyModel): Boolean =
                oldItem == newItem

            override fun areItemsTheSame(oldItem: MyModel, newItem: MyModel): Boolean =
                oldItem.id == newItem.id
        }
    }

    private var networkState: PagingNetworkState? = null

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            R.layout.li_my_model -> MyModelViewHolder(
                LayoutInflater.from(parent.context)
                    .inflate(R.layout.li_my_model, parent, false)
            )
            R.layout.li_paging_network_state -> NetworkStateItemViewHolder.create(
                parent,
                retryCallback
            )
            else -> throw IllegalArgumentException("unknown view type $viewType")
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (getItemViewType(position)) {
            R.layout.li_my_model -> (holder as MyModelViewHolder).bind(
                getItem(position),
                onMyModelItemClickListener
            )
            R.layout.li_paging_network_state -> (holder as NetworkStateItemViewHolder).bindTo(
                networkState
            )
        }
    }

    private fun hasExtraRow() = networkState != null && networkState != PagingNetworkState.LOADED

    override fun getItemViewType(position: Int): Int {
        return if (hasExtraRow() && position == itemCount - 1) {
            R.layout.li_paging_network_state
        } else {
            R.layout.li_my_model
        }
    }

    override fun getItemCount(): Int {
        return super.getItemCount() + if (hasExtraRow()) 1 else 0
    }

    fun setNetworkState(newNetworkState: PagingNetworkState?) {
        val previousState = this.networkState
        val hadExtraRow = hasExtraRow()
        this.networkState = newNetworkState
        val hasExtraRow = hasExtraRow()
        if (hadExtraRow != hasExtraRow) {
            if (hadExtraRow) {
                notifyItemRemoved(super.getItemCount())
            } else {
                notifyItemInserted(super.getItemCount())
            }
        } else if (hasExtraRow && previousState != newNetworkState) {
            notifyItemChanged(itemCount - 1)
        }
    }

    class MyModelViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val title: TextView = itemView.findViewById(R.id.tv_title)
        ...

        fun bind(
            myModel: MyModel?,
            onMyModelItemClickListener: (myModel: MyModel) -> Unit
        ) {
            myModel?.let {
                title.text = myModel.title
                ...

                itemView.setOnClickListener {
                    onMyModelItemClickListener.invoke(myModel)
                }
            }
        }
    }

    class NetworkStateItemViewHolder(view: View, private val retryCallback: () -> Unit) :
        RecyclerView.ViewHolder(view) {
        private val progressBar = view.findViewById<ProgressBar>(R.id.progress_bar)
        private val retry = view.findViewById<Button>(R.id.retry_button)
        private val errorMsg = view.findViewById<TextView>(R.id.error_msg)

        init {
            retry.setOnClickListener {
                retryCallback()
            }
        }

        fun bindTo(networkState: PagingNetworkState?) {
            progressBar.visibility = toVisibility(networkState?.status == Status.LOADING)
            retry.visibility = toVisibility(networkState?.status == Status.ERROR)
            errorMsg.visibility = toVisibility(networkState?.msg != null)
            errorMsg.text = networkState?.msg
        }

        companion object {
            fun create(parent: ViewGroup, retryCallback: () -> Unit): NetworkStateItemViewHolder {
                val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.li_paging_network_state, parent, false)
                return NetworkStateItemViewHolder(view, retryCallback)
            }

            fun toVisibility(constraint: Boolean): Int {
                return if (constraint) {
                    View.VISIBLE
                } else {
                    View.GONE
                }
            }
        }
    }
}

li_paging_network_state.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="8dp">

    <TextView
        android:id="@+id/error_msg"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal" />

    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center" />

    <Button
        android:id="@+id/retry_button"
        style="@style/Widget.AppCompat.Button.Colored"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="@string/retry_message" />
</LinearLayout>