This post is about Android Jetpack Paging 3 library and how we can use it to load the large set of data from network using Retrofit in our RecyclerView efficiently with the help of AndroidPagingExp application.
Android Paging
The Paging library helps you load and display pages of data or small chunks of data at a time from a larger dataset from local storage or over network. Loading partial data on demand reduces usage of network bandwidth and system resources.
It integrates cleanly with other Jetpack components, and provide first-class Kotlin support.
Benefits of using the Paging library
The Paging library includes the following features:
- Provides in-memory caching of the paged data that assures the systematic use of device resources.
- Prevents duplication of the API request, ensuring that your app uses network bandwidth and system resources efficiently.
- Configurable RecyclerView adapters that automatically request data as the user scrolls toward the end of the loaded data.
- Finest support for Kotlin coroutines and Flow, as well as LiveData and RxJava.
- Built-in functionality to add loading state headers, footers, and list separators.
- Built-in support for error handling, including refresh and retry capabilities.
Paging Library Components
To implement “infinite scroll” functionality , we will use the following Paging library components:
- PagingData : A container for paginated data. It connects the ViewModel layer to the UI.
- PagingSource : PagingSource object defines a source of data and how to retrieve data from that source. A PagingSource object can load data from any single source, including network sources and local databases.
- Pager.flow : builds a Flow<PagingData>.
- PagingDataAdapter : a RecyclerView.Adapter that presents PagingData in a RecyclerView.

Creating new Project
1 . Create a new project by going to File ⇒ New Android Project, select Empty Activity, provide app name, select language to kotlin and then finally click on finish.
2 . Open app-level build.gradle file, add the below dependencies as shown below, and then sync the project.
build.gradle
dependencies { // Android Jetpack Paging 3.0 implementation "androidx.paging:paging-runtime:3.0.0-alpha06" // ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" // Retrofit implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.4.0' // Gson support adapter for Retrofit implementation "com.squareup.okhttp3:logging-interceptor:4.9.0" //OKHttp Interceptor //Kotlin Coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9" //Glide to load images from URL implementation 'com.github.bumptech.glide:glide:4.11.0' implementation 'androidx.databinding:databinding-runtime:7.0.3' annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' }
3. Inside android{} block enable viewBinding.
viewBinding {
enabled true
}
4. Go to AndroidManifest.xml and add the internet permission above <application> tag as shown below:
AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
Setting Up Retrofit
Now let’s setup our Retrofit that will hit the backend API to fetch data sets.
Backend API
In our project, we will use the below free JSON API.
https://api.instantwebtools.net/v1/passenger?page=0&size=10
Creating API Interface
Create an interface named MyApi and write the following code.
MyApi.kt
interface MyApi { @GET("passenger") suspend fun getPassengersData( @Query("page") page: Int, @Query("size") size: Int = 10 ): PassengersResponse companion object { private const val BASE_URL = "https://api.instantwebtools.net/v1/" operator fun invoke(): MyApi = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build() .create(MyApi::class.java) } }
getPassangersData() : A function which will hit a GET request using the query parameter page and size.
companion object{ } : Inside this, we have the base URL and the invoke function that will return us the MyApi Instance.
Data Classes to Map API Response
Now, we have to create the data class for the following JSON output which we get as the response.
{ "totalPassengers": 19851, "totalPages": 19851, "data": [ { "_id": "5fec58acc9a1dc9a93bca8c2", "name": "John Doe", "trips": 500, "airline": [ { "id": 8, "name": "Thai Airways", "country": "Thailand", "logo": "https://upload.wikimedia.org/wikipedia/en/thumb/5/58/Thai_Airways_Logo.svg/200px-Thai_Airways_Logo.svg.png", "slogan": "Smooth as Silk / I Fly THAI", "head_quaters": "Jom Phol Subdistrict, Chatuchak, Bangkok, Thailand", "website": "www.thaiairways.com", "established": "1960" } ], "__v": 0 } ] }
Given below are the corresponding data classes for the above JSON response.
- The PassengersResponse data class looks like,
PassengersResponse.kt
data class PassengersResponse( val `data`: List<Passenger>, val totalPages: Int, val totalPassengers: In )
- The Passenger data class looks like,
Passenger.kt
data class Passenger( val __v: Int, val _id: String, val airline: List<Airline>, val name: String, val trips: Int )
- The Airline data class looks like,
Airline.kt
data class Airline( val country: String, val established: String, val head_quaters: String, val id: Int, val logo: String, val name: String, val slogan: String, val website: String )
PagingSource
A PagingSource class is used to access data from the backend API.
The PassengersDataSource class extends PagingSource class and takes a primary constructor parameter MyApi.
PassengersDataSource.kt
class PassengersDataSource(private val api: MyApi) : PagingSource<Int, Passenger>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Passenger> {} }
- PagingSource takes two parameters, a key and a value.
- key: what data to load. E.g. Int as a page number or String as a next page token.
- value: type of data (Passenger) that will be loaded.
- The load() function returns a LoadResult and should be implemented to retrieve data from the data source (network in our case).
- load() is a suspend function, so you can call other suspend functions here, such as the network call.
Now, we update the load function like:
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Passenger> { return try { val nextPageNumber = params.key ?: 0 val response = api.getPassengersData(nextPageNumber) LoadResult.Page( data = response.data, prevKey = if (nextPageNumber > 0) nextPageNumber - 1 else null, nextKey = if (nextPageNumber < response.totalPages) nextPageNumber + 1 else null ) } catch (e: Exception) { LoadResult.Error(e) } } }
LoadResult can take one of the following types:
- LoadResult.Page: If the result is successful, return the loaded data wrapped in a LoadResult.Page object together with information about next and previous keys.
- LoadResult.Error: If the request fails, return the exception wrapped in a LoadResult.Error object containing info about the exception.
Pager and PagingData
The container for the data returned from PagingSource is called PagingData. A new instance of PagingData is created every time your data is refreshed (pulled from the network).
A Pager instance has to be created to build a PagingData stream, using a PagingConfig configuration object and a function that tells the Pager how to get an instance of your PagingSource implementation .
class PassengersViewModel( private val api: MyApi ) : ViewModel() { val passengers = Pager(config = PagingConfig(pageSize = 10), pagingSourceFactory = { PassengersDataSource(api) }).flow.cachedIn(viewModelScope) }
The Pager instance takes the below parameter:
- PagingSource is our data source created in the name PassengersDataSource.
- PagingConfig defines how to get data from the PagingSource like page size (the number of items loaded at once from the PagingSource).
Pager.flow : will convert the stream of data into a Flow<PagingData<Passenger>>.
cachedIn() : is used to persist the data beyond configuration changes. The best place to do this in a ViewModel, using the viewModelScope.
As we are passing a parameter inside our ViewModel, we also need to create a ViewModelFactory .
PassengersViewModelFactory.kt
class PassengersViewModelFactory( private val api: MyApi ) : ViewModelProvider.NewInstanceFactory(){ override fun <T : ViewModel?> create(modelClass: Class<T>): T { return PassengersViewModel(api) as T } }
PagingDataAdapter
To connect a RecyclerView to the PagingData, implement a PagingDataAdapter:
class PassengersAdapter : PagingDataAdapter<Passenger, PassengersAdapter.PassengersViewHolder>(PassengersComparator) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): PassengersViewHolder { return PassengersViewHolder( ItemPassengerBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindViewHolder(holder: PassengersViewHolder, position: Int) { val item = getItem(position) item?.let { holder.bindPassenger(it) } } inner class PassengersViewHolder(private val binding: ItemPassengerBinding) : RecyclerView.ViewHolder(binding.root) { fun bindPassenger(item: Passenger) = with(binding) { imageViewAirlinesLogo.loadImage(item.airline.get(0).logo) textViewHeadquarters.text = item.airline.get(0).head_quaters textViewNameWithTrips.text = "${item.name}, ${item.trips} Trips" } } object PassengersComparator : DiffUtil.ItemCallback<Passenger>() { override fun areItemsTheSame(oldItem: Passenger, newItem: Passenger): Boolean { return oldItem._id == newItem._id } override fun areContentsTheSame(oldItem: Passenger, newItem: Passenger): Boolean { return oldItem == newItem } } }
- The PagingDataAdapter listens to internal PagingData loading events as pages are loaded and uses DiffUtil on a background thread to compute fine-grained updates as updated content is received in the form of new PagingData objects.
The item_passenger.xml defines the layout of recyclerView item.
item_passenger.xml
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="12dp"> <ImageView android:id="@+id/image_view_airlines_logo" android:layout_width="280dp" android:layout_height="52dp" android:layout_gravity="center_horizontal" /> <TextView android:id="@+id/text_view_headquarters" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="12dp" android:textAlignment="center" android:textColor="@android:color/holo_blue_dark" android:textSize="14sp" tools:text="Jom Phol Subdistrict, Chatuchak, Bangkok, Thailand" /> <TextView android:id="@+id/text_view_name_with_trips" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:gravity="center_horizontal" android:textAlignment="center" android:textColor="@android:color/holo_orange_dark" android:textSize="22sp" tools:text="Dodi Papagena, 2223 Trips" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="8dp" android:alpha="0.3" android:background="@android:color/holo_red_dark" /> </LinearLayout>
The activity_main.xml defines the layout of MainActivity.
activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".ui.MainActivity"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:listitem="@layout/item_passenger" /> </androidx.constraintlayout.widget.ConstraintLayout>
Now, in MainActivity.kt collect the Flow<PagingData> and submit it to the PagingDataAdapter.
MainActivity.kt
class MainActivity : AppCompatActivity() { lateinit var passengersViewModel: PassengersViewModel lateinit var passengersAdapter: PassengersAdapter private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) setupViewModel() setupView() setupList() } private fun setupViewModel() { val factory = PassengersViewModelFactory(MyApi()) passengersViewModel = ViewModelProvider(this, factory).get(PassengersViewModel::class.java) } private fun setupView() { passengersAdapter = PassengersAdapter() binding.recyclerView.apply { layoutManager = LinearLayoutManager(context) adapter = passengersAdapter setHasFixedSize(true) } } private fun setupList() { lifecycleScope.launch { passengersViewModel.passengers.collectLatest { pagedData -> passengersAdapter.submitData(pagedData) } } } }
When you run the app it will look like this:
Displaying Loading/Error State
The item_loading_state.xml, defines a layout for displaying Loading/Error state.
item_loading_state.xml
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="12dp"> <ProgressBar android:id="@+id/progressbar" android:layout_width="32dp" android:layout_height="32dp" android:layout_gravity="center_horizontal" /> <TextView android:id="@+id/text_view_error" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_horizontal" android:textAlignment="center" android:textColor="@android:color/holo_red_dark" android:textSize="14sp" android:visibility="gone" tools:text="Some Error Occurred" tools:visibility="visible" /> <TextView android:id="@+id/button_retry" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="12dp" android:text="Tap to Retry" android:textAllCaps="false" android:textColor="@android:color/holo_green_dark" android:textSize="16sp" /> </LinearLayout>
LoadStateAdapter
The class PassengersLoadStateAdapter extends LoadStateAdapter class.
PassengersLoadStateAdapter.kt
class PassengersLoadStateAdapter( private val retry: () -> Unit ) : LoadStateAdapter<PassengersLoadStateAdapter.PassengerLoadStateViewHolder>() { inner class PassengerLoadStateViewHolder( private val binding: ItemLoadingStateBinding, private val retry: () -> Unit ) : RecyclerView.ViewHolder(binding.root) { fun bind(loadState: LoadState) { if (loadState is LoadState.Error) { binding.textViewError.text = loadState.error.localizedMessage } binding.progressbar.visible(loadState is LoadState.Loading) binding.buttonRetry.visible(loadState is LoadState.Error) binding.textViewError.visible(loadState is LoadState.Error) binding.buttonRetry.setOnClickListener { retry() } binding.progressbar.visibility = View.VISIBLE } } override fun onBindViewHolder(holder: PassengerLoadStateViewHolder, loadState: LoadState) { holder.bind(loadState) } override fun onCreateViewHolder( parent: ViewGroup, loadState: LoadState ) = PassengerLoadStateViewHolder( ItemLoadingStateBinding.inflate(LayoutInflater.from(parent.context), parent, false), retry ) }
Using LoadStateAdapter
Now to add the LoadStateAdapter to our passengersAdapter, we will use the function withLoadStateHeaderAndFooter.
binding.recyclerView.adapter = passengersAdapter.withLoadStateHeaderAndFooter( header = PassengersLoadStateAdapter { passengersAdapter.retry() }, footer = PassengersLoadStateAdapter { passengersAdapter.retry() } )
Run your app and you will see the loading state or error state if occurred, you will also get a retry button (when network is off).