This tutorial teaches us how to display the most popular YouTube video content in a RecyclerView using the Paging library.
Check out my previous article on the Google News clone, Part 1 of this series.
This is what we are trying to implement:
screen showing the button for retrying the network request
Step 1: Update page keys based on the network response
The main trick is to override the
getRefreshKey
function in ourPagingSource
implementation so that it updates thepageToken
parameter based on the API response.
private const val STARTING_PAGE_TOKEN = " " | |
class VideosPagingSource @Inject constructor( | |
private val coroutineScope: CoroutineScope, | |
private val remoteApiService: VideosRemoteInterface | |
) : PagingSource<String, Item>() { | |
override fun getRefreshKey(state: PagingState<String, Item>): String? { | |
var current: String? = " " | |
val anchorPosition = state.anchorPosition | |
coroutineScope.launch { | |
if (anchorPosition != null) { | |
current = state.closestPageToPosition(anchorPosition)?.prevKey?.let { | |
remoteApiService.getMostPopularVideos( | |
it | |
).nextPageToken | |
} | |
} | |
} | |
return current | |
} | |
override suspend fun load(params: LoadParams<String>): LoadResult<String, Item> { | |
val start = params.key ?: STARTING_PAGE_TOKEN | |
return try { | |
val response = remoteApiService.getMostPopularVideos(start) | |
val nextKey = if (response.items.isEmpty()) null else response.nextPageToken | |
val prevKey = if (start == STARTING_PAGE_TOKEN) null else response.prevPageToken | |
LoadResult.Page( | |
data = response.items, | |
prevKey = prevKey, | |
nextKey = nextKey | |
) | |
}catch (e: IOException){ | |
return LoadResult.Error(e) | |
}catch (exception: HttpException){ | |
return LoadResult.Error(exception) | |
} | |
} | |
} |
Look at this step of the basic paging codelab for an overview of the PagingSource
implementation.
As you would have noticed, first we acquire the closest page centered around the
anchorPosition
, take itsprevKey
, and then make the API call using thisprevKey
as the query parameterpageToken
in the functiongetMostPopularVideos()
.Finally, the response from the API gives us
nextPageToken
which is nothing but the currentpageToken
or ‘refresh key’ because this page was accessed using itsprevKey
.
All of this is done in a separate CoroutineScope
which is injected using Hilt.
@Singleton | |
@Provides | |
fun provideCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) |
Step 2: Use the map operator to display a YouTube channel’s thumbnail
In YouTube Data API, getting the profile/thumbnail image of a YouTube channel involves a different endpoint, so we need to make a separate API request for this task.
First, let us create a data class that holds a video-item field and a string field for the channel’s image-URL.
data class VideoWithChannelModel( val item: Item, val channelUrl: String )
Then the part where we apply transformations to the ‘Pager.flow'
in our ViewModel:
- Use the
map
operator as a transform on the upstream flow which containsPagingData<Item>
as an emitted value. - On each emitted value, apply another
map
transform (this one is provided by the paging library). - Make the API call associated with the ‘channels’ endpoint inside the map function (since
map
is a suspend function). - Store the
item
andchannelUrl
fields in the data class and return them from themap
function. - Now this
Flow
is of typePagingData<VideoWithChannelModel>
.
The complete implementation for VideoViewModel
looks like this:
@HiltViewModel | |
class VideoViewModel @Inject constructor( | |
private val repository: VideoRepository | |
) : ViewModel() { | |
val items: Flow<PagingData<VideoWithChannelModel>> = Pager( | |
PagingConfig(10, enablePlaceholders = false), | |
pagingSourceFactory = { repository.videosPagingSource() } | |
).flow | |
.map { value: PagingData<Item> -> //map function from kotlinx.coroutines.flow | |
value.map { // map function in androidx.paging | |
val channelUrl = onChannelImgRequired(it.snippet.channelId.toString()) | |
VideoWithChannelModel(it, channelUrl) | |
} | |
} | |
.cachedIn(viewModelScope) | |
private suspend fun onChannelImgRequired(channelId: String): String { | |
return repository.loadChannelThumbnail(channelId) | |
} | |
} | |
data class VideoWithChannelModel( | |
val item: Item, | |
val channelUrl: String | |
) |
Repository’s implementation:
@Singleton | |
class VideoRepository @Inject constructor( | |
private val coroutineScope: CoroutineScope, | |
private val apiService: VideosRemoteInterface | |
) { | |
fun videosPagingSource() = VideosPagingSource(coroutineScope, apiService) | |
suspend fun loadChannelThumbnail(channelId: String): String { | |
return apiService.getChannelDetails(channelId).items[0].snippet.thumbnails.default.url | |
} | |
} |
The PagingDataAdapter
also needs to hold items that are of the type VideoWithChannelModel
class VideoAdapter : | |
PagingDataAdapter<VideoWithChannelModel, VideoAdapter.VideoViewHolder>(DiffCallback) { | |
companion object DiffCallback : DiffUtil.ItemCallback<VideoWithChannelModel>() { | |
override fun areItemsTheSame( | |
oldItem: VideoWithChannelModel, | |
newItem: VideoWithChannelModel | |
): Boolean { | |
return oldItem.item.id == newItem.item.id | |
} | |
override fun areContentsTheSame( | |
oldItem: VideoWithChannelModel, | |
newItem: VideoWithChannelModel | |
): Boolean { | |
return oldItem == newItem | |
} | |
} | |
inner class VideoViewHolder( | |
private val binding: VideoListItemBinding | |
) : RecyclerView.ViewHolder(binding.root) { | |
@SuppressLint("SetTextI18n") | |
fun bind(video: VideoWithChannelModel) { | |
binding.apply { | |
val imgUrl = video.channelUrl.toUri().buildUpon().scheme("https").build() | |
channelThumbnailImage.load(imgUrl) { | |
transformations(CircleCropTransformation()) | |
} | |
video.item.apply { | |
videoTitle.text = this.snippet.title | |
channelTitle.text = snippet.channelTitle | |
viewCount.text = statistics.viewCount | |
val imgUrl = snippet.thumbnails?.high?.url | |
val dateTime = getDateTimeDifference(snippet.publishedAt.toString()) | |
if (dateTime.days.toInt() != 0) { | |
timePublished.text = "${dateTime.days.toInt()} days ago" | |
} else if (dateTime.days.toInt() == 0) { | |
timePublished.text = "${dateTime.hours.toInt()} hours ago" | |
} | |
if (dateTime.hours.toInt() == 0) { | |
timePublished.text = "${dateTime.minutes.toInt()} minutes ago" | |
} | |
val imgUri = imgUrl?.toUri()?.buildUpon()?.scheme("https")?.build() | |
videoThumbnailImage.load(imgUri) | |
} | |
} | |
} | |
} | |
override fun onBindViewHolder(holder: VideoViewHolder, position: Int) { | |
val currentItem = getItem(position) | |
currentItem?.let { | |
holder.bind(it) | |
} | |
} | |
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideoViewHolder { | |
val binding = | |
VideoListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) | |
return VideoViewHolder(binding) | |
} | |
} |
Job Offers
Step 3: Presenting LoadStates in the recycler-view
There are two ways in which the loading state of the UI can be presented to the user:
- On the main screen, by ‘collecting’ the
loadStateFlow
from thePagingDataAdapter
. - At the end of the list, when more items are being loaded.
For now, we concentrate on implementing the second part.
This is done by creating separate classes that extend RecyclerView.ViewHolder
and LoadStateAdapter
respectively and creating a separate layout file for the recycler-view item.
We primarily need information on:
- How to retry the network request (done by defining
retry: () -> Unit
as a constructor argument). loadState
as a parameter of thebind
method (the typeLoadState
is already provided by the Paging library).
Following is the implementation of the steps discussed above:
class VideoLoadStateViewHolder( | |
private val binding: LoadstateViewholderLayoutBinding, | |
retry: () -> Unit | |
) : RecyclerView.ViewHolder(binding.root) { | |
init { | |
binding.retryButton.setOnClickListener { | |
retry.invoke() | |
} | |
} | |
fun bind(loadState: LoadState){ | |
binding.progressBar.isVisible = loadState is LoadState.Loading | |
binding.retryButton.isVisible = loadState is LoadState.Error | |
binding.retryText.isVisible = loadState is LoadState.Error | |
} | |
companion object{ | |
fun create(parent: ViewGroup, retry: () -> Unit): VideoLoadStateViewHolder{ | |
val view = LayoutInflater.from(parent.context).inflate(R.layout.loadstate_viewholder_layout, parent, false) | |
val binding = LoadstateViewholderLayoutBinding.bind(view) | |
return VideoLoadStateViewHolder(binding, retry) | |
} | |
} | |
} |
As specified in this task of Kotlin Koans :
Objects with the
invoke() method can be invoked as a function.
Hence we wrote
retry.invoke()
in thesetOnClickListener
‘s lambda.
The Adapter class which takes a function as an argument :
class VideoLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<VideoLoadStateViewHolder>() { | |
override fun onBindViewHolder(holder: VideoLoadStateViewHolder, loadState: LoadState) { | |
holder.bind(loadState) | |
} | |
override fun onCreateViewHolder( | |
parent: ViewGroup, | |
loadState: LoadState | |
): VideoLoadStateViewHolder { | |
return VideoLoadStateViewHolder.create(parent, retry) | |
} | |
} |
And finally the layout file:
<?xml version="1.0" encoding="utf-8"?> | |
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
xmlns:app="http://schemas.android.com/apk/res-auto" | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content"> | |
<TextView | |
android:id="@+id/retry_text" | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content" | |
android:fontFamily="@font/roboto_medium" | |
android:padding="10dp" | |
android:text="@string/oops_something_went_wrong" | |
android:textAlignment="center" | |
android:textColor="@color/black" | |
android:textSize="20sp" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent" /> | |
<Button | |
android:id="@+id/retry_button" | |
android:layout_width="wrap_content" | |
android:layout_height="50dp" | |
android:background="@drawable/retry_button_background" | |
android:fontFamily="@font/roboto_medium" | |
android:text="@string/retry" | |
android:textColor="@color/black" | |
android:textSize="16sp" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@id/retry_text" /> | |
<com.google.android.material.progressindicator.LinearProgressIndicator | |
android:id="@+id/progress_bar" | |
app:indicatorColor="@color/red" | |
android:layout_width="match_parent" | |
android:layout_height="60dp" | |
android:indeterminate="true" | |
app:layout_constraintTop_toTopOf="parent" /> | |
</androidx.constraintlayout.widget.ConstraintLayout> |
All done, the only remaining step is to observe/collect the loading state in our UI.
As you can see in the fragment’s implementation, we pass the function adapter.retry()
as a lambda argument in the VideoLoadStateAdpapter
.
adapter.withLoadStateFooter()
is used when items are to be loaded only at the end of the list.
@AndroidEntryPoint | |
class VideosFragment : Fragment(R.layout.fragment_popular_videos_list) { | |
private lateinit var _binding: FragmentPopularVideosListBinding | |
private val binding get() = _binding | |
private val viewModel: VideoViewModel by viewModels() | |
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | |
super.onViewCreated(view, savedInstanceState) | |
_binding = FragmentPopularVideosListBinding.bind(view) | |
val adapter = VideoAdapter() | |
binding.apply { | |
videosList.adapter = adapter.withLoadStateFooter( | |
footer = VideoLoadStateAdapter{adapter.retry()} | |
) | |
} | |
viewLifecycleOwner.lifecycleScope.launch { | |
adapter.loadStateFlow.collectLatest { | |
binding.progressBar.isVisible = it.source.append is LoadState.Loading | |
} | |
} | |
viewLifecycleOwner.lifecycleScope.launch { | |
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { | |
viewModel.items.collectLatest { | |
adapter.submitData(it) | |
} | |
} | |
} | |
} | |
} |
For more information, check out this step of the advanced paging codelab.
Learning Resources:
- Paging Basic Codelab
- Paging Advanced Codelab
- Part 1 of the series:
4. Android Developers’ blog post:
5. ‘Safely’ collecting Flows in the UI layer:
Link to this project’s GitHub repository:
This article was originally published on proandroiddev.com on May 22, 2022