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 ༼ つ ◕_◕ ༽つ.