Lists are everywhere in Android applications and it can be a pain to implement an elegant never-ending list design. Well, Google has taken notice and their solution is the new Paging Library. While confusing at first glance, the library makes dynamically loading your lists much easier than ever before and after mastering it you’ll never look back.
In this walkthrough I’ll be implementing a PagedList within a MVVM architecture design using the provided Google architecture components. If you aren’t familiar with MVVM or the Google architecture design I highly recommend you take a quick look at Google’s Guide to app architecture. Also definitely take a look at the Clean Paging example in the Google Samples repository, as my implementation is heavily based on it. While the example is great, it was definitely hard to understand and I hope to clearly explain everything in this post! Lets get to it.
Every PagedList implementation basically boils down to the following files:
- Data endpoint (DAO or API)
- Data Source
- Data Factory
- Repository
- View Model
- PagedList Adapter
- Activity/Fragment
It looks complicated now..and it kind of is. But after we take a look at each part individually it’ll make a lot more sense. We’ll start at the Data endpoint and end at the Activity/Fragment. Some of these pieces also aren’t specific to a PagedList implementation, but are highlighted here just to make things easier to understand.
Data Endpoint
@Dao
interface JokesDao {
...
@Query("SELECT * FROM jokes WHERE id BETWEEN :idStart AND :idEnd")
suspend fun getJokes(idStart: Int, idEnd: Int): List<Joke>
@Query("SELECT * FROM jokes WHERE id BETWEEN :idStart AND :idEnd")
fun getJokesSync(idStart: Int, idEnd: Int): List<Joke>
...
}
Above is a simple Room Dao implementation that includes 2 endpoints for getting a list of jokes. One is a suspend function and one is not and labeled as “getJokesSync”. You will soon understand why this is necessary, but for now just understand that we need some sort of endpoint that returns a list.
Data Source
This class is unique to PagedList, so if you’ve never seen it before you’re in good company. Basically what implementing the data source boils down to is implementing three functions:
- loadInitial
- loadBefore
- loadAfter
We also have the retry value and retryAllFailed function, which will be used for retrying on an error, and networkState, which is used to tell our view if we are loading, loaded, or have received an error.
LoadInitial is what is called initially (makes sense, right?). As noted, we need to make sure that we call our end point synchronously in this function, because if we don’t we will observe some flickering while refreshing our list. Now all we do is set networkState and retry to the appropriate value within our loadInitial function based on if we successfully load our data. On a successful loading we need to call the provided callback with our data, previousKey, and nextKey values. These key values are used as the param key value in loadBefore and loadAfter.
LoadBefore and LoadAfter are basically the same as LoadInitial, but are used for loading list items before and after the initially loaded list values. The main difference is we can now call our data endpoint asynchronously and instead of providing previous and next keys in our callback, we only need to include a single key for the next before or after load.
Note: I have not implemented LoadBefore below because I am choosing to start loading the list at the beginning, which means we’ll never have items before the initial load to load.
class JokeDataSource @Inject constructor(val jokesDao: JokesDao, val retryExecutor: Executor) : PageKeyedDataSource<Int, Joke>() {
private var retry: (() -> Any)? = null
val networkState = MutableLiveData<NetworkState>()
fun retryAllFailed() {
val prevRetry = retry
retry = null
prevRetry?.let {
retryExecutor.execute {
it.invoke()
}
}
}
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, Joke>) {
networkState.postValue(NetworkState.LOADING)
try {
//call synchronously
val response = jokesDao.getJokesSync(1, 10)
retry = null
networkState.postValue(NetworkState.LOADED)
callback.onResult(response, null, 11)
} catch (e: Exception) {
retry = {
loadInitial(params, callback)
}
networkState.postValue(NetworkState.FAILED)
}
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, Joke>) {
networkState.postValue(NetworkState.LOADING)
GlobalScope.launch {
try {
val response = jokesDao.getJokes(params.key, params.key + 9)
retry = null
networkState.postValue(NetworkState.LOADED)
callback.onResult(response, params.key + 10)
} catch (e: Exception) {
retry = {
loadAfter(params, callback)
}
networkState.postValue(NetworkState.FAILED)
}
}
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, Joke>) {
//not implemented because we never load backwards
}
}
Data Factory
The data factory acts as a wrapper for our data source. It doesn’t do a lot, but what it does do is handle creating our data source and keeping a LiveData reference to it, which we’ll see is very important.
class JokeDataFactory @Inject constructor(val jokesDao: JokesDao, val retryExecutor: Executor): DataSource.Factory<Int, Joke>() {
val mutableLiveData = MutableLiveData<JokeDataSource>()
override fun create(): JokeDataSource {
val source = JokeDataSource(jokesDao, retryExecutor)
mutableLiveData.postValue(source)
return source
}
}
Repository
In our repository we need an endpoint where we get our PagedList from. To better let our ViewModel know about and interact with the PageList, we will be wrapping it in the Listing data class (shown under the Repo code). In our repo’s endpoint we start by simply creating a pagedList by calling LivePagedListBuilder with our data factory as the first argument and the page size as our second argument and calling build(). The page size determines the number of items to be loaded each time the list is dynamically loaded. Now we set the different values on our Listing class and return it. This includes the PagedList and networkState (from our data source) wrapped in LiveDatas as well as endpoints to retry and refresh our PagedList. We retry by calling our retry function value (from our data source) which is set to one of the load functions on a failed load and we refresh by calling invalidate on our saved instance of the data source inside the data factory.
IMPORTANT: I struggled for quite a bit trying to get my PagedList to refresh by calling invalidate on my data source. I believe in the end my issue was that I was not calling invalidate on the same data source instance. After refactoring my code into the design explained in this post it worked like a charm because the data factory wrapper assures that we keep the same data source instance and we create a new data source instance if we build a new PagedList.
class JokesRepository @Inject constructor(private val jokesDao: JokesDao, private val jokeDataFactory: JokeDataFactory) {
...
@MainThread
fun getPagedJokes(): Listing<Joke> {
val livePagedList = LivePagedListBuilder(jokeDataFactory, 10).build()
return Listing(
pagedList = livePagedList,
networkState = Transformations.switchMap(jokeDataFactory.mutableLiveData) {
it.networkState
},
retry = {
jokeDataFactory.mutableLiveData.value?.retryAllFailed()
},
refresh = {
jokeDataFactory.mutableLiveData.value?.invalidate()
}
)
}
}
data class Listing<T>(
val pagedList: LiveData<PagedList<T>>,
val networkState: LiveData<NetworkState>,
val refresh: () -> Unit,
val retry: () -> Unit
)
View Model
Now thanks to all the work we did in our data layer our View Model can stay relatively simple. On init we call our Repository’s endpoint and put the Listing object in jokeList. jokes and networkState are then populated using switchMaps with the PagedList and NetworkState, respectively. We also have our refresh and retry endpoints wired up to the Listing object.
class JokesViewModel @Inject constructor(val jokesRepo: JokesRepository): BaseViewModel() {
private val jokeList = MutableLiveData<Listing<Joke>>()
val jokes = switchMap(jokeList){ it.pagedList }
val networkState = switchMap(jokeList){ it.networkState }
init {
jokeList.value = jokesRepo.getPagedJokes()
}
fun refresh() {
jokeList.value?.refresh?.invoke()
}
fun retry() {
jokeList.value?.retry?.invoke()
}
}
PagedList Adapter
The PagedList Adapter is a crucial part of this to get right, because if something is wrong with your adapter that is in charge of managing the UI then you won’t see any results from all the work you’ve done. I’ve included my entire PagedListAdapter implementation below and I’ll highlight the PagedList specific parts now:
-
Comparator:
This is needed so that on a refresh the adapter can recognize when certain list items are unchanged and elegantly translate that into a satisfying user experience. Simply implement areContentsTheSame and areItemsTheSame for your main list item type.
-
retry/loading states:
hasExtraRow, getItemViewType, getItemCount, and setNetworkState all need to be implemented as they are below to allow the adapter to display a progress view or error and retry button when in the loading or failed networkState. We also have our retry and networkState properties that will be set in our view.
PagedListAdapter:
class JokesAdapter @Inject constructor(): PagedListAdapter<Joke, RecyclerView.ViewHolder>(JOKE_COMPARATOR) { private var retryCallback: () -> Unit = {} fun setRetryCallback(callback: () -> Unit) { retryCallback = callback } private var networkState: NetworkState? = null override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when(getItemViewType(position)) { R.layout.item_network_state -> { val nsHolder = holder as NetworkStateViewHolder nsHolder.progressBar.visibility = Utils.toVisibility(networkState == NetworkState.LOADING) nsHolder.retryButton.visibility = Utils.toVisibility(networkState == NetworkState.FAILED) nsHolder.retryButton.setOnClickListener { retryCallback() } nsHolder.textError.visibility = Utils.toVisibility(networkState == NetworkState.FAILED) nsHolder.textError.text = "Error" } R.layout.item_joke -> { val jHolder = holder as JokeViewHolder val joke: Joke = getItem(position)!! jHolder.textName.text = joke.name jHolder.imageFinished.setImageDrawable(if (joke.finished) jHolder.itemView.resources.getDrawable(R.drawable.ic_check_outline) else jHolder.itemView.resources.getDrawable(R.drawable.ic_check_outline_progress)) jHolder.textBody.text = joke.body jHolder.textType.text = joke.type jHolder.textLength.text = joke.getShortenedLength() jHolder.itemView.setOnClickListener { val directions: NavDirections = JokesFragmentDirections.actionNavigationJokesToJokeFragment(joke.id) findNavController(it).navigate(directions) } } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when(viewType) { R.layout.item_network_state -> NetworkStateViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_network_state, parent, false)) R.layout.item_joke -> JokeViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_joke, parent, false)) else -> throw Exception("Not a valid view type") } } private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED override fun getItemViewType(position: Int): Int { return when { hasExtraRow() && position == itemCount - 1 -> R.layout.item_network_state else -> R.layout.item_joke } } override fun getItemCount(): Int { return super.getItemCount() + if (hasExtraRow()) 1 else 0 } fun setNetworkState(newNetworkState: NetworkState) { val previousState = networkState val hadExtraRow = hasExtraRow() networkState = newNetworkState val hasExtraRow = hasExtraRow() if(hadExtraRow != hasExtraRow) { if(hadExtraRow) { notifyItemChanged(super.getItemCount()) } else { notifyItemInserted(super.getItemCount()) } } else if(hasExtraRow && previousState != newNetworkState) { notifyItemChanged(itemCount - 1) } } class JokeViewHolder(view: View): RecyclerView.ViewHolder(view) { val textName = view.tv_joke_name val imageFinished = view.iv_finished val textBody = view.tv_joke_body val textType = view.tv_joke_type val textLength = view.tv_joke_length } companion object { private val TYPE_PROGRESS = 0 private val TYPE_ITEM = 1 val JOKE_COMPARATOR = object: DiffUtil.ItemCallback<Joke>() { override fun areContentsTheSame(oldItem: Joke, newItem: Joke): Boolean = oldItem == newItem override fun areItemsTheSame(oldItem: Joke, newItem: Joke): Boolean = oldItem.id == newItem.id } } }
item_joke.xml:
<?xml version="1.0" encoding="utf-8"?> <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="4dp" app:cardCornerRadius="2dp" app:cardUseCompatPadding="true" app:cardElevation="2dp" android:foreground="?android:attr/selectableItemBackground" android:clickable="true"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dp"> <TextView android:id="@+id/tv_joke_name" android:layout_width="0dp" android:layout_height="wrap_content" android:paddingEnd="8dp" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@id/iv_finished" app:layout_constraintHorizontal_bias="0" android:textStyle="bold" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" tools:text="Joke Name"/> <ImageView android:id="@+id/iv_finished" android:layout_width="24dp" android:layout_height="24dp" app:layout_constraintHorizontal_bias="100" app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent" android:src="@drawable/ic_check_outline_progress"/> <TextView android:id="@+id/tv_joke_body" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:layout_marginBottom="8dp" app:layout_constraintBottom_toTopOf="@id/tv_joke_type" app:layout_constraintTop_toBottomOf="@+id/tv_joke_name" android:maxLines="3" android:ellipsize="end" tools:text="I started listening to classical music while working recently. My favorite composer so far has to be Mozart. Which isn’t his full name by the way. His full name is “Wolfgang Amadeus Mozart”. Which is an incredibly badass name to abbreviate. That’s like having a friend named Cheetaposse Dionysus Matthew and just calling him Matt. Like imagine you’re meeting the friend group of someone you know and they’re like:"/> <TextView android:id="@+id/tv_joke_type" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintTop_toBottomOf="@+id/tv_joke_body" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@+id/tv_joke_length" app:layout_constraintHorizontal_bias="0" android:maxLines="1" android:ellipsize="end" tools:text="Type"/> <TextView android:id="@+id/tv_joke_length" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintStart_toEndOf="@id/tv_joke_type" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="100" tools:text="3 min"/> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.cardview.widget.CardView>
item_network_state:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="8dp"> <TextView android:id="@+id/tv_error" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal"/> <ProgressBar android:id="@+id/progress_bar" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center"/> <Button android:id="@+id/btn_retry" style="@style/Widget.AppCompat.Button.Colored" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="Retry"/> </LinearLayout>
Activity/Fragment
Finally we have everything that we need and now just need to wire it all up inside our view. I’ll include the layout and fragment code here and explain afterwards:
Layout:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewmodel"
type="com.jacobstinson.standup.view_viewmodel.jokes.JokesViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".view_viewmodel.jokes.JokesFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".view_viewmodel.jokes.JokesFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview_jokes"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_margin="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:src="@drawable/ic_pencil"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Fragment:
class JokesFragment @Inject constructor(private val viewModelFactory: ViewModelProvider.Factory,
private val jokesAdapter: JokesAdapter): Fragment() {
val viewModel by lazy {
ViewModelProviders.of(activity!!, viewModelFactory).get(JokesViewModel::class.java)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = FragmentJokesBinding.inflate(inflater, container, false).apply {
viewmodel = viewModel
}
binding.lifecycleOwner = viewLifecycleOwner
jokesAdapter.setRetryCallback {
viewModel.retry()
}
binding.recyclerviewJokes.adapter = jokesAdapter
viewModel.jokes.observe(this, Observer {
jokesAdapter.submitList(it)
binding.swipeRefresh.isRefreshing = false
})
viewModel.networkState.observe(this, Observer {
jokesAdapter.setNetworkState(it)
})
binding.swipeRefresh.setOnRefreshListener {
viewModel.refresh()
}
return binding.root
}
}
The important part of the View implementation is inside of the onCreateView in the fragment code. First we wire up our retry functionality by calling setRetryCallback on our PagedListAdapter and setting the callback to the viewModel.retry() function.
Then we attach our adapter to our view’s recyclerview and observe the PagedList and NetworkState LiveData values in our viewmodel and set their corresponding values in the PagedListAdapter whenever they change.
Finally we call viewModel.refresh() when we swipe our SwipeRefresh layout.
Build and run your project, and as long as your data endpoint is providing you the proper data you will now have a nice and elegantly dynamic list. Yay!