Blog Infos
Author
Published
Topics
,
Published

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 our PagingSource implementation so that it updates the pageToken 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 its prevKey, and then make the API call using this prevKey as the query parameter pageToken in the function getMostPopularVideos().

Finally, the response from the API gives us nextPageToken which is nothing but the current pageToken or ‘refresh key’ because this page was accessed using its prevKey.

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 contains PagingData<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 and channelUrl fields in the data class and return them from the map function.
  • Now this Flow is of type PagingData<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)
}
}
view raw VideoAdapter.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

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:

  1. On the main screen, by ‘collecting’ the loadStateFlow from the PagingDataAdapter.
  2. 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:
  1. How to retry the network request (done by defining retry: () -> Unit as a constructor argument).
  2. loadState as a parameter of the bind method (the type LoadState 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 the setOnClickListener ‘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:
  1. Paging Basic Codelab
  2. Paging Advanced Codelab
  3. 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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
This is part of a multi-part series about learning to use Jetpack Compose through…
READ MORE
blog
RecyclerView is a really cool and powerful tool to display list(s) of content on Android.…
READ MORE
blog
Hi everyone! We (Kaspresso Team and AvitoTech) are back with more about automated Android testing. Previously…
READ MORE
blog
Ever wondered how to implement a synchronizer between Android’s RecyclerView and TabLayout? What are…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu