Sometimes we want to display an extra content about the list items which does not necessarily need a separate screen usually called the detail screen,this is where the Expandable RecyclerView comes in, We are going to be learning how to create an expandable recyclerview using the THOUGHBOT
expandable recyclerview library. We will also be fetching our items from local database using Room Persistence Library
which is part of the Android Architecture Components
We will be displaying the list of continents and some of the countries under them from local database which is added only once when the database is created, the end result should look like the below images.
After you create a new project with empty activity add the following dependencies to your app level build.gradle
// Room components
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
implementation "androidx.room:room-ktx:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"
//Gson
implementation 'com.google.code.gson:gson:2.8.6'
// Lifecycle components
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion"
// Kotlin components
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"
// Material design
implementation "com.google.android.material:material:$rootProject.materialVersion"
//Expandable
implementation 'com.thoughtbot:expandablerecyclerview:1.3'
implementation 'com.thoughtbot:expandablecheckrecyclerview:1.4'
Also add the versions to the project level build.gradle
ext {
roomVersion = '2.2.5'
archLifecycleVersion = '2.2.0'
coreTestingVersion = '2.1.0'
materialVersion = '1.1.0'
coroutines = '1.3.4'
}
make sure the following plugins are present at the top of the app level build. gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
There are three major classes required while working with Room,the Entity
class which represents a table in the database,the DAO
class just as its name implies a data access object which contains the methods used for accessing the database, the database
class.
ContinentEntity.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
@Entity(tableName = "continent-table")
@TypeConverters(ContinentConverter::class)
data class ContinentEntity
(@PrimaryKey @ColumnInfo(name = "continent")
val continentName: String, val countrys: List<Country>
)
This class has the @Entity annotation passing the table name to be created into its parameter which is optional if you don't want the class name to be used as the table name, @ColumnInfo tells the database to use continent as the column name instead of continentName variable and @PrimaryKey which all tables must have. Also notice the @TypeConverters which is the annotation that tells room to convert the List with the ContinentConverter class
ContinentConverter.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data.model
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.lang.reflect.Type
import java.util.*
class ContinentConverter {
companion object {
var gson: Gson = Gson()
@TypeConverter
@JvmStatic
fun stringToSomeObjectList(data: String?): List<Country> {
val listType: Type =
object : TypeToken<List<Country?>?>() {}.getType()
return gson.fromJson(data, listType)
}
@TypeConverter
@JvmStatic
fun someObjectListToString(someObjects: List<Country>?): String {
return gson.toJson(someObjects)
}
}
}
This is the converter class that has @TypeConverter on each method performing the conversion with Gson library
ContinentDao.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data.db
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.ContinentEntity
import
com.developer.kulloveth.expandablelistsamplewithroom.data.model.Continents
@Dao
interface ContinentDao {
@Query("SELECT * from `continent-table` ORDER BY continent ASC")
fun getAllContinent(): LiveData<List<ContinentEntity>>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(continent: ContinentEntity)
}
This is the dao interface for accessing the database, getAllContinent method has the @Query annotation that fetches all data in ascending order, it returns a LiveData
which helps keep the data updated and automatically runs the operation asynchronously on background thread. The insert method has the @Insert annotation that inserts data taking care of conflicts that might occur, it uses the suspend function to indicate that the method requires time to execute since we don't want to block the main thread.
ContinentDatabase.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import com.developer.kulloveth.expandablelistsamplewithroom.data.DataGenerator
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.ContinentEntity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Database(entities = [ContinentEntity::class], version = 1, exportSchema = false)
abstract class ContinentDatabase : RoomDatabase() {
abstract fun continentDao(): ContinentDao
companion object {
@Volatile
private var INSTANCE: ContinentDatabase? = null
fun getDatabase(context: Context, scope: CoroutineScope): ContinentDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: buildDatabase(context, scope).also {
INSTANCE = it
}
}
}
private fun buildDatabase(context: Context, scope: CoroutineScope): ContinentDatabase {
return Room.databaseBuilder(context, ContinentDatabase::class.java, "place_db")
.addCallback(object : RoomDatabase.Callback()
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
scope.launch {
INSTANCE?.let {
for (continent: ContinentEntity in DataGenerator.getContinents()) {
it.continentDao().insert(
ContinentEntity(
continent.continentName,
continent.countrys
) )
}}}}}).build()
}}}
This is the database class which must be an abstract class and must contain an abstract method representing the dao interface class,it has @Database with the its entity, version and set export-schema to false since we are not exporting the database into a folder. The getDatabase method is a singleton which ensures that only one instance of the database is open at any time, we also added a roomCallback to insert the data only once when the room is created using its onCreate method. notice that the insert method is called within a coroutine scope since its a suspend function to ensure the operation is performed on the background thread.
DataGenerator.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.ContinentEntity
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.Country
class DataGenerator {
companion object {
fun getContinents(): List<ContinentEntity> {
return listOf(
ContinentEntity("Europe", europeCountrys()),
ContinentEntity("Africa", africaCountrys()),
ContinentEntity("Asia", asiaCountrys()),
ContinentEntity("North America", northAmericaCountrys()),
ContinentEntity("South America", southAmericaCountrys()),
ContinentEntity("Antarctica", antarcticaCountrys()),
ContinentEntity("Oceania", oceaniaCountrys())
)
}
fun europeCountrys(): List<Country> {
return listOf(
Country("Germany"),
Country("Italy"),
Country("France"),
Country("United Kingdom"),
Country("NertherLand")
)
}
fun africaCountrys(): List<Country> {
return listOf(
Country("South Africa"),
Country("Nigeria"),
Country("Kenya"),
Country("Ghana"),
Country("Ethiopia")
)
}
fun asiaCountrys(): List<Country> {
return listOf(
Country("Japan"),
Country("India"),
Country("Indonesi"),
Country("China"),
Country("Thailand")
)
}
fun northAmericaCountrys(): List<Country> {
return listOf(
Country("United States"),
Country("Mexico"),
Country("Cuba"),
Country("Green Land")
)
}
fun southAmericaCountrys(): List<Country> {
return listOf(
Country("Brazil"),
Country("Argentina"),
Country("Columbia"),
Country("Peru"),
Country("Chile")
)}
fun antarcticaCountrys(): List<Country> {
return listOf(
Country("Esperenza Base"),
Country("Villa az Estrellaz"),
Country("General Bernando O'Higging"),
Country("Bellgrano II base"),
Country("Carlini Base") )}
fun oceaniaCountrys(): List<Country> {
return listOf(
Country("Australia"),
Country("Newzeland"),
Country("Fiji"),
Country("Samao"),
Country("Federated States")
)}}}
Next we are going to be creating the Adapter, its data class,observe the data we added to Room and setup the recyclerView.
Continent.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data.model
import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup
data class Continent(
val continentName: String, val countries: List<Country>
): ExpandableGroup<Country>(continentName, countries)
Country.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data.model
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.android.parcel.Parcelize
@Parcelize
data class Country(val countryName: String) : Parcelable
Continent class is the Parent class to be used with the adapter which will be extending the ExpandableGroup passing the child class Country
continent_layout.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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="0dp"
app:cardUseCompatPadding="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/arrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_arrow_drop_down_black_24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/continent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="?listPreferredItemPaddingLeft"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
countrys_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="50dp"
android:padding="0dp">
<TextView
android:id="@+id/countryName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
tools:text="Niger" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/black" />
</androidx.cardview.widget.CardView>
The above layouts are the parent and child layout for the items to be referenced in their respective viewholder
MainViewHolder.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.developer.kulloveth.expandablelistsamplewithroom.R
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.Continent
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.Country
import com.thoughtbot.expandablerecyclerview.viewholders.ChildViewHolder
import com.thoughtbot.expandablerecyclerview.viewholders.GroupViewHolder
class CountryViewHolder(itemView: View) : ChildViewHolder(itemView) {
val countryName = itemView.findViewById<TextView>(R.id.countryName)
fun bind(country: Country) {
countryName.text = country.countryName
}
}
class ContinentViewHolder(itemView: View) : GroupViewHolder(itemView) {
val continentName = itemView.findViewById<TextView>(R.id.continent)
val arrow = itemView.findViewById<ImageView>(R.id.arrow)
fun bind(continent: Continent) {
continentName.text = continent.continentName
}
}
The MainViewHolder is a kotlin file containing both the parent viewholder and child viewholder
ContinentAdapter.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import com.developer.kulloveth.expandablelistsamplewithroom.R
import com.developer.kulloveth.expandablelistsamplewithroom.data.ContinentViewHolder
import com.developer.kulloveth.expandablelistsamplewithroom.data.CountryViewHolder
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.Continent
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.Country
import com.thoughtbot.expandablerecyclerview.ExpandableRecyclerViewAdapter
import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup
class ContinentAdapter(groups: List<ExpandableGroup<*>>?) :
ExpandableRecyclerViewAdapter<ContinentViewHolder, CountryViewHolder>(
groups
) {
override fun onCreateGroupViewHolder(parent: ViewGroup?, viewType: Int): ContinentViewHolder {
val itemView =
LayoutInflater.from(parent?.context).inflate(R.layout.continent_layout, parent, false)
return ContinentViewHolder(itemView)
}
override fun onCreateChildViewHolder(parent: ViewGroup?, viewType: Int): CountryViewHolder {
val itemView =
LayoutInflater.from(parent?.context).inflate(R.layout.countrys_layout, parent, false)
return CountryViewHolder(itemView)
}
override fun onBindChildViewHolder(
holder: CountryViewHolder?,
flatPosition: Int,
group: ExpandableGroup<*>?,
childIndex: Int
) {
val country: Country = group?.items?.get(childIndex) as Country
holder?.bind(country)
}
override fun onBindGroupViewHolder(
holder: ContinentViewHolder?,
flatPosition: Int,
group: ExpandableGroup<*>?
) {
val continent: Continent = group as Continent
holder?.bind(continent)
}
}
The adapter class accepts a List of ExpandableGroup type extending ExpandableAdapter with the parent and child viewholder respectively,Both parent and child has its own oncreateViewHolder and onBindViewHolder
Repository.kt
package com.developer.kulloveth.expandablelistsamplewithroom.data.model
import androidx.lifecycle.LiveData
import com.developer.kulloveth.expandablelistsamplewithroom.data.db.ContinentDao
class Repository(continentDao: ContinentDao) {
val allContinents: LiveData<List<ContinentEntity>> = continentDao.getAllContinent()
}
MainActivityViewModel.kt
package com.developer.kulloveth.expandablelistsamplewithroom.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.developer.kulloveth.expandablelistsamplewithroom.data.db.ContinentDatabase
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.ContinentEntity
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.Repository
class MainActivityViewModel(application: Application) : AndroidViewModel(application) {
private val repository: Repository
val continents: LiveData<List<ContinentEntity>>
init {
val continentDao = ContinentDatabase.getDatabase(application, viewModelScope).continentDao()
repository = Repository(continentDao)
continents = repository.allContinents
}
}
The repository pattern helps to separate business logic from the UI logic which is most useful when you are fetching data from different sources. The viewmodel class provides data to the UI and also survives configuration changes
activity_main.xml
<?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"
tools:context=".data.ui.MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="0dp"
android:id="@+id/rvConinent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt
package com.developer.kulloveth.expandablelistsamplewithroom.ui
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.developer.kulloveth.expandablelistsamplewithroom.R
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.ContinentEntity
import com.developer.kulloveth.expandablelistsamplewithroom.data.model.Continent
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
lateinit var viewModel: MainActivityViewModel
val continents = ArrayList<Continent>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewModel = ViewModelProvider(this)[MainActivityViewModel::class.java]
viewModel.continents.observe(this, Observer {
for (continentEntity: ContinentEntity in it) {
val continent = Continent(continentEntity.continentName, continentEntity.countrys)
continents.add(continent)}
val adapter = ContinentAdapter(continents)
rvConinent.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
rvConinent.adapter = adapter
} })}}
finally the main layout and its activity that Observes the data from the MainActivityViewModel, adds to a new list and displays on the recyclerView.
kindly leave your Feedback and suggestions in the comment below TWITTER
Top comments (0)