Modtion logo
Modtion
android

Android : Build Infinite Scroll with Pagination and Retrofit in Android Jetpack Compose

Android : Build Infinite Scroll with Pagination and Retrofit in Android Jetpack Compose

Pagination

Large amounts of data are often a big problem for applications, especially affecting performance, efficiency and server load. pagination is one solution to overcome this. The large amount of data will be divided into small groups based on the amount of data to be displayed. The API will provide data in the form of pages, and if the application wants further data, just request it from the same API but with the next page.

Here are the reasons for using pagination:

Let's Start

Make sure your API have pagination feature. We will use API from https://newsdata.io. We will use Retrofit as HTTP Client to fetch the API. if you want to learn the basic use of Retrofit, visit my previous article in this article.

Step 1

Implement retrofit to your project. libs.versions.toml

[versions]
...
retrofitVersion = "2.9.0"
loggingInterceptor = "4.11.0"

[libraries]
...
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofitVersion" }
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }

[bundles]
retrofit = [
    "logging-interceptor",
    "retrofit",
    "converter-gson",
]

app/build.gradle.kts

dependencies {
    ...
    implementation(libs.bundles.retrofit)
}

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET"/>
    ...
</manifest>

Don't forget to sync the Project.

Step 2

Create ApiService to declare API Request and ApiConfig to Configure the Retrofit.

ApiService.kt

import retrofit2.http.GET
import retrofit2.http.Query

interface ApiService {

    @GET("latest")
    suspend fun getPagination(
        @Query("language") language: String = "en",
        @Query("apikey") apikey: String = "",
        @Query("page") page: String
    ): NewsResponse
}

Register to https://newsdata.io and create your own apikey.

ApiConfig.kt

import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object ApiConfig {

    private const val BASE_URL = "https://newsdata.io/api/1/"

    private val client = OkHttpClient.Builder().build()

    val api: ApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }
}

Step 3

Create Model class NewsResponse.kt

data class NewsResponse(
    val nextPage: String?,
    val results: List<Article>?
)

Article.kt

data class Article(
    val article_id: String?,
    val title: String?,
    val description: String?,
    val content: String?,
    val pubDate: String?,
    val source_name: String?,
    val image_url: String?
)

Step 5

Create View Model, Action and state class

NewsViewModel.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class NewsViewModel : ViewModel() {

    var state by mutableStateOf(NewsState())
        private set

    fun onAction(action: NewsAction){
        when(action){
            NewsAction.GetNews -> fetchNews(null)
            NewsAction.GetNewsPagination -> fetchNews(state.nextPage)
        }
    }

    private fun fetchNews(page: String?) {
        viewModelScope.launch {
            state = state.copy(
                isLoading = true
            )
            try {
                val api = RetrofitInstance.api

                val response = if (page == null){
                    api.getLatest()
                }else{
                    api.getPagination(page = page)
                }

                val news = response.results ?: emptyList()
                state = state.copy(
                    isError = false,
                    news = state.news + news,
                    nextPage = response.nextPage
                )
                Log.d("VIEWMODEL", response.results.toString())
            } catch (e: Exception) {
                e.printStackTrace()
                state = state.copy(
                    isError = true,
                    isLoading = false
                )
            }
            state = state.copy(
                isLoading = false
            )
        }
    }
}

NewsAction.kt

sealed interface NewsAction {
    data object GetNews: NewsAction
    data object GetNewsPagination: NewsAction
}

NewsState.kt

data class NewsState(
    val isLoading: Boolean = false,
    val isError: Boolean = false,
    val news: List<Article> = emptyList(),
    val nextPage: String? = null,
)

Step 5

Create Screen Class

NewsScreen.kt

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsScreen(viewModel: NewsViewModel) {

    LaunchedEffect(true) {
        viewModel.onAction(NewsAction.GetNews)
    }

    val state = viewModel.state

    val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
        state = rememberTopAppBarState()
    )

    Scaffold(
        modifier = Modifier.run {
            nestedScroll(scrollBehavior.nestedScrollConnection)
                .fillMaxSize()
        },
        topBar = {
            TopAppBar(
                scrollBehavior = scrollBehavior,
                title = {
                    Text(
                        text = "News",
                        fontSize = 33.sp,
                        fontWeight = FontWeight.SemiBold,
                        modifier = Modifier.padding(start = 8.dp)
                    )
                },
                windowInsets = WindowInsets(
                    top = 50.dp, bottom = 8.dp
                )
            )
        }
    ) { innerPadding ->
        Box(
            modifier = Modifier.padding(innerPadding)
                .fillMaxSize(),
            contentAlignment = Alignment.Center
        ){
            if (state.isLoading && state.news.isEmpty()) {
                CircularProgressIndicator()
            }

            if (state.isError && state.news.isEmpty()) {
                Text(
                    text = "Can't Load News",
                    fontSize = 27.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = MaterialTheme.colorScheme.error
                )
            }

            if (state.news.isNotEmpty()) {

                // state for pagination
                val listState = rememberLazyListState()

                val shouldPaginate = remember {
                    derivedStateOf {
                        val totalItems = listState.layoutInfo.totalItemsCount
                        val lastVisibleIndex = listState.layoutInfo
                            .visibleItemsInfo.lastOrNull()?.index ?: 0

                        lastVisibleIndex == totalItems - 1 && !state.isLoading
                    }
                }

                LaunchedEffect(key1 = listState) {
                    snapshotFlow { shouldPaginate.value }
                        .distinctUntilChanged()
                        .filter { it }
                        .collect { viewModel.onAction(NewsAction.GetNews) }
                }

                LazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    contentPadding = PaddingValues(bottom = 8.dp),
                    state = listState,
                ) {
                    itemsIndexed(
                        items = state.news,
                        key = { _, article -> article.article_id.toString() }
                    ) { _, article ->
                        Card(modifier = Modifier.fillMaxWidth().padding(8.dp), elevation = CardDefaults.cardElevation(4.dp)) {
                            Column(modifier = Modifier.padding(16.dp)) {
                                Text(text = article.title.toString(), style = MaterialTheme.typography.headlineSmall)
                                article.description?.let {
                                    Text(text = it, style = MaterialTheme.typography.bodyMedium)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

Pagination Component

LazyListState keep track the current state of LazyColumn like scroll position and visible items.

val listState = rememberLazyListState()

derivedStateOf is used to derive a new state from other state. this function will recalculate the value when the list state (scrooll state) change or update in every frame without reduce performance. remember is use to retain the value of scroll state and list of items and preventing unnecessary recalculations.

val shouldPaginate = remember {
    derivedStateOf {
        val totalItems = listState.layoutInfo.totalItemsCount
        val lastVisibleIndex = listState.layoutInfo
            .visibleItemsInfo.lastOrNull()?.index ?: 0

        lastVisibleIndex == totalItems - 1 && !state.isLoading
    }
}

LaunchedEffect is to trigger the snapshotFlow as coroutine. snapshotFlow will observe value from shouldPaginate function, especially when list near the bottom and call action to request more of news.

LaunchedEffect(key1 = listState) {
    snapshotFlow { shouldPaginate.value }
        .distinctUntilChanged()
        .filter { it }
        .collect { viewModel.onAction(NewsAction.GetNews) }
}

LazyColumn is the vertical scrollable list compose component. It only rendering the visible items that will make better performance. Set state with LazyListState, it will track the state of LazyColumn. Inside of LazyColumn, implement itemIndexed with key. This key will give unique identifier for each item. It will make each item tracked and avoiding unnecessary recomposition or re-rendering when list updated.

LazyColumn(
    modifier = Modifier.fillMaxSize(),
    contentPadding = PaddingValues(bottom = 8.dp),
    state = listState,
) {
    itemsIndexed(
        items = state.news,
        key = { _, article -> article.article_id.toString() }
    ) { _, article ->
        Card(modifier = Modifier.fillMaxWidth().padding(8.dp), elevation = CardDefaults.cardElevation(4.dp)) {
            Column(modifier = Modifier.padding(16.dp)) {
                Text(text = article.title.toString(), style = MaterialTheme.typography.headlineSmall)
                article.description?.let {
                    Text(text = it, style = MaterialTheme.typography.bodyMedium)
                }
            }
        }
    }
}

Step 6

Setup your MainActivity MainActivity.kt

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ComposeRetrofitTheme {
                val newsViewModel = NewsViewModel()
                NewsScreen(newsViewModel)
            }
        }
    }
}