Ever tried making an infinite scroll list that loads data from a network API? You know, like you see on social media feeds like Facebook and Twitter?

You can’t load an infinite amount of data from an api in one go. You would have to split it into pages, hence the term pagination.

Among the Android Architecture components, we have the Paging Library which introduces classes such as PagedList, DataSource, and PagedListAdapter.

And while there is documentation on this library and a mini-tutorial, it does a good job of showing how it works using the Room persistence library but paged data with an api is a bit different. They also have a PagingWithNetwork sample but that took me some time to get through and decipher how the paging library works.

So we’ll be going over said library here today. Like the sample, we’ll be using the Reddit api, as well as Retrofit to load it in an MVVM app. As such, much of the code will be similar to parts of the sample but unlike the sample, we’ll be coding only what we need to get the paging library to work.

 

Get the Dependencies

// Android Architecture Components
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
implementation 'androidx.core:core-ktx:1.2.0'
implementation 'androidx.activity:activity-ktx:1.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.recyclerview:recyclerview:1.1.0'

// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.7.1'
implementation 'com.squareup.retrofit2:converter-gson:2.7.1'

Note that we are using the Kotlin version of the paging library, denoted by the ktx suffix. The rest of the androidx dependencies and Retrofit are necessary for building the rest of our app.

implementation "androidx.paging:paging-runtime:$paging_version" // For Kotlin use paging-runtime-ktx

// alternatively - without Android dependencies for testing
testImplementation "androidx.paging:paging-common:$paging_version" // For Kotlin use paging-common-ktx

// optional - RxJava support
implementation "androidx.paging:paging-rxjava2:$paging_version" // For Kotlin use paging-rxjava2-ktx

The other paging library dependencies you might need but are not being used in the app are as shown above.

How it works in a nutshell

We will define a RedditApi retrofit interface and pair it up with our own implementation of a PageKeyedDataSource instead of an api service.  This has methods loadInitial() and loadAfter() which are called automatically by our use and implementation of PagedList and PagedListAdapter.

Setup the Api

Like I mentioned earlier, we’ll be using the Reddit Api in this app. It allows us to fetch posts on a subreddit and also allows us to do so in pages.

RedditApi

interface RedditApi {

    @GET("/r/{subreddit}/hot.json")
    fun getTop(
        @Path("subreddit") subreddit: String,
        @Query("limit") limit: Int): Call<ListingResponse>

    @GET("/r/{subreddit}/hot.json")
    fun getTopAfter(
        @Path("subreddit") subreddit: String,
        @Query("after") after: String,
        @Query("limit") limit: Int): Call<ListingResponse>


    class ListingResponse(val data: ListingData)

    class ListingData(
        val children: List<RedditChildrenResponse>,
        val after: String?,
        val before: String?
    )

    data class RedditChildrenResponse(val data: RedditPost)


}

I have my endpoints listed as such. When our activity initially loads, we want to run getTop() to get the top n posts of the subreddit. As we scroll to the bottom of our list, we want our app to run getTopAfter() to get the top n posts of the subreddit after a certain post which we will identify by its name.

PostsDataSource

class PostsDataSource: PageKeyedDataSource<String, RedditPost>() {

    private val redditApi: RedditApi = Retrofit.Builder()
        .baseUrl("https://www.reddit.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(RedditApi::class.java)

In place of the standard api service, I will use my own implementation of the PageKeyedDataSource class, which is a component of the Paging Library. In this implementation, we must implement 3 methods which make up the engine of our pagination: loadBefore(), loadInitial(), and loadAfter(). For what we are trying to achieve, we don’t need to make use of loadBefore().

override fun loadInitial(
    params: LoadInitialParams<String>,
    callback: LoadInitialCallback<String, RedditPost>
) {
    val request = redditApi.getTop("androiddev", params.requestedLoadSize)
    val response = request.execute()
    val data = response.body()?.data
    val items = data?.children?.map { it.data } ?: emptyList()
    callback.onResult(items, data?.before, data?.after)
}

This method gets called when our list is initialised (and theoretically after loadBefore()).  We want to use this to load our initial set of data, and pass that data through the callback parameter of the method.

We are using execute() here instead of enqueue() as this is the initial load and thus, we are not too bothered about blocking the current thread. Feel free to change this depending on the needs of your app.

Also note we are passing in a hardcoded "androiddev" as a parameter. In a real app implementation, you will most likely have a different way of passing in this parameter.

override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<String, RedditPost>) {
    redditApi.getTopAfter(
        "androiddev",
        params.key,
        params.requestedLoadSize
    ).enqueue(object: Callback<RedditApi.ListingResponse> {

        override fun onResponse(
            call: Call<RedditApi.ListingResponse>,
            response: Response<RedditApi.ListingResponse>
        ) {
            if (response.isSuccessful) {
                val data = response.body()?.data
                val items = data?.children?.map { it.data } ?: emptyList()
                callback.onResult(items, data?.after)
            }
        }

        override fun onFailure(call: Call<RedditApi.ListingResponse>, t: Throwable) {
            t.printStackTrace()
        }
    })
}

This method gets called when we scroll through all our currently loaded items. We use enqueue() to asynchronously load a new set of items to append to our current list. It does this by making a new api call where it passes an after query parameter to load a new set of items that proceed an item specified by a key (in this case, its name).

PostsDataSourceFactory

class PostsDataSourceFactory: DataSource.Factory<String, RedditPost>() {

    val sourceLiveData = MutableLiveData<PostsDataSource>()

    override fun create(): DataSource<String, RedditPost> {
        val source = PostsDataSource()
        sourceLiveData.postValue(source)
        return source
    }
}

In order to create a LiveData from our DataSource, we need to create a concrete implementation of DataSource.Factory with a globally declared LiveData, and use this when you override its create() method. Another bonus of having this is as a factory is that it makes using the Abstract Factory pattern easy to implement.

DataSource.Factory also contains the toLiveData() method which lets us continuously extract the data we set from our DataSource and keep updating this piece of LiveData.

RedditRepository

class RedditRepository {

    private val sourceFactory = PostsDataSourceFactory()

    fun getPosts(): LiveData<PagedList<RedditPost>> {
        return sourceFactory.toLiveData(
            pageSize = 10
        )
    }
}

Things get much simpler from here on out, so we’ll go a bit faster too. Give repository class an instance of the previously created DataSource.Factory, and create a function that returns a LiveData of  PagedList, a special type of list that gets its data from the DataSource.

MainViewModel

class MainViewModel: ViewModel() {

    private val redditRepository = RedditRepository()

    val posts = redditRepository.getPosts()

}

The ViewModel does nothing special here. Just prepare your LiveData using the function you already prepared in your repository.

MainActivity

class MainActivity: AppCompatActivity() {

    lateinit var viewModel: MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val viewModel: MainViewModel by viewModels()
        this.viewModel = viewModel

        observePosts()
    }

    private fun observePosts() {
        val adapter = PostsAdapter()
        posts_recycler.adapter = adapter
        viewModel.posts.observe(this, Observer {
            adapter.submitList(it) {}
        })
    }
}

The Activity’s main role here is to attach the ViewModel to itself, initialise the recycler view adapter, and observe the LiveData from the ViewModel.

Note that we don’t use notifyDataSetChanged(), but instead opt for submitList(). This is a special update kind of method for PagedListAdapter which handles the appending of new pages of our data in addition to existing ones.

PostsAdapter

class PostsAdapter: PagedListAdapter<RedditPost, PostsAdapter.PostViewHolder>(POST_COMPARATOR) {

    companion object {
        val POST_COMPARATOR = object : DiffUtil.ItemCallback<RedditPost>() {
            override fun areContentsTheSame(oldItem: RedditPost, newItem: RedditPost): Boolean =
                oldItem == newItem

            override fun areItemsTheSame(oldItem: RedditPost, newItem: RedditPost): Boolean =
                oldItem.name == newItem.name
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder {
        val itemView = LayoutInflater.from(parent.context)
            .inflate(R.layout.list_item_post, parent, false)
        return PostViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    class PostViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
        fun bind(redditPost: RedditPost?) {
            itemView.title.text = redditPost?.title
            itemView.body.text = if (redditPost?.selftext?.isNotEmpty() == true)
                redditPost.selftext else redditPost?.url
        }
    }
}

The final piece in our paging solution is an implementation of PagedListAdapter. This handles requesting for new information or ‘pages’ when the current list is completely scrolled through.

It is for the most part the same as a standard recycler view adapter implementation, other than the fact that you have to use getItem() to retrieve your item from your PagedList, as well as the obvious addition of a comparator callback it takes as a parameter.

So why do we need a comparator exactly? Well according to the documentation of submitList():

“If a list is already being displayed, a diff will be computed on a background thread, which will dispatch Adapter.notifyItem events on the main thread.”

And that’s it! If you did everything correctly, you can now run your app and see it all at work

 

How it all works

The PagedList being used in your adapter is a result of DataSource.Factory.toLiveData(), which links the DataSource to your list. When the adapter detects that it needs to fetch a new page of data, it prompts the DataSource to fetch it, which then updates the LiveData and tells the observer in the activity to call submitList(). With the aid of the comparator, this updates the list with a new page of items.

Get the Source Code

Checkout the source code here on Github and if you found this little tutorial useful, please share this with your fellow devs and maybe even stick a comment down below. As always, happy coding ༼ つ ◕_◕ ༽つ.

 

Newsletter

Subscribe to the Newsletter