Since there seemed to be very few articles on the Android Paging library and especially on pagination in the AdapterDelegates library, I am writing my own.
The Paging library allows you to organize pagination of data in lists in both directions, and can also show a loader at the bottom of the list and some other things.
Google recommends using AsyncPagingDataDiffer, but I will give an example with PagingDataAdapter, which represents a wrapper over AsyncPagingDataDiffer and implements RecyclerView.Adapter.
I will not make a RemoteMediator – in most tasks it is not needed, we can add it to the article later
The code will contain comments on what is needed and for what. If you find an error, please write to RomanKryvolapov on Telegram. I write everything in a notepad, because I am too lazy to create a project
Let’s say we have a list with user categories and users
// interface for convenience, or Any interface UserOrCategory // models for the application domain layer data class UserData( val id: String, val title: String, val description: String, val avatar: String, ) : UserOrCategory data class CategoryData( val id: String, val title: String, val error: String ) : UserOrCategory
network models
// interface for converting network model to application model interface DomainMapper<T : Any> { fun mapToDomainModel(): T } // network models for the application data layer // assume that in case of failure the api returns a text error data class UsersAndCategoriesNetworkModel( private val list: List<Any>, private val error: String?, ) : DomainMapper<List<UserOrCategory>> { // As a result, we should get a list containing users and categories List<UserOrCategory> override fun mapToDomainModel() = list.map { userOrCategory -> if (userOrCategory.type == "user") { (userOrCategory as UserNetworkModel).mapToDomainModel() } else if (userOrCategory.type == "category") { (userOrCategory as CategoryNetworkModel).mapToDomainModel() } } ?: emptyList() } data class UserNetworkModel( private val id: String?, private val type: String?, private val title: String?, private val description: String?, private val avatar: String?, ) : DomainMapper<UserData> { override fun mapToDomainModel() = UserData( id = id ?: "", title = title ?: "Unknown", description = description ?: "No description", avatar = avatar ?: "", ) } data class CategoryNetworkModel( private val id: String?, private val type: String?, private val title: String?, ) : DomainMapper<CategoryData> { override fun mapToDomainModel() = CategoryData( id = id ?: "", title = title ?: "Unknown", ) }
Next we need to create a PagingSource in the data layer
// getUsersAndCategories in networkApi - suspend function that returns UsersAndCategoriesNetworkModel // for example @FormUrlEncoded @POST("get_users") suspend fun getUsersAndCategories( @Field("page_number") pageNumber: Int, @Field("page_size") pageSize: Int ): Response<UsersAndCategoriesNetworkModel> // or write the fields to the map and pass it as body @POST("get_users") suspend fun getUsersAndCategories( @Body body: Map<String, String> ): Response<UsersAndCategoriesNetworkModel> // data source for the application data layer, in theory the call to getUsersAndCategories // could be done through the repository, but here I did it directly. Also, the data source can theoretically be not only NetworkApi but anything class UserAndCategoryDataSource( private val pageSize: Int, private val initialPageNumber: Int, private val networkApi: NetworkApi, ) : PagingSource<Int, TransactionHistory>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UserOrCategory> { // the page number is in key, if it is null then we also pass the initial page number to the class // initialPageNumber for api, since it can be, for example, 0 or 1 val pageNumber: Int = params.key ?: initialPageNumber return try { val response = networkApi.getUsersAndCategories(pageNumber, pageSize) // if the text is not empty, then an error occurred if (!response?.body()?.error.isNullOrEmpty()) { LoadResult.Error(throwable = java.lang.Exception()) } else { val userAndCategoryList = response.body()?.mapToDomainModel() // what will be in pageNumber for the next page val nextPageNumber: Int? = if (!userAndCategoryList.isNullOrEmpty()) pageNumber + 1 else null // and for the previous one val prevPageNumber: Int? = if (pageNumber > initialPageNumber) pageNumber - 1 else null // and here is the data for the pager LoadResult.Page( data = userAndCategoryList.orEmpty(), prevKey = prevPageNumber, nextKey = nextPageNumber ) } // it would be better to specify a network error here rather than Exception } catch (e: Exception) { LoadResult.Error(throwable = e) } } override fun getRefreshKey(state: PagingState<Int, UserOrCategory>): Int? { val anchorPosition = state.anchorPosition ?: return null val page = state.closestPageToPosition(anchorPosition) ?: return null return page.prevKey?.plus(1) ?: page.nextKey?.minus(1) }
Next we move on to the ViewModel
// we will inject networkApi class UserAndCategoryViewModel(private val networkApi: NetworkApi) : ViewModel() { fun getUsersAndCategories( pageSize: Int, prefetchDistance: Int, initialLoadSize: Int, initialPageNumber: Int, ): Flow<PagingData<UserOrCategory>> = Pager( config = PagingConfig( pageSize = pageSize, enablePlaceholders = false, prefetchDistance = prefetchDistance, initialLoadSize = initialLoadSize, ) // RemoteMediator is also thrown here via "remoteMediator =" if it is used ) { UserAndCategoryDataSource( pageSize = pageSize, initialPageNumber = initialPageNumber, networkApi = networkApi, ) }.flow .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.Lazily, PagingData.empty()) .cachedIn(viewModelScope) }
Next is a fragment
// first a small extension for flow fun <T> Flow<T>.launchWhenStarted(scope: LifecycleCoroutineScope) { scope.launchWhenStarted { [email protected]() } } // next is a fragment class UsersFragment : BaseFragment() { companion object { // then all constants for the pager private const val PAGE_SIZE = 10 private const val PREFETCH_DISTANCE = 3 private const val INITIAL_LOAD_SIZE = 10 private const val INITIAL_STATEMENTS_PAGE_NUMBER = 1 fun newInstance() = UsersFragment() } // loadStateJob is not needed in this example, but you may need to use it to cancel a task private var loadStateJob: Job? = null private val binding by viewBinding(FragmentUsersBinding::bind) private val viewModel: UserAndCategoryViewModel by sharedViewModel() // here we initialize the first adapter UserAndCategoryAdapter, which will show the list private val userAndCategoryAdapter = UserAndCategoryAdapter( onUserClicked = { // logic for clicking on user, could use listiner instead or any other way } ) override fun getLayout() = R.layout.fragment_users override fun viewReady() { binding.run { // with withLoadStateHeaderAndFooter you can set a second StatementsLoaderAdapter that shows loading and error recyclerViewUsersAndCategories.adapter = userAndCategoryAdapter.withLoadStateHeaderAndFooter( header = StatementsLoaderAdapter(), footer = StatementsLoaderAdapter() ) subscribeToLoadState() subscribeToDataSource() } } private fun subscribeToLoadState() { // instead of loadStateFlow you can use addLoadStateListener userAndCategoryAdapter.loadStateFlow.onEach { state -> loadStateJob?.cancel() loadStateJob = lifecycleScope.launchWhenStarted { val isLoading = state.refresh == LoadState.Loading val isEmpty = statementsAdapter.itemCount < 1 val isError = state.refresh is LoadState.Error // and then any logic for the states, I advise logging the states to see how they work // in general there are CombinedLoadStates: refresh, prepend, append, source, mediator, and LoadState states: Loading, NotLoading, Error } } }.launchWhenStarted(lifecycleScope) private fun subscribeToDataSource() { // pass all constants viewModel.getAccountStatementsDataSource( pageSize = PAGE_SIZE, prefetchDistance = PREFETCH_DISTANCE, initialLoadSize = INITIAL_LOAD_SIZE, initialPageNumber = INITIAL_STATEMENTS_PAGE_NUMBER, ).onEach { data -> // throw the date into the adapter userAndCategoryAdapter.submitData(data) }.launchWhenStarted(lifecycleScope) } }
next is an adapter for displaying the list
class UserAndCategoryAdapter(private val onUserClicked: (String) -> Unit) : PagingDataAdapter<UserOrCategory, RecyclerView.ViewHolder>(DiffCallback) { companion object { private enum class Type(val value: Int) { USER(0), CATEGORY(1), } } // I will use viewBinding here override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { Type.USER.value -> UserHolder( LayoutUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) else -> CategoryHolder( LayoutCategoryBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is CategoryHolder -> holder.bind( categoryData = getItem(position) as CategoryData ) is UserHolder -> holder.bind( userData = getItem(position) as UserData onUserClicked = { onUserClicked(it) } ) } } override fun getItemViewType(position: Int) = when (getItem(position)) { is UserData -> Type.USER.value else -> Type.CATEGORY.value } class CategoryHolder(private val binding: LayoutCategoryBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(categoryData: CategoryData) { binding.category.setSimpleText(categoryData.title) } } class UserHolder(private val binding: LayoutUserBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(userData: UserData, onUserClicked: (String) -> Unit) { binding.run { title.setSimpleText(userData.title) description.setSimpleText(userData.description) avatar.load(userData.avatar) } } } private object DiffCallback : DiffUtil.ItemCallback<UserOrCategory>() { // here is some logic for comparing elements override fun areItemsTheSame(oldItem: UserOrCategory, newItem: UserOrCategory): Boolean { if (oldItem is UserData && newItem is UserData && oldItem.id == newItem.id) { return true } else if (oldItem is CategoryData && newItem is CategoryData && oldItem.id == newItem.id) { return true } return false } // here is some logic for comparing whether the element has been updated or not override fun areContentsTheSame(oldItem: UserOrCategory, newItem: UserOrCategory): Boolean { if (oldItem is UserData && newItem is UserData && oldItem.title == newItem.title) { return true } else if (oldItem is CategoryData && newItem is CategoryData && oldItem.category == newItem.category) { return true } return false } } }
and then an adapter for displaying states
// here for the sake of variety I didn't use ViewBinding class StatementsLoaderAdapter : LoadStateAdapter<RecyclerView.ViewHolder>() { companion object { private enum class State(val value: Int) { ERROR(1), PROGRESS(0), } } class ProgressViewHolder(view: View) : RecyclerView.ViewHolder(view) { fun bind(loadState: LoadState) { } } class ErrorViewHolder(view: View) : RecyclerView.ViewHolder(view) { private val errorMessage = view.findViewById<TextView>(R.id.errorMessage) fun bind(loadState: LoadState) { if (loadState is LoadState.Error) { errorMessage.setSimpleText(loadState.error.localizedMessage) } } } override fun getStateViewType(loadState: LoadState) = when (loadState) { LoadState.Loading -> State.PROGRESS.value is LoadState.NotLoading -> error("Not supported") is LoadState.Error -> State.ERROR.value } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, loadState: LoadState) { when (holder) { is ProgressViewHolder -> holder.bind(loadState) is ErrorViewHolder -> holder.bind(loadState) } } override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState) = when (loadState) { LoadState.Loading -> ProgressViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.layout_paging_progress, parent, false) ) is LoadState.Error -> ErrorViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.item_paging_error, parent, false) ) is LoadState.NotLoading -> error("Not supported") } }
Now, regarding pagination in the popular AdapterDelegates library by Hannes Dorfmann
(I finished this part of the article 2 years after the previous part and slightly changed the approach)
https://hannesdorfmann.com/android/adapter-delegates
https://github.com/sockeqwe/AdapterDelegates
Making a DataSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.onEach import androidx.paging.PositionalDataSource class UserAndCategoryDataSource( private val showLoader: (Unit) -> Unit, private val hideLoader: (Unit) -> Unit, private val viewModelScope: CoroutineScope, private val showErrorState: (String?) -> Unit, private val userAndCategoryUiMapper: UserAndCategoryUiMapper, private val getUserAndCategoryUseCase: GetUserAndCategoryUseCase, ) : PositionalDataSource<UserAndCategoryUi>() { companion object { private const val TAG = "UserAndCategoryDataSourceTag" private const val CURSOR_SIZE = 20 } private var cursor: String? = null override fun loadInitial( params: LoadInitialParams, callback: LoadInitialCallback<ApplicationUi> ) { logDebug("loadInitial", TAG) showLoader.invoke(Unit) number = -1 loadNext { logDebug("loadInitial onResult size: ${it.size}", TAG) callback.onResult( it, 0, it.size ) } } override fun loadRange( params: LoadRangeParams, callback: LoadRangeCallback<ApplicationUi> ) { logDebug("loadRange", TAG) loadNext { logDebug("loadRange onResult size: ${it.size}", TAG) callback.onResult(it) } } private fun loadNext(callback: (List<UserAndCategoryUi>) -> Unit) { getUserAndCategoryUseCase.invoke( cursor = cursor, size = CURSOR_SIZE, ).onEach { result -> result.onLoading { logDebug("loadNext onLoading", TAG) }.onSuccess { model, _, _ -> logDebug("loadNext onSuccess", TAG) if (model.content == null || model.cursor == null) { showErrorState.invoke("Data from server is empty") hideLoader.invoke(Unit) return@onEach } if (!model.content.isNullOrEmpty()) { callback(applicationsUiMapper.mapList(model.content!!)) cursor = model.cursor!! } delay(HIDE_LOADER_LONG_DELAY) hideLoader.invoke(Unit) }.onFailure { _, message, _, _ -> logError("loadNext onFailure", message, TAG) showErrorState.invoke(message) hideLoader.invoke(Unit) } }.launchInScope(viewModelScope) } }
Adapter, here is an example of using an adapter with only one element type:
import com.hannesdorfmann.adapterdelegates4.paging.PagedListDelegationAdapter import org.koin.core.component.KoinComponent import org.koin.core.component.inject class UserAndCategoryMeAdapter : PagedListDelegationAdapter<UserAndCategoryUi>(DefaultDiffUtilCallback()), KoinComponent { private val userAndCategoryDelegate: UserAndCategoryDelegate by inject() var clickListener: ClickListener? = null set(value) { field = value userAndCategoryDelegate.openClickListener= { model -> clickListener?.onOpenClicked( model = model, ) } } init { delegatesManager.apply { addDelegate(userAndCategoryDelegate) } } interface ClickListener { fun onOpenClicked(model: UserAndCategoryUi) } }
Delegate:
import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.hannesdorfmann.adapterdelegates4.AbsFallbackAdapterDelegate class UserAndCategoryDelegate : AbsFallbackAdapterDelegate<MutableList<UserAndCategoryUi>>() { var openClickListener: ((model: UserAndCategoryUi) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder { return ViewHolder(parent.inflateBinding(ListItemUserAndCategoryBinding::inflate)) } override fun onBindViewHolder( items: MutableList<UserAndCategoryUi>, position: Int, holder: RecyclerView.ViewHolder, payloads: MutableList<Any> ) { (holder as ViewHolder).bind(items[position]) } private inner class ViewHolder( private val binding: ListItemUserAndCategoryBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(model: UserAndCategoryUi) { binding.tvName.text = model.name binding.rootLayout.onClickThrottle { openClickListener?.invoke(model) } } } }
add to ViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import androidx.paging.DataSource import androidx.paging.LivePagedListBuilder import androidx.paging.PagedList import org.koin.core.component.inject class UserAndCategoryViewModel : BaseViewModel() { companion object { private const val TAG = "UserAndCategoryViewModelTag" const val CURSOR_SIZE = 20 } private val userAndCategoryUiMapper: UserAndCategoryUiMapper by inject() private val getUserAndCategoryUseCase: GetUserAndCategoryUseCase by inject() private val dataSourceFactory: DataSource.Factory<Int, UserAndCategoryUi> val adapterListLiveData: LiveData<PagedList<UserAndCategoryUi>> // this piece is copied from another class for example private var sortingModel: SortingModel by Delegates.observable( SortingModel( sortBy = SortingByEnum.DEFAULT, sortDirection = SortingDirectionEnum.ASC ) ) { _, _, _ -> updateSortingModel() dataSourceFactory.create() adapterListLiveData.value?.dataSource?.invalidate() } init { dataSourceFactory = object : DataSource.Factory<Int, UserAndCategoryUi>() { override fun create(): DataSource<Int, UserAndCategoryUi> { return UserAndCategoryDataSource( viewModelScope = viewModelScope, userAndCategoryUiMapper = userAndCategoryUiMapper, getUserAndCategoryUseCase = getUserAndCategoryUseCase, showLoader = { hideErrorState() showLoader() }, hideLoader = { hideLoader() }, showErrorState = { showErrorState(descriptionString = it) }, ) } } val config = PagedList.Config.Builder() .setEnablePlaceholders(false) .setPageSize(CURSOR_SIZE) .build() adapterListLiveData = LivePagedListBuilder(dataSourceFactory, config) .setBoundaryCallback(object : PagedList.BoundaryCallback<UserAndCategoryUi>() { override fun onZeroItemsLoaded() { super.onZeroItemsLoaded() logDebug("boundaryCallback onZeroItemsLoaded", TAG) hideLoader() } override fun onItemAtEndLoaded(itemAtEnd: UserAndCategoryUi) { super.onItemAtEndLoaded(itemAtEnd) logDebug("boundaryCallback onItemAtEndLoaded", TAG) hideLoader() } override fun onItemAtFrontLoaded(itemAtFront: UserAndCategoryUi) { super.onItemAtFrontLoaded(itemAtFront) logDebug("boundaryCallback onItemAtFrontLoaded", TAG) hideLoader() } }) .build() } fun refreshScreen() { logDebug("refreshScreen", TAG) adapterListLiveData.value?.dataSource?.invalidate() } override fun updateSortingModel() { logDebug("updateSortingModel", TAG) _currentSortingCriteriaData.value = getSortingTitleByModel( sortingModelUiMapper.map(sortingModel) ) } }
I will also specify the class used for the data, but you can use any approach you like, this is just one example
enum class ErrorType { EXCEPTION, SERVER_ERROR, ERROR_IN_LOGIC, SERVER_DATA_ERROR, NO_INTERNET_CONNECTION, } data class ResultEmittedData<out T>( val model: T?, val error: Any?, val status: Status, val message: String?, val responseCode: Int?, val errorType: ErrorType?, ) { enum class Status { SUCCESS, ERROR, LOADING, } companion object { fun <T> success( model: T, message: String?, responseCode: Int?, ): ResultEmittedData<T> = ResultEmittedData( error = null, model = model, errorType = null, message = message, status = Status.SUCCESS, responseCode = responseCode, ) fun <T> loading( model: T?, message: String? = null, ): ResultEmittedData<T> = ResultEmittedData( model = model, error = null, errorType = null, message = message, responseCode = null, status = Status.LOADING, ) fun <T> error( model: T?, error: Any?, message: String?, responseCode: Int?, errorType: ErrorType?, ): ResultEmittedData<T> = ResultEmittedData( model = model, error = error, message = message, errorType = errorType, status = Status.ERROR, responseCode = responseCode, ) } } inline fun <T : Any> ResultEmittedData<T>.onLoading( action: ( message: String?, ) -> Unit ): ResultEmittedData<T> { if (status == ResultEmittedData.Status.LOADING) action( message ) return this } inline fun <T : Any> ResultEmittedData<T>.onSuccess( action: ( model: T, message: String?, responseCode: Int?, ) -> Unit ): ResultEmittedData<T> { if (status == ResultEmittedData.Status.SUCCESS && model != null) action( model, message, responseCode, ) return this } inline fun <T : Any> ResultEmittedData<T>.onFailure( action: ( model: Any?, message: String?, responseCode: Int?, errorType: ErrorType?, ) -> Unit ): ResultEmittedData<T> { if (status == ResultEmittedData.Status.ERROR) action( model, message, responseCode, errorType ) return this }
import com.google.gson.JsonSyntaxException import kotlinx.coroutines.withContext import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.RequestBody import org.json.JSONObject import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koin.core.qualifier.named import java.net.UnknownHostException import javax.net.ssl.SSLPeerUnverifiedException abstract class BaseRepository : KoinComponent { companion object { private const val TAG = "BaseRepositoryTag" } protected suspend fun <T> getResult( call: suspend () -> retrofit2.Response<T> ): ResultEmittedData<T> { return try { val response = call() val responseCode = response.code() val successCode = when (responseCode) { 200, 201, 202, 203, 204, 205, 206, 207, 208, 226 -> true else -> false } val responseMessage = response.message() val responseBody = response.body() ?: getEmptyResponse() when { successCode && responseBody !is EmptyResponse -> { dataSuccess( model = responseBody, message = responseMessage, responseCode = responseCode, ) } successCode -> { logDebug("getResult successCode", TAG) dataSuccess( model = getEmptyResponse(), message = responseMessage, responseCode = responseCode, ) } responseCode == 401 -> { logError("getResult responseCode == 401", TAG) startAuthorization() dataError( model = responseBody, message = responseMessage, responseCode = responseCode, error = responseBody.toString(), errorType = ErrorType.SERVER_ERROR, ) } else -> { logError("getResult conditions else", TAG) dataError( model = responseBody, error = null, // TODO responseCode = responseCode, message = responseMessage, errorType = ErrorType.SERVER_ERROR, ) } } } catch (exception: UnknownHostException) { logError( "getResult Exception is UnknownHostException, message: ${exception.message} stackTrace: ${exception.stackTrace}", exception, TAG ) dataError( model = null, error = null, responseCode = null, message = "No internet connection", errorType = ErrorType.NO_INTERNET_CONNECTION, ) } catch (exception: SSLPeerUnverifiedException) { logError( "getResult Exception is SSLPeerUnverifiedException, message: ${exception.message} stackTrace: ${exception.stackTrace}\",", exception, TAG ) dataError( model = null, error = null, responseCode = null, errorType = ErrorType.EXCEPTION, message = "Error while receiving data from server, encryption incorrect", ) } catch (exception: JsonSyntaxException) { logError( "getResult Exception is JsonSyntaxException, message: ${exception.message} stackTrace: ${exception.stackTrace}\",", exception, TAG ) dataError( model = null, error = null, responseCode = null, errorType = ErrorType.EXCEPTION, message = "Error while receiving data from server, data format incorrect", ) } catch (exception: java.io.EOFException) { logError( "getResult Exception is EOFException, message: ${exception.message} stackTrace: ${exception.stackTrace}\",", exception, TAG ) dataError( model = null, error = null, responseCode = null, errorType = ErrorType.EXCEPTION, message = exception.message ?: exception.toString(), ) } catch (exception: Throwable) { logError( "getResult Exception is other, message: ${exception.message} stackTrace: ${exception.stackTrace}\",", exception, TAG ) dataError( model = null, error = null, responseCode = null, errorType = ErrorType.EXCEPTION, message = exception.message ?: exception.toString(), ) } } private fun <T> dataError( model: T?, error: Any?, responseCode: Int?, message: String?, errorType: ErrorType?, ): ResultEmittedData<T> = ResultEmittedData.error( model = model, error = error, message = message, errorType = errorType, responseCode = responseCode, ) private fun <T> dataSuccess( model: T, message: String?, responseCode: Int, ): ResultEmittedData<T> = ResultEmittedData.success( model = model, message = message, responseCode = responseCode, ) private fun startAuthorization() { logDebug("start authorization", TAG) } } class EmptyResponse @Suppress("UNCHECKED_CAST") fun <T> getEmptyResponse(): T { return EmptyResponse() as T }
The code is certainly not optimal and depends on the context of the project, but I think the basic principle is clear and can be rewritten to suit your needs if necessary.