Android RecyclerView with Endless Scrolling in Kotlin

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.

This is Chapter 3 of the RecyclerView series:
Android RecyclerView Endless Scrolling Kotlin

Photo by Kerde Severin — Android RecyclerView with Endless Scrolling

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 isLoading before triggering a new page load to prevent fetching the same page twice.
  • Handle the last page — track isLastPage and 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 ProgressBar at 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.

📚 Continue Learning
📝 Summary
  • 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 isLoading flag
  • 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

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

Post a Comment

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form