[Huawei Developers] AbdurrahimCillioglu:
Introduction

Hi everyone, In this article, we’re going to explore how to paginate with the Paging 3 library for Huawei Site Kit and, we’ll use Kotlin in Android Studio. Paging library is a generic solution for pagination. It helps us load and display data from any local or network sources. We’ve used Site Kit as a sample.

Why we need Pagination?

While we get data from any source, pagination provides us getting small chunks of data at a time. That way, we don’t load and display to the user extra data. As a result, we reduce unnecessary network requests and system resource usage.

What is Paging 3 Library?

Paging 3 library is a part of Android Jetpack and enables to load large sets of data gradually. It suits with Android App Architecture and integrates easily with other Jetpack components.

We’ve listed some of the features of the library below.

It supports Kotlin coroutines, Flow, RxJava, and LiveData.

It works with RecyclerView in an integrated manner.

It caches the paged data in-memory to use system resources efficiently.

It makes simple error handling, page refreshing, and retry.

Huawei Site Kit

Site Kit provides place search services including keyword search, nearby place search, place detail search, and place search suggestion, helping your app provide convenient place-related services to attract more users and improve user loyalty.

We’re not going to go into the details of adding Sit Kit to a project. You can follow the instructions to add Site Kit to your project via official docs or codelab.

Our Sample Pagination Project

In this project, we’ll develop a sample app showing nearby places of the user.

You can see the package structure of our application in the image below.

We used MVVM architecture and recent libraries.

Setup the Project

Add the necessary dependencies to build.gradle (app level)

// HMS Core
implementation 'com.huawei.agconnect:agconnect-core:1.4.2.300'
// Huawei Site Kit
implementation 'com.huawei.hms:site:5.1.0.300'

// Hilt for Dependency Injection
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"

// Coroutines for Asynchronous Programming
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1'

def lifecycle_version = "2.2.0"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Annotation processor
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"

// to use viewModels() property delegate
implementation "androidx.activity:activity-ktx:1.1.0"
// Paging 3 for pagination
implementation "androidx.paging:paging-runtime:3.0.0-alpha13"

Layout Files

activity_main.xml includes RecyclerView to display nearby places, a progress bar for the loading process, and a button for retry the data.

<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerSite"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/retryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Retry"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

item_site.xml -> We need a item for RecyclerView.

<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:background="#f9f9f9"
android:orientation="vertical">
<ImageView
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:src="@drawable/ic_place"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/siteName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toTopOf="parent"
tools:text="Eiffel Tower" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintGuide_percent="0.2"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/siteDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/siteName"
tools:text="800m" />
</androidx.constraintlayout.widget.ConstraintLayout>

Model Classes

We have 2 model classes in our app named Site and Result. Site class is a part of the Site Kit so, we don’t create it.

Result.kt -> Result is a wrapper class which contains Success and Error.

sealed class Result<out R> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
}

Source Of Data

Firstly we need a source to get data so, use the Site Kit as a source. We need a location to use the NearbyLocation feature of the Site Kit and, we selected a mock location near the Eiffel Tower.

HuaweiSiteSource.kt -> With the help of the getNearbyPlaces() function, we get the Site List. This suspend function takes the page number as a parameter. If an error occurs in this process, it returns the Error as a result.

@Singleton
class HuaweiSiteSource @Inject constructor(@ApplicationContext context: Context) {

private val PAGE_SIZE = 20

private val searchService by lazy {
SearchServiceFactory.create(
context,
URLEncoder.encode(Constant.API_KEY, "UTF-8")
)
}

suspend fun getNearbyPlaces(
page: Int,
): Result<List<Site>> {

val eiffelTowerCoordinate = Coordinate(48.858093, 2.294694)

return suspendCoroutine { cont ->

val radius = 10000
val request = NearbySearchRequest()

request.location = eiffelTowerCoordinate
request.radius = radius
request.pageSize = PAGE_SIZE
request.pageIndex = page
searchService?.nearbySearch(
request,
object : SearchResultListener<NearbySearchResponse> {
override fun onSearchResult(nearbySearchResponse: NearbySearchResponse?) {
val siteList = nearbySearchResponse?.sites
siteList?.let {
cont.resume(Result.Success(it))
}
}

override fun onSearchError(searchStatus: SearchStatus?) {
cont.resume(Result.Error(Exception(searchStatus?.errorMessage)))
}
})
}
}

}

Then, we create the SitePagingSource.kt and implement a PagingSource<Key, Value> to define a data source. It takes a Key and Value as parameters. The Key is the index numbers for pages and, Value is the type of data loaded. We specified Int as the page number, Site as the data type.

PagingSource requires us to implement load() and getRefreshKey() functions.

load() is a suspend function. So, we can make our network or local database requests without blocking the main thread.

getRefreshKey() provides a Key for the initial load for the next Paging Source due to invalidation of this PagingSource. The last accessed position can be retrieved via "state.anchorPosition" so, we used the "state.anchorPosition".

@Singleton
class SitePagingSource(
private val huaweiSiteSource: HuaweiSiteSource
) : PagingSource<Int, Site>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Site> {

return try {
val page = params.key ?: FIRST_PAGE_INDEX
val result = huaweiSiteSource.getNearbyPlaces(page)

return when (result) {
is Result.Success -> LoadResult.Page(
data = result.data,
prevKey = if (page == FIRST_PAGE_INDEX) null else page - 1,
nextKey = if (result.data.isEmpty() || page >= LAST_PAGE_INDEX) null else page + 1
)
else -> LoadResult.Error(Throwable("Error occurred"))
}

} catch (e: Exception) {
LoadResult.Error(e)
}

}

override fun getRefreshKey(state: PagingState<Int, Site>): Int? {
return state.anchorPosition
}

companion object {
const val FIRST_PAGE_INDEX = 1
const val LAST_PAGE_INDEX = 3
}

}

HuaweiRepository.kt -> getDefaultPageConfig() provides us to configure our settings such as page size, placeholders, initialloadsize etc.

getSiteListAsFlow() calls the load() method from the SitePagingSource to get data and, we’re transferring the data through the flow.

@Singleton
class HuaweiRepository @Inject constructor(
private val huaweiSiteSource: HuaweiSiteSource
) {

fun getSiteListAsFlow(
pagingConfig: PagingConfig = getDefaultPageConfig()
): Flow<PagingData<Site>> {
return Pager(
config = pagingConfig,
pagingSourceFactory = { SitePagingSource(huaweiSiteSource) }
).flow
}

private fun getDefaultPageConfig(): PagingConfig {
return PagingConfig(
pageSize = 20,
enablePlaceholders = false
)
}

}

ViewModel

MainViewModel.kt -> fetchSiteList() helps us getting the data and cache it to survive configuration changes like screen rotation.

@HiltViewModel
class MainViewModel @Inject constructor(
private val huaweiRepository: HuaweiRepository
) : ViewModel() {

fun fetchSiteList(): Flow<PagingData<Site>> {
return huaweiRepository.getSiteListAsFlow()
.cachedIn(viewModelScope)
}

}

Before get into the showing places on the view, I want to mention about RecyclerView Adapter. Paging3 has a special Adapter to list items in the recyclerview.

SiteAdapter.kt -> In this class, we extend our class from PagingDataAdapter. It takes two parameters; a Model class (Site) and a ViewHolder (SiteViewHolder). Also, we used ViewBinding to interact with the views that have an assigned id value in a null-safe and type-safe way. And, we applied the Higher-order function for item clicks.

class SiteAdapter : PagingDataAdapter<Site, SiteAdapter.SiteViewHolder>(REPO_COMPARATOR) {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SiteViewHolder {
val binding = ItemSiteBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return SiteViewHolder(binding)
}

companion object {
private val REPO_COMPARATOR = object : DiffUtil.ItemCallback<Site>() {

override fun areItemsTheSame(oldItem: Site, newItem: Site): Boolean {
return oldItem.siteId == newItem.siteId
}

override fun areContentsTheSame(oldItem: Site, newItem: Site): Boolean {
return oldItem.equals(newItem)
}
}
}

private var onItemClickListener: ((Site) -> Unit)? = null

override fun onBindViewHolder(holder: SiteViewHolder, position: Int) {

val site = getItem(position) ?: return

holder.binding.apply {
this.siteName.text = site.name
this.siteDistance.text = "${site.distance}m"
}

holder.itemView.apply {
setOnClickListener {
onItemClickListener?.let { it(site) }
}
}

}

inner class SiteViewHolder(val binding: ItemSiteBinding) : RecyclerView.ViewHolder(binding.root)

fun setOnItemClickListener(listener: (Site) -> Unit) {
onItemClickListener = listener
}

}

View

MainActivity.kt -> Firstly, we set up our RecyclerView and SiteAdapter. After that, we collected the data from ViewModel and passed it to the adapter. Finally, we observed the load state with the help of the addLoadStateListener() method. When there is a change in the load state of the adapter, it notifies us. According to these states, we can change our UI status such as loading, displaying, or retrying.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

private val viewModel: MainViewModel by viewModels()
private lateinit var binding: ActivityMainBinding

private val siteAdapter by lazy {
SiteAdapter().apply {
setOnItemClickListener {
Toast.makeText(
this@MainActivity,
"Clicked Site Name : ${it.name}",
Toast.LENGTH_SHORT
).show()
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)

binding.recyclerSite.apply {
layoutManager = LinearLayoutManager(context)
adapter = siteAdapter
layoutManager = layoutManager
}

binding.retryButton.setOnClickListener { siteAdapter.retry() }

siteAdapter.addLoadStateListener { loadState ->

when (loadState.source.refresh) {
is LoadState.NotLoading -> {
binding.recyclerSite.isVisible = true
binding.progressBar.isVisible = false
binding.retryButton.isVisible = false
}
is LoadState.Loading -> {
binding.progressBar.isVisible = true
binding.retryButton.isVisible = false
}
is LoadState.Error -> {
binding.progressBar.isVisible = false
binding.retryButton.isVisible = true
}
}

val errorState = loadState.source.append as? LoadState.Error
?: loadState.append as? LoadState.Error
errorState?.let {
Toast.makeText(this, errorState.error.localizedMessage, Toast.LENGTH_SHORT).show()
}
}

lifecycleScope.launch {
viewModel.fetchSiteList().collectLatest { pagingData ->
viewModel.fetchSiteList().distinctUntilChanged().collectLatest {
siteAdapter.submitData(it)
}
}
}

}

Tips & Tricks

⚠While using the Flow, make sure you import correctly. Adding unambiguous imports on the fly in Android Studio can cause the adding of wrong imports.

⚠ Paging3 supports LiveData and RxJava beside Flow.

⚠Configuring your page size relies upon how your data is being loaded and used. Smaller page sizes improve memory usage, latency. Larger pages generally improve loading throughput.

Conclusion

In summary, we have developed a simple app that shows the nearby places of the user. We’ve used the Paging 3 library to make it easier to work with large sets of data and Huawei Site kit to get nearby places information of the users. Please do not hesitate to ask your questions as a comment.

Thank you for your time and dedication. I hope it was helpful. See you in other articles!

References

Paging 3 Official Documentation

Huawei Site Kit Official Documentation