In this blog we are going to see how we can display list of products fetched from an API and the product detail screen.
We are going to use Retrofit to make the api call. Add these dependencies to add retrofit to our project.
Follow the retrofit doc to add the dependencies – https://square.github.io/retrofit/
//Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.google.code.gson:gson:2.8.7'
Next add navigation component dependency. We will learn how to use the navigation component and integrate in our app. Refer the doc for more details – https://developer.android.com/guide/navigation
Project build.gradle
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
App build.gradle
plugins {
...
id 'androidx.navigation.safeargs.kotlin'
}
// Navigation component
def nav_version = "2.3.5"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
First let’s create the required activity, fragments and their layout files.
Create ProductActivity.kt with activity_product.xml file, this will have the container to host the navigation childs.
activity_product.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView 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"
android:id="@+id/nav_host_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="androidx.navigation.fragment.NavHostFragment"
app:defaultNavHost="true"
app:navGraph="@navigation/product_nav_graph"
tools:context=".ui.product.ProductActivity" />
Create two fragments one is for product listing that will be the landing screen when user logged in and another one is Product detail fragment. It will show the details of that product
Next step create a new navigation graph product_nav_graph.xml file.
Add the two fragments and set product listing as the startDestination.
Link the product listing fragment to product detail fragment. It will create an action which can be used to navigate to product detail fragment.
You can see the xml file below.
<?xml version="1.0" encoding="utf-8"?>
<navigation 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:id="@+id/product_nav_graph"
app:startDestination="@id/productListFragment">
<fragment
android:id="@+id/productListFragment"
android:name="com.aswin.androidpatterns.ui.product.productList.ProductListFragment"
android:label="Products"
tools:layout="@layout/product_list_fragment">
<action
android:id="@+id/action_productListFragment_to_productDetailFragment"
app:destination="@id/productDetailFragment"
app:popUpToInclusive="false" />
</fragment>
<fragment
android:id="@+id/productDetailFragment"
android:name="com.aswin.androidpatterns.ui.product.productDetail.ProductDetailFragment"
android:label="Product Detail"
tools:layout="@layout/product_detail_fragment">
</fragment>
</navigation>
From the list screen we need to pass the product detail via arguments. Nav args will take care of providing the arguments in the detail screen.
To know more details check out the documentation – https://developer.android.com/guide/navigation/navigation-pass-data
Update the nav graph as follows.
... <fragment
android:id="@+id/productDetailFragment"
...
<argument
android:name="productDetail"
app:argType="com.aswin.androidpatterns.models.ProductModel"
app:nullable="true" />
</fragment>...
ProductDetailFragment will take ProductModel as argument.
ProductModel.kt
class ProductModel(
val id: Int,
val title: String,
val description: String,
val image: String,
) : Parcelable {
constructor(parcel: Parcel) : this(
id = parcel.readInt(),
title = parcel.readString() ?: "",
description = parcel.readString() ?: "",
image = parcel.readString() ?: ""
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(id)
parcel.writeString(title)
parcel.writeString(description)
parcel.writeString(image)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<ProductModel> {
override fun createFromParcel(parcel: Parcel): ProductModel {
return ProductModel(parcel)
}
override fun newArray(size: Int): Array<ProductModel?> {
return arrayOfNulls(size)
}
}
}
Why should we use parcelable instead of serializable ?
Serializable
Serializable is a standard Java interface. You can just implement Serializable interface and add override methods. The problem with this approach is that reflection is used and it is a slow process. This method creates a lot of temporary objects and causes quite a bit of garbage collection. However, Serializable interface is easier to implement.
Parcelable
Parcelable process is much faster than Serializable. One of the reasons for this is that we are being explicit about the serialization process instead of using reflection to infer it. It also stands to reason that the code has been heavily optimized for this purpose.
In the product detail view let’s have an image, title and description of the product.
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:id="@+id/image"
android:contentDescription="@string/product_image"
android:layout_width="match_parent"
android:layout_height="150dp" />
<TextView
android:id="@+id/title_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextAppearance.AppCompat.Title"
android:textSize="24sp"
tools:text="Task title comes here" />
<TextView
android:id="@+id/description_tv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textSize="24sp"
tools:text="Task title comes here" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
Next step we need to integrate view and data binding. First let’s create a viewmodel for the detail screen. To know more about how to integrate view binding and data binding checkout the MVVM architecture .
ProductDetailViewModel.kt
@HiltViewModel
class ProductDetailViewModel @Inject constructor() : ViewModel() {
val productDetail = MutableLiveData<ProductModel>()
}
Now we need to initialize the viewmodel and bind it with the view. Add the following in the product detail fragment.
@AndroidEntryPoint
class ProductDetailFragment : Fragment() {
private val mViewModel: ProductDetailViewModel by viewModels()
private lateinit var mBinding: ProductDetailFragmentBinding
private val args: ProductDetailFragmentArgs by navArgs()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = ProductDetailFragmentBinding.inflate(inflater, container, false)
return mBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mBinding.lifecycleOwner = viewLifecycleOwner
mBinding.viewModel = mViewModel
mViewModel.productDetail.value = args.productDetail
}
}
Let’s bind the viewmodel with the views in the xml.
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".ui.product.productDetail.ProductDetailFragment">
<data>
<variable
name="viewModel"
type="com.aswin.androidpatterns.ui.product.productDetail.ProductDetailViewModel" />
</data>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:id="@+id/image"
setImageUrl="@{viewModel.productDetail.image}"
android:contentDescription="@string/product_image"
android:layout_width="match_parent"
android:layout_height="150dp" />
<TextView
android:id="@+id/title_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextAppearance.AppCompat.Title"
android:text="@{viewModel.productDetail.title}"
android:textSize="24sp"
tools:text="Task title comes here" />
<TextView
android:id="@+id/description_tv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@{viewModel.productDetail.description}"
android:textSize="24sp"
tools:text="Task title comes here" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</layout>
setImageUrl is a binding adapter which will get the image url as input and using glide the image will be updated in the imageView.
Let’s create a ImageBindingAdapter.kt file under bindingAdapters directory.
object ImageBindingAdapter {
@JvmStatic
@BindingAdapter("setImageUrl")
fun setImage(view: ImageView, imgUrl: String?) {
view.setImage(imgUrl)
}
}
Here view.setImage() is an extension function of the image view. This extension function will be responsible for using the glide to set the image from the url.
Let’s create a GlideExtension.kt file under utils.extensions directory.
GlideExtension.kt
fun ImageView.setImage(imgUrl: String?) {
if (!imgUrl.isNullOrEmpty()) {
Glide.with(this).load(imgUrl).into(this)
}
}
fun ImageView.clear() {
Glide.with(this).clear(this)
}
Add these dependencies in the gradle file to use glide.
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
That’s it we have created the detail screen and binded the product detail with the view.
Let’s move to product listing.
We need to fetch the products list from api and display the list in recycler view. We also need to show a loader till the api fetch is completed.
We are going to use retrofit to fetch the product list from the api.
For that first we need to set up the api layer. We have already added the required dependencies for retrofit at the beginning of this blog.
Now let’s initialize the retrofit and create a repository to fetch products.
RetrofitBuilder.kt
object RetrofitBuilder {
private const val BASE_URL = "https://fakestoreapi.com"
fun getRetrofit(): Retrofit {
val httpLoginIntercept =
HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)
val okHttpClient = OkHttpClient.Builder()
.readTimeout(120, TimeUnit.SECONDS)
.connectTimeout(120, TimeUnit.SECONDS)
.addInterceptor(httpLoginIntercept)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
}
Next we need to create an api service interface that provides the method to make the api call.
ProductApiService.kt
interface ProductApiService {
@GET("/products")
suspend fun getProducts(@Query("limit") limit: Int = 20): Response<ArrayList<ProductModel>>
}
Before creating a repository to use this api service, we need to think about how we can handle response and error in one place.
Kotlin provides a way to pass functions as arguments to another function.
Let’s create a BaseRepository.kt with a generic function that takes a function as input.
BaseRepository.kt
open class BaseRepository {
inline fun <T> makeRequest(request: () -> Response<T>): ApiResult<T> {
return try {
val response = request.invoke().body()
ApiResult.Success(response)
} catch (e: Exception) {
ApiResult.Error(e.hashCode(), e.message, e)
}
}
}
This function will return the ApiResult<T> of the required response type.
ApiResult.kt
sealed class ApiResult<out T> {
data class Success<out T>(val data: T?) : ApiResult<T>()
data class Error(val errorCode: Int, val errorMessage: String?, val exception: Exception) :
ApiResult<Nothing>()
}
Next we need to create a repository that uses this api service to make the api call. We need to inject the api service to the repository.
class ProductRepository @Inject constructor(private val productApiService: ProductApiService) :
BaseRepository(),
ProductRepositoryInterface {
override suspend fun getTaskList(): ApiResult<ArrayList<ProductModel>> {
return makeRequest { productApiService.getProducts() }
}
}
ProductRepositoryInterface will provide the method to call the api service.
interface ProductRepositoryInterface {
suspend fun getTaskList(): ApiResult<ArrayList<ProductModel>>
}
We are injecting the api service to the repository and we repository to the viewmodel.
We need to tell hilt how to create an instance of api service and a repository to inject.
If you are not familiar with Hilt DI please checkout the blog about android dependency injection Dependency Injection.
In the AppModule.kt file add these following code.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Singleton
@Provides
fun loginRepository(): LoginRepositoryInterface {
return LoginRepository()
}
@Singleton
@Provides
fun productRepository(productApiService: ProductApiService): ProductRepositoryInterface {
return ProductRepository(productApiService)
}
@Singleton
@Provides
fun retrofitProvider(): Retrofit {
return RetrofitBuilder.getRetrofit()
}
@Singleton
@Provides
fun productApiService(retrofit: Retrofit): ProductApiService {
return retrofit.create(ProductApiService::class.java)
}
}
That’s it we have completed the api layer setup. Now we need to create the view model and inject this repository.
@HiltViewModel
class ProductListViewModel @Inject constructor(val repository : ProductRepositoryInterface): ViewModel() {
val productListMLD = MutableLiveData<ArrayList<ProductModel>?>()
val isLoadingMLD = MutableLiveData(false)
val errorMLD = MutableLiveData<String?>()
init {
fetchProducts()
}
private fun fetchProducts() {
viewModelScope.launch {
isLoadingMLD.postValue(true)
when(val result = repository.getProductList()){
is ApiResult.Success ->{
productListMLD.postValue(result.data)
}
is ApiResult.Error -> {
errorMLD.postValue(result.errorMessage)
}
}
isLoadingMLD.postValue(false)
}
}
}
Next step create the view and bind the viewmodel.
<?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"
tools:context=".ui.product.productList.ProductListFragment">
<data>
<variable
name="viewMode"
type="com.aswin.androidpatterns.ui.product.productList.ProductListViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/product_list_rv"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:itemCount="5"
tools:listitem="@layout/view_product_list_item" />
<ProgressBar
android:id="@+id/progress_bar"
viewVisibility="@{viewMode.isLoadingMLD}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
To list the products in recyclerview we need a recyclerview adapter. Let’s create a recyclerview adapter and handle user item selection.
ProductListAdapterInterface.kt
interface ProductListAdapterInterface {
fun onTaskSelected(productModel: ProductModel)
}
view_product_list_item.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"
app:cardCornerRadius="6dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:id="@+id/image"
android:layout_width="match_parent"
android:layout_height="150dp" />
<TextView
android:id="@+id/title_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/black"
android:layout_marginTop="8dp"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Task title" />
<TextView
android:id="@+id/description_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
tools:text="Task description comes here" />
</LinearLayout>
</androidx.cardview.widget.CardView>
ProductListAdapter.kt
class ProductListAdapter(private val productListAdapterInterface: ProductListAdapterInterface) :
RecyclerView.Adapter<ProductListAdapter.ProductItemViewHolder>() {
private val productList = ArrayList<ProductModel>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductItemViewHolder {
val binding =
ViewProductListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ProductItemViewHolder(binding)
}
override fun onBindViewHolder(holder: ProductItemViewHolder, position: Int) {
holder.bind(position)
}
override fun getItemCount(): Int {
return productList.size
}
override fun onViewRecycled(holder: ProductItemViewHolder) { super.onViewRecycled(holder) holder.unBind() }
fun addTask(newProductList: ArrayList<ProductModel>) {
productList.clear()
productList.addAll(newProductList)
notifyDataSetChanged()
}
inner class ProductItemViewHolder(private val binding: ViewProductListItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(position: Int) {
val data = productList[position]
binding.image.setImage(data.image)
binding.titleTv.text = data.title
binding.descriptionTv.text = data.description
binding.root.setOnClickListener {
productListAdapterInterface.onTaskSelected(data)
}
}
fun unBind() {
binding.image.clear()
}
}
}
Now we need to initialize the adapter and viewmodel in the ProductListFragment.kt
ProductListFragment.kt
@AndroidEntryPoint
class ProductListFragment : Fragment(), ProductListAdapterInterface {
private lateinit var mBinding: ProductListFragmentBinding
private val mViewModel: ProductListViewModel by viewModels()
private lateinit var mAdapter: ProductListAdapter
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = ProductListFragmentBinding.inflate(inflater, container, false)
return mBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initView()
attachObservers()
}
private fun initView() {
mAdapter = ProductListAdapter(this)
mBinding.productListRv.adapter = mAdapter
}
private fun attachObservers() {
mViewModel.productListMLD.observe(viewLifecycleOwner) {
if (it != null) {
mAdapter.addTask(it)
}
}
mViewModel.errorMLD.observe(viewLifecycleOwner) {
if (it != null) {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
mViewModel.errorMLD.value = null
}
}
}
override fun onTaskSelected(productModel: ProductModel) {
val action =
ProductListFragmentDirections.actionProductListFragmentToProductDetailFragment(
productModel
)
findNavController().navigate(action)
}
}
Now we have the product list fragment and product detail fragment setup completed. Next we need to set up the navcontroller with the support action bar to display the title.
@AndroidEntryPoint
class ProductActivity : AppCompatActivity() {
private lateinit var navController: NavController
private val mViewModel: ProductActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_product)
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navController = navHostFragment.navController
setupActionBarWithNavController(navController)
}
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp() || super.onSupportNavigateUp()
}
}
Now you can run the app to see the output. The products will be fetched from the api and will be displayed in the list. On selecting the product it will be navigated to the detail screen.
Next step , let’s write some test unit and instrumented test cases.
To know more about testing in android check out this blog Testing.
That’s it we have completed the testing of product listing and product detail screen.
Hope you learned how to write a clean, testable api layer and how to use jetpack navigation component to display fragments in a feature module and also how to test fragments, activities and viewmodels.
Happy learning
Team Appmetry