Pagination: With remote API - devrath/ComposeAlchemy GitHub Wiki

Endpoints Example

{
  "count": 20,
  "totalCount": 2127,
  "page": 1,
  "totalPages": 107,
  "lastItemIndex": 20,
  "results": [
    {
      "_id": "bfNpGC2NI",
      "author": "Thomas Edison",
      "content": "As a cure for worrying, work is better than whisky.",
      "tags": [
        "Humorous"
      ],
      "authorSlug": "thomas-edison",
      "length": 51,
      "dateAdded": "2023-04-14",
      "dateModified": "2023-04-14"
    },
    {
      "_id": "ghVnmSpeAo",
      "author": "Thomas Edison",
      "content": "Everything comes to him who hustles while he waits.",
      "tags": [
        "Success",
        "Motivational"
      ],
      "authorSlug": "thomas-edison",
      "length": 51,
      "dateAdded": "2023-04-14",
      "dateModified": "2023-04-14"
    },
  ]
}
  • API-ENDPOINT: https://quotable.io/quotes?page=2
  • API-PAGE-RESPONSE: Complete Response
{
  "count": 20,
  "totalCount": 2127,
  "page": 2,
  "totalPages": 107,
  "lastItemIndex": 40,
  "results": [
    {
      "_id": "XtZMaD2s28",
      "author": "Thomas Edison",
      "content": "If we all did the things we are capable of doing, we would literally astound ourselves.",
      "tags": [
        "Inspirational",
        "Motivational"
      ],
      "authorSlug": "thomas-edison",
      "length": 87,
      "dateAdded": "2023-04-14",
      "dateModified": "2023-04-14"
    },
    {
      "_id": "niVz2fQWSH",
      "author": "Thomas Edison",
      "content": "Opportunity is missed by most people because it is dressed in overalls and looks like work.",
      "tags": [
        "Opportunity",
        "Work"
      ],
      "authorSlug": "thomas-edison",
      "length": 91,
      "dateAdded": "2023-04-14",
      "dateModified": "2023-04-14"
    }
  ]
}

Code

PagingSource

/**
 * In the paging source, We define how to load the data from the API.
 * *************
 * PagingSource<Int, Result>
 * <*> First Param: The type of the key used to load the data.
 * URL: https://quotable.io/quotes?page=2
 * Here the value `2` is the key used to load the data which is integer type
 * <*> Second Param: The type is the base response data which we will receive from the API.
 */
class QuotePagingSource(private val quoteAPI: QuoteAPI) : PagingSource<Int, Result>() {

    /**
     * OBJECTIVE: In this method we will load the data from the API.
     */
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Result> {
        // Params contains the information on what page to load next
        return try {
            // Determine the page number: When we do not have the `key`(It will be null) in the param, So we load the first page
            val position = params.key ?: 1
            val response = quoteAPI.getQuotes(position)

            // Load the page
            return LoadResult.Page(
                // Define the current data
                data = response.results,
                // <---> Define the previous page key <--->
                // If the position = 1 ==> This indicates ths there is no previous page
                // If the position > 1 ==> This indicates that there is a previous page (position - 1)
                prevKey = if (position == 1) null else position - 1,
                // <---> Define the next page key <--->
                // If the position = totalPages ==> This indicates that there is no next page
                // If the position < totalPages ==> This indicates that there is a next page (position + 1)
                nextKey = if (position == response.totalPages) null else position + 1
            )

        } catch (e: Exception) {
            // Load the error
            LoadResult.Error(e)
        }
    }

    /**
     * OBJECTIVE: This function will help us telling which page to load next.
     * Based on the anchor position we determine which page to load next
     * ******
     * Basically the anchor position maintains the current position of the page.
     */
    override fun getRefreshKey(state: PagingState<Int, Result>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            // When we pass the anchor position the `closestPageToPosition` function it will get you the closest page to that position
            val anchorPage = state.closestPageToPosition(anchorPosition)
            // Once we have the anchor position, We will see the previous key or next key of the page
            // Here we are checking if the current page has a previous key ==> If previous key is not there then check the next key
            // If the current page has a next key ==> If next key is not there then check the previous key
            // ----> If none of the key is there then return null --> In such case it will get the first page
            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
        }
    }

}

Composables

@Composable
fun QuotesDemo(navController: NavHostController) {

    val viewModel: QuotesDemoViewModel = hiltViewModel()
    val quotesPagingItems = viewModel.list.collectAsLazyPagingItems()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(10.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.Start,
            verticalArrangement = Arrangement.Top
        ) {
            items(quotesPagingItems.itemCount) { index ->
                val quote = quotesPagingItems[index]
                quote?.let {
                    QuoteItem(it)
                }
            }
        }
    }

}

@Composable
fun QuoteItem(quote: Result) {
    Column(modifier = Modifier.fillMaxWidth()) {
        Column(modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp)) {
            Text(
                text = quote.content,
                style = MaterialTheme.typography.titleLarge,
                modifier = Modifier.padding(bottom = 4.dp),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
            Text(
                text = "Author: ${quote.author}",
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier.padding(bottom = 2.dp)
            )
            Text(
                text = "Date Added: ${quote.dateAdded}",
                style = MaterialTheme.typography.titleSmall
            )
        }
        HorizontalDivider(
            modifier = Modifier.fillMaxWidth(),
            thickness = 2.dp,
            color = Color.Gray
        )
    }
}

ViewModel

@HiltViewModel
class QuotesDemoViewModel @Inject constructor(
    repository : QuoteRepository
) : ViewModel() {
    val list = repository.getQuotes().cachedIn(viewModelScope)
}

Repository

class QuoteRepository @Inject constructor(private val quoteAPI: QuoteAPI) {
    fun getQuotes(): Flow<PagingData<Result>> = Pager(
        config = PagingConfig(pageSize = 20, maxSize = 100),
        pagingSourceFactory = { QuotePagingSource(quoteAPI) }
    ).flow
}

API

interface QuoteAPI {
    @GET("/quotes")
    suspend fun getQuotes(@Query("page") page: Int): QuoteList
}

di

@OptIn(ExperimentalPagingApi::class)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Singleton
    @Provides
    fun getRetrofit(): Retrofit {
        return Retrofit.Builder().baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create()).build()
    }

    @Singleton
    @Provides
    fun getQuoteAPI(retrofit: Retrofit): QuoteAPI {
        return retrofit.create(QuoteAPI::class.java)
    }
}

Models

data class Result(
    val _id: String,
    val author: String,
    val authorSlug: String,
    val content: String,
    val dateAdded: String,
    val dateModified: String,
    val length: Int,
    val tags: List<String>
)

data class QuoteList(
    val count: Int,
    val lastItemIndex: Int,
    val page: Int,
    val results: List<Result>,
    val totalCount: Int,
    val totalPages: Int
)
⚠️ **GitHub.com Fallback** ⚠️