How to create Expandable List in Jetpack Compose

Mrugendra Thatte
4 min readJun 14, 2023

--

If you are here you either are in same position as I was few months ago or you are just curious about things in Jetpack Compose. Few months ago after weeks of experimenting we started using Jetpack Compose on real feature screens.

The RecyclerView way

One of the biggest issue we faced with our current code was we have quite few expandable lists. You might have seem quite few expandable list in Android apps. Yet, there is no out of box solution from Android SDK. There are plenty libraries and custom solutions available in Android view system to achieve expandable lists.

One of the common solution to achieve this (and the one we followed) is using different item_type for headers and items in ListAdapter.

Now showing section headers and items is straight forward, but logic for expansion and collapsing of the items, is quite an expensive and complex operation.

The Compose way

Now let’s try to replicate this with Jetpack Compose.

Lets first create simple data structure to represent our section data such as header and items

data class SectionData(val headerText: String, val items: List<String>)

Now that we have a structure representation of single section data, let’s create reusable composable for header view and item view.

Item view

@Composable
fun SectionItem(text: String) {
Text(
text = text,
style = MaterialTheme.typography.subtitle1,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
)
}

Header View

While creating composable for header view, we have added couple more parameters than the data text.

isExpanded : to identify if the section is currently expanded or not, so that we can show correct icon.

onHeaderClicked : This is a callback when section header is clicked

@Composable
fun SectionHeader(text: String, isExpanded: Boolean, onHeaderClicked: () -> Unit) {
Row(modifier = Modifier
.clickable { onHeaderClicked() }
.background(Color.LightGray)
.padding(vertical = 8.dp, horizontal = 16.dp)) {
Text(
text = text,
style = MaterialTheme.typography.h6,
modifier = Modifier.weight(1.0f)
)
if (isExpanded) {
ExpandedCheveronIcon()
} else {
CollapsedCheveronIcon()
}
}
}

Expandable List

Now that we have reusable Header and Item view composable, Its time to use them to expandable list ( or Expandable LazyColumn as its called in Compose).

Firstly, we need to make sure we can store the expansion state of each section. So we use rememberSavable() with custom map saver (lets ignore the custom saver for now). This makes sure stored value will survive the activity or process recreation using the saved instance state and do not reset to default values.

@Composable
fun ExpandableList(sections: List<SectionData>) {
val isExpandedMap = rememberSavableSnapshotStateMap {
List(sections.size) { index: Int -> index to true }
.toMutableStateMap()
}

LazyColumn(
content = {
sections.onEachIndexed { index, sectionData ->
Section(
sectionData = sectionData,
isExpanded = isExpandedMap[index] ?: true,
onHeaderClick = {
isExpandedMap[index] = !(isExpandedMap[index] ?: true)
}
)
}
}
)
}

LazyColumn looks very similar to normal list implementation, with small exception — instead of using items{ } directly to render item composable, we will iterate over sections and call Section composable with each sectionData and expansion state for that section.
isExpanded parameter specifies the section expansion state.
onHeaderClick this is very important callback. When this callback is executed, we toggle the expansion state of that section using index.

Section

Section is an extension function on LazyListScope , which takes care of rendering one section.
The reason Section() is an extension function on LazyListScope is that, we need LazyListScope to be able to render items with item{} or items(list){ }.

Now lets add section header view for section using SectionHeader composable we previously created and pass required data.
We call SectionHeader in item{ } which creates single item in LazyList.

fun LazyListScope.Section(
sectionData: SectionData,
isExpanded: Boolean,
onHeaderClick: () -> Unit
) {

item {
SectionHeader(
text = sectionData.headerText,
isExpanded = isExpanded,
onHeaderClicked = onHeaderClick
)
}
}

Now that we have added header view, its time to add items of the section using items(sectionData.items){ } method. This method iterates all items in the sectionData.items and renders SectionItem view.

fun LazyListScope.Section(
sectionData: SectionData,
isExpanded: Boolean,
onHeaderClick: () -> Unit
) {

item {
SectionHeader(
text = sectionData.headerText,
isExpanded = isExpanded,
onHeaderClicked = onHeaderClick
)
}

items(sectionData.items) {
SectionItem(text = it)
}
}

Now these items will show up all the time no matter if section is expanded or not.

But we want it to collapse and expand the section when header is clicked.

This is where Jetpack Compose shines over traditional RecyclerView and RecyclerView.Adapter.

Hiding the items when collapsed and showing them when expanded is as easy as adding an if(){} statement.

fun LazyListScope.Section(
sectionData: SectionData,
isExpanded: Boolean,
onHeaderClick: () -> Unit
) {

item {
SectionHeader(
text = sectionData.headerText,
isExpanded = isExpanded,
onHeaderClicked = onHeaderClick
)
}

if(isExpanded) {
items(sectionData.items) {
SectionItem(text = it)
}
}
}

With this simple addition, when the section is expanded, items will be rendered, and when when section is collapsed (i.e. isExpanded = false ) Section composable will be recomposed and items will not be rendered.

And we have our Expandable and collapsible list.

Next Topics?

  1. Expand and Collapse Animations
  2. Add Pull to refresh
  3. Make it reusable with generics

--

--

Mrugendra Thatte

Android Platform Lead — Mobile | AR/VR Enthusiast | Photography & Drone Enthusiast | Leadership | Australia