Note

This is a four-part series covering pagination. Here are the links:

Setup

This module is an android-library module and contains all the data required for fetching and playing with the character-related data, which includes:

  • Api
  • Data models
  • PagingSource
  • PagingSource’s factory

Here is the build.gradle.kts setup:

plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.kapt)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.anvil)
    alias(libs.plugins.ksp)
}

android.namespace = "dev.kadirkid.pagingtest.character"

kotlin.explicitApi()

dependencies {
    implementation(libs.dagger.core)
    implementation(libs.dagger.android)
    kapt(libs.dagger.compiler)
    implementation(libs.paging.runtime)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.okhttp.core)
    implementation(libs.retrofit.core)
    implementation(libs.retrofit.converter.kotlinx)
    testImplementation(libs.junit)
    testImplementation(libs.mockk)
    testImplementation(libs.paging.test)
    testImplementation(libs.kotlinx.coroutines.test)
}

Here are a bunch of dependencies but the ones you need to focus on are:

  • paging.runtime which contains all the android paging3 classes that we’ll use
  • paging.test which contains test helpers to test the paging source

NOTE: kotlin.explicitApi() is optional and just a personal preference. It forces us to explicitly define api visibility which is public by default if none is defined.

Api

For the api, we’ll be using retrofit to set it up. The api is a simple interface which we’ll tie to retrofit later on:

package dev.kadirkid.pagingtest.character.data

import dev.kadirkid.pagingtest.character.model.CharacterResponse
import retrofit2.http.GET
import retrofit2.http.Url

public interface CharacterApi {
    @GET
    public suspend fun getAllCharacters(@Url url: String): CharacterResponse
}

getAllCharacters takes in url which is annotated with retrofit2.http.Url. This is because with paging, every subsequent page needs an api call which depends on the new key. Given that, we need to override the base url set when initializing the retrofit client

Data models

There’s nothing really special about these data models since they are parsed from the response as-is.

@Serializable
public data class CharacterResponse(
    @SerialName("info") val info: Info,
    @SerialName("results") val characters: List<Character>
)

@Serializable
public data class Info(
    @SerialName("count") val count: Int,
    @SerialName("pages") val pages: Int,
    @SerialName("next") val next: String?,
    @SerialName("prev") val prev: String?
)

@Serializable
public data class Character(
    @SerialName("id") val id: Int,
    @SerialName("name") val name: String,
    @SerialName("status") val status: Status,
    @SerialName("species") val species: String,
    @SerialName("type") val type: String,
    @SerialName("gender") val gender: String,
    @SerialName("origin") val origin: Location,
    @SerialName("location") val location: Location,
    @SerialName("image") val image: String,
    @SerialName("episode") val episode: List<String>,
    @SerialName("url") val url: String,
    @SerialName("created") val created: String
) {
    @Serializable
    public enum class Status {
        @SerialName("Alive")
        ALIVE,
        @SerialName("Dead")
        DEAD,
        @SerialName("unknown")
        UNKNOWN
    }
}

@Serializable
public data class Location(
    @SerialName("name") val name: String,
    @SerialName("url") val url: String
)

PagingSource

Extending PagingSource forces us to override 2 methods:

  1. public abstract suspend fun load(params: LoadParams<Key>): LoadResult<Key, Value> which is called when loading any page
  2. public abstract fun getRefreshKey(state: PagingState<Key, Value>): Key? which is called during the first page load ONLY when the initial PagingSource was invalidated.

Refresh key

override fun getRefreshKey(state: PagingState<String, Character>): String? =
        state.anchorPosition?.let { state.closestPageToPosition(anchorPosition = it) }?.prevKey

When getting the refresh key, we have access to some data from the previously invalidated paging source. To get the refresh key, we’ll use the PagingState<Key, Value>.anchorPosition, which gives us the position last accessed by the paging source (if any). If it’s null, then the initial paging source failed to load for whatever reason, and we can simply return null. If it’s not null, we can use it to get the closest page to that anchor position and get the previous page key (or next page key if that makes more sense to you).

Load

override suspend fun load(params: LoadParams<String>): LoadResult<String, Character> =
        runCatching { api.getAllCharacters(params.key ?: DEFAULT_URL) }.fold(
            onSuccess = {
                LoadResult.Page(
                    data = it.characters,
                    nextKey = it.info.next,
                    prevKey = it.info.prev
                )
            },
            onFailure = { LoadResult.Error(it) }
        )

    private companion object {
        const val DEFAULT_URL = "https://rickandmortyapi.com/api/character"
    }

load will be called every time we need to load a new page and for every page, we need to fetch the data from our api and return it. If param doesn’t have a key, that means that this is the first page, so we fetch from the default url.

We need to return a LoadResult which can be of 3 types:

  • Error which means we failed to load a new page for whatever reason, and we won’t load a new page in the future
  • Page which means we fetched the new page data successfully
  • Invalid which means we fetched the new data, but it’s invalid so don’t want to load a new page in the future.

In the onSuccess state, we return a LoadResult.Page instance which contains the new page data along with the previous and next key. In subsequent page loads, it’ll use next key when attempting to load a new page. In the event that you want to separate DTO and UI models, you could also map it at this point and pass that into the Page instance instead but we’ll keep it simple here.

If the api failed, we’ll simply return a LoadResult.Error instance and no subsequent load will happen in the future unless you refresh or retry. You could also map this error to some custom error so that you could handle it downstream and show different UIs for different failures i.e. different UIs for no internet vs an BE returned exception.

Final look

package dev.kadirkid.pagingtest.character.data

import androidx.paging.PagingSource
import androidx.paging.PagingState
import dev.kadirkid.pagingtest.character.model.Character

public class CharacterPagingSource(
    private val api: CharacterApi
): PagingSource<String, Character>() {
    override fun getRefreshKey(state: PagingState<String, Character>): String? =
        state.anchorPosition?.let { state.closestPageToPosition(anchorPosition = it) }?.prevKey

    override suspend fun load(params: LoadParams<String>): LoadResult<String, Character> =
        runCatching { api.getAllCharacters(params.key ?: DEFAULT_URL) }.fold(
            onSuccess = {
                LoadResult.Page(
                    data = it.characters,
                    nextKey = it.info.next,
                    prevKey = it.info.prev
                )
            },
            onFailure = { LoadResult.Error(it) }
        )

    private companion object {
        const val DEFAULT_URL = "https://rickandmortyapi.com/api/character"
    }
}

Factory

This is a helper class to create our paging source and it’s mostly straightforward:

public class CharacterPagingSourceFactoryImpl(private val api: CharacterApi) {
    fun create(): PagingSource<String, Character> = CharacterPagingSource(api)
}