Android ViewModel + DataBinding + Retrofit + RecyclerView Example

Thomas Han
Rakulee
Published in
4 min readJan 11, 2022

This article is for educational purposes.

Thanks to Android Jetpack Libraries, we can present data with perfect sync. ViewModel always monitoring data and Views don’t need to know about the relationship between data and view. This article will show you the approach of this example and code snippet.

Note that, In the ViewModel, we should not save the context reference(Fragment, Activity, and Views), but the application context.

The purpose of ViewModel is to separate Data/View/Logic

Lifecycle of ViewModel

Simple Android example app of using ViewModel + Databinding + Retrofit + RecyclerView.

This sample code shows some crypto exchanges from CoinGecko API.
The Coingekco API is public. Here is the CoinGecko base URL.
https://api.coingecko.com/api/v3/

KTX extension has been applied in this example.

What do we need?

  1. Internet Permission in menifest.xml file.
  2. Gradle dependencies — Retrofit, GSON converter, ViewModel, Glide, and so on.
  3. The Base API & End Point for the data source.
  4. A Pojo data class for parsing data from the API server.
  5. RecyclerView
  6. A RecyclerView Adapter to update data.

The flow logic is simple.

  1. Make an API Call
  2. Receive data and save it to a ViewModel.
  3. ViewModel observer notifies the recyclerview adapter the dataset has been changed.

Note — For BaseURL, you may create a “Configs file” or directly put hard-coded baseURL.

Here is the full source code:

https://github.com/rakuleethomas/RetrofitExample

Demo Screen

Build.Gradle(App-Level)

plugins {
id 'com.android.application'
id 'kotlin-android'
}

android {
compileSdkVersion 32
buildToolsVersion "32.0.0"
apply plugin: 'kotlin-kapt'

defaultConfig {
applicationId "org.rakulee.retrofitexample"
minSdkVersion 21
targetSdkVersion 32
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}

buildFeatures {
dataBinding true
}
}

dependencies {

implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "androidx.core:core-ktx:1.7.0"
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
/** Retrofit */
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

/** Android ViewModel + Lifecycle KTX + LiveData */
//ViewModel
implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.4.0"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.4.0'
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0"

/** Glide */
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
}

Setup Retrofit:

// init retrofit
val retrofit = Retrofit.Builder()
.baseUrl(Configs.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()

val api = retrofit.create(CoinGeckoExchangeListApi::class.java)
val getExchangeCall : Call<ArrayList<ExchangeResult.ExchangeItem>> = api.getExchangeLists(50, 1)

Setup RecyclerView Adapter on MainActivity:

// setup recyclerview adapter
val adapter = RvExchangeAdapter()
binding.rvList.adapter = adapter
binding.rvList.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))

RecyclerView Adapter File

class RvExchangeAdapter : RecyclerView.Adapter<RvExchangeAdapter.ViewHolder>() {
var lists = ArrayList<ExchangeResult.ExchangeItem>()

fun update(lists : ArrayList<ExchangeResult.ExchangeItem>) {
this.lists = lists
notifyDataSetChanged()
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_exchange_list, parent, false)
return ViewHolder(view)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(lists[position])
}

override fun getItemCount(): Int {
return lists.size
}

override fun onViewAttachedToWindow(holder: ViewHolder) {
super.onViewAttachedToWindow(holder)
}

inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val binding = ItemExchangeListBinding.bind(itemView)
fun bind(item : ExchangeResult.ExchangeItem){
binding.item = item
Glide.with(itemView.context).asBitmap().load(item.image).into(binding.ivExchangeLogo)
}
}
}

MainActivity:

class MainActivity : AppCompatActivity() {

lateinit var binding : ActivityMainBinding
val viewModel : TestViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.lifecycleOwner = this

// init retrofit
val retrofit = Retrofit.Builder()
.baseUrl(Configs.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()

val api = retrofit.create(CoinGeckoExchangeListApi::class.java)
val getExchangeCall : Call<ArrayList<ExchangeResult.ExchangeItem>> = api.getExchangeLists(50, 1)

/**
* RecyclerVIew Adapter Setup
*/
val adapter = RvExchangeAdapter()
binding.rvList.adapter = adapter
binding.rvList.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))

/**
* Loading Progress Bar
*/
binding.isLoading = true

getExchangeCall.enqueue(object : Callback<ArrayList<ExchangeResult.ExchangeItem>> {

override fun onResponse(
call: Call<ArrayList<ExchangeResult.ExchangeItem>>,
response: Response<ArrayList<ExchangeResult.ExchangeItem>>
) {
Log.d("Response", "onResponse: ${response.body()?.get(0)?.name}")
binding.isLoading = false
viewModel.updateData(response.body()!!)
}

override fun onFailure(
call: Call<ArrayList<ExchangeResult.ExchangeItem>>,
t: Throwable
) {
binding.isLoading = false
t.printStackTrace()
}
})

/**
* The viewModel observer watches the dataset changes.
* Once dataset modification is detected, it will update
*/
viewModel.data.observe(this, {
adapter.update(it)
})
}
}

ViewModel:

class TestViewModel : ViewModel() {

private val _data : MutableLiveData<ArrayList<ExchangeResult.ExchangeItem>> by lazy {
MutableLiveData<ArrayList<ExchangeResult.ExchangeItem>>()
}
val data : MutableLiveData<ArrayList<ExchangeResult.ExchangeItem>> get() = _data

fun updateData(list : ArrayList<ExchangeResult.ExchangeItem>){
_data.postValue(list)
}
}

Pojo Class:

import com.google.gson.annotations.SerializedName

class ExchangeResult : ArrayList<ExchangeResult.ExchangeItem>(){
data class ExchangeItem(
@SerializedName("country")
val country: String?,
@SerializedName("description")
val description: String?,
@SerializedName("has_trading_incentive")
val hasTradingIncentive: Boolean?,
@SerializedName("id")
val id: String,
@SerializedName("image")
val image: String,
@SerializedName("name")
val name: String,
@SerializedName("trade_volume_24h_btc")
val tradeVolume24hBtc: Double,
@SerializedName("trade_volume_24h_btc_normalized")
val tradeVolume24hBtcNormalized: Double,
@SerializedName("trust_score")
val trustScore: Int,
@SerializedName("trust_score_rank")
val trustScoreRank: Int,
@SerializedName("url")
val url: String,
@SerializedName("year_established")
val yearEstablished: Int?
)
}

Main Layout XML File:

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

<data>
<variable
name="isLoading"
type="Boolean" />
<import type="android.view.View"/>
</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_exchange_list"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/pb_loading"
android:layout_height="0dp"
android:layout_width="0dp"
app:layout_constraintWidth_percent="0.2"
app:layout_constraintDimensionRatio="W, 1:1"
android:indeterminate="true"
android:elevation="2dp"
style="?android:attr/progressBarStyle"
android:backgroundTint="@color/design_default_color_primary"
android:visibility="@{isLoading? View.VISIBLE:View.GONE}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

RecyclerView Item Layout XML File:

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

<data>

<variable
name="viewModel"
type="org.rakulee.retrofitexample.TestViewModel" />

<variable
name="item"
type="org.rakulee.retrofitexample.ExchangeResult.ExchangeItem" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="64dp">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">


<androidx.constraintlayout.utils.widget.ImageFilterView
android:id="@+id/iv_exchange_logo"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_launcher_foreground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H, 1:1"
app:layout_constraintEnd_toStartOf="@+id/guideline3"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

</androidx.constraintlayout.utils.widget.ImageFilterView>

<com.google.android.material.textview.MaterialTextView
android:id="@+id/tv_exchange_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{item.name}"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/guideline3"
app:layout_constraintTop_toTopOf="parent"
tools:text="Exchange">

</com.google.android.material.textview.MaterialTextView>


<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.25" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

I hope this example helps those who are learning Android Jetpack Library.

Thank you for reading this article!

Rakulee, Inc. is a 501(c)(3) scientific, education, and charitable organization to assist college/university students. All your donation is tax-deductible. Please support.rakulee.org

--

--