loading...

ExpandableRecyclerView: Expandable Items With Room Database in Kotlin

kulloveth profile image Loveth Nwokike Updated on ・9 min read

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.

ParentView
Parent with child

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'
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
)

Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()
}}}
Enter fullscreen mode Exit fullscreen mode

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")
            )}}}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
    }

}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()


}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
            } })}}


Enter fullscreen mode Exit fullscreen mode

finally the main layout and its activity that Observes the data from the MainActivityViewModel, adds to a new list and displays on the recyclerView.

Final Result

kindly leave your Feedback and suggestions in the comment below TWITTER

GitHub logo kulloveth / ExpandableRecyclerViewSampleWithRoom

A project showing the continents of the world with some of there countries to demonstrate Expadable RecyclerView with ROOM

ExpandableRecyclerViewSampleWithRoom

A project showing the continents of the world with some of there countries to demonstrate Expandable RecyclerView with ROOM

Discussion

pic
Editor guide