Displaying a large list of data that loads more items as the user scrolls — commonly called endless scrolling or infinite scroll — is one of the most common patterns in Android apps. Think of your Twitter feed, Instagram, or any news app.
In this guide you'll implement endless scrolling in Android RecyclerView using Kotlin, Retrofit for API calls, and a ViewModel with coroutines for clean architecture. We'll also add a proper loading indicator so users know when new data is being fetched.
- Chapter 1: RecyclerView with ItemClickListener in Kotlin
- Chapter 2: RecyclerView with Multiple View Types in Kotlin
- Chapter 3: RecyclerView with Endless Scrolling (this post)
Step 1: Add Dependencies
Add the following to your build.gradle (app):
dependencies {
// RecyclerView
implementation 'androidx.recyclerview:recyclerview:1.3.2'
// Retrofit + Gson
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
// OkHttp
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
// ViewModel + Coroutines
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
}
Step 2: Set Up the Layout
Create activity_main.xml with a RecyclerView and a loading indicator at the bottom:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="16dp"
android:visibility="gone"/>
</FrameLayout>
Create item_layout.xml for each list item:
<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="16dp">
<TextView
android:id="@+id/titleTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"/>
<TextView
android:id="@+id/descriptionTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="14sp"/>
</LinearLayout>
Step 3: Create the Data Model and API Service
Define your data model and Retrofit API interface:
// Item.kt
data class Item(
val id: Int,
val title: String,
val description: String
)
// ApiService.kt
import retrofit2.http.GET
import retrofit2.http.Query
interface ApiService {
@GET("items")
suspend fun getItems(
@Query("page") page: Int,
@Query("limit") limit: Int
): List<Item>
}
// RetrofitClient.kt
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object RetrofitClient {
private const val BASE_URL = "https://api.example.com/"
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
val apiService: ApiService by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
}
Step 4: Create the RecyclerView Adapter
The adapter holds the list and exposes an addData() method for appending new pages:
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
class ItemAdapter : RecyclerView.Adapter<ItemAdapter.ItemViewHolder>() {
private val items = mutableListOf<Item>()
class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val titleTextView: TextView = itemView.findViewById(R.id.titleTextView)
val descriptionTextView: TextView = itemView.findViewById(R.id.descriptionTextView)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_layout, parent, false)
return ItemViewHolder(view)
}
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val item = items[position]
holder.titleTextView.text = item.title
holder.descriptionTextView.text = item.description
}
override fun getItemCount() = items.size
// Appends new page of data to existing list
fun addData(newItems: List<Item>) {
val startPosition = items.size
items.addAll(newItems)
notifyItemRangeInserted(startPosition, newItems.size)
}
fun clearData() {
items.clear()
notifyDataSetChanged()
}
}
Step 5: Create the Endless Scroll Listener
This custom scroll listener detects when the user is near the end of the list and triggers loading of the next page:
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
abstract class EndlessScrollListener(
private val layoutManager: LinearLayoutManager
) : RecyclerView.OnScrollListener() {
private var previousTotalItems = 0
private var isLoading = true
private val visibleThreshold = 5
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (isLoading && totalItemCount > previousTotalItems) {
isLoading = false
previousTotalItems = totalItemCount
}
// Trigger load when within visibleThreshold items of the end
if (!isLoading && (totalItemCount - visibleItemCount) <= (firstVisibleItemPosition + visibleThreshold)) {
onLoadMore()
isLoading = true
}
}
fun reset() {
previousTotalItems = 0
isLoading = true
}
abstract fun onLoadMore()
}
Step 6: Create the ViewModel
Use ViewModel with coroutines to handle data fetching. This keeps the Activity clean and survives configuration changes:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainViewModel : ViewModel() {
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _hasError = MutableStateFlow(false)
val hasError: StateFlow<Boolean> = _hasError
private var currentPage = 0
private val pageLimit = 20
private var isLastPage = false
fun loadNextPage() {
if (_isLoading.value || isLastPage) return
viewModelScope.launch {
_isLoading.value = true
_hasError.value = false
try {
val newItems = RetrofitClient.apiService.getItems(currentPage, pageLimit)
if (newItems.isEmpty()) {
isLastPage = true
} else {
_items.value = _items.value + newItems
currentPage++
}
} catch (e: Exception) {
_hasError.value = true
} finally {
_isLoading.value = false
}
}
}
fun refresh() {
currentPage = 0
isLastPage = false
_items.value = emptyList()
loadNextPage()
}
}
Step 7: Wire Everything in MainActivity
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val viewModel: MainViewModel by viewModels()
private lateinit var itemAdapter: ItemAdapter
private lateinit var scrollListener: EndlessScrollListener
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupRecyclerView()
observeViewModel()
// Load first page
viewModel.loadNextPage()
}
private fun setupRecyclerView() {
itemAdapter = ItemAdapter()
val layoutManager = LinearLayoutManager(this)
scrollListener = object : EndlessScrollListener(layoutManager) {
override fun onLoadMore() {
viewModel.loadNextPage()
}
}
binding.recyclerView.apply {
this.layoutManager = layoutManager
adapter = itemAdapter
addOnScrollListener(scrollListener)
}
}
private fun observeViewModel() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Observe items
launch {
viewModel.items.collect { items ->
itemAdapter.clearData()
itemAdapter.addData(items)
}
}
// Observe loading state
launch {
viewModel.isLoading.collect { isLoading ->
binding.progressBar.isVisible = isLoading
}
}
// Observe errors
launch {
viewModel.hasError.collect { hasError ->
if (hasError) {
// Show error message or snackbar
}
}
}
}
}
}
}
Best Practices
- Use ViewModel for fetching — never fetch data directly in Activity/Fragment. ViewModel survives rotation, preventing duplicate API calls when the screen is rotated mid-load.
- Guard against duplicate requests — always check
isLoadingbefore triggering a new page load to prevent fetching the same page twice. - Handle the last page — track
isLastPageand stop the scroll listener once all data is loaded. Don't endlessly call the API with empty results. - Show a loading indicator — always show a
ProgressBarat the bottom while loading. Users need visual feedback that more items are coming. - Handle errors gracefully — if a page fetch fails, show a retry button instead of silently failing. Don't increment the page counter on failure.
- Consider Paging 3 for production apps — for complex pagination needs, Jetpack's Paging 3 library handles all of this automatically with built-in support for loading states, error handling, and efficient diffing.
Frequently Asked Questions
What is the difference between endless scrolling and pagination?
They solve the same problem differently. Endless scrolling automatically loads more data as the user scrolls — no user action needed. Pagination requires the user to tap a "Load More" button or navigate to the next page. Endless scrolling provides a smoother experience for feed-style content.
Should I use Paging 3 instead of a custom scroll listener?
For production apps with complex data sources, yes — Jetpack Paging 3 is the recommended approach. It handles loading states, error retries, and efficient list diffing automatically. A custom EndlessScrollListener is simpler and perfectly fine for straightforward use cases.
How do I reset endless scrolling when refreshing the list?
Call scrollListener.reset() to reset the internal counters of the EndlessScrollListener, then call viewModel.refresh() to clear the data and reload from page 0.
Why does my RecyclerView fetch duplicate pages on screen rotation?
This happens when you fetch data directly in the Activity's onCreate() instead of using a ViewModel. The Activity is recreated on rotation, triggering another fetch. Move your data fetching to a ViewModel — it survives configuration changes and prevents duplicate requests.
- Use a custom EndlessScrollListener to detect when the user reaches the end of the list
- Always fetch data in a ViewModel with viewModelScope — not directly in Activity
- Guard against duplicate page loads with an
isLoadingflag - Track isLastPage to stop fetching when all data is loaded
- Show a ProgressBar while loading for better user experience
- For production apps consider Jetpack Paging 3 for advanced pagination