How to make Expandable List with Jetpack Compose

Last updated on: February 16, 2024

In this tutorial, I’ll show you how to make an expandable list using Jetpack Compose.

Each row contains two composable views: a header (HeaderView), and an expandable view (ExpandableView).

The user taps the HeaderView, and the ExpandableView expands or collapses according to its previous state.

When we tap an item, we store its index in a list and update the UI accordingly using Kotlin Coroutines Flow.

Creating the ViewModel

Let’s begin by creating the view model, where we’ll put the business logic.

We’ll create two methods—one for getting the demo data and another for storing or deleting the ids from the list—when we expand or collapse an item.

Right-click on the project name folder > New > Kotlin Class/File

Creating the view model

We choose Class, and we name it ExpandableListViewModel

Naming the view model

Next, we extend the ViewModel class

import androidx.lifecycle.ViewModel

class ExpandableListViewModel : ViewModel() {
    
    // ...
    
}Code language: Swift (swift)

After that, we create a new method called getData(), which gets the data. In this example, we have an array list of demo data stored using a model.

class ExpandableListViewModel : ViewModel() {

    private val itemsList = MutableStateFlow(listOf<DataModel>())
    val items: StateFlow<List<DataModel>> get() = itemsList


    private fun getData() {
        viewModelScope.launch {
            withContext(Dispatchers.Default) {
                itemsList.emit(Data.items)
            }
        }
    }

}Code language: Kotlin (kotlin)

data class DataModel(val question: String, val answer: String)

Code language: Kotlin (kotlin)
object Data {
    var items = arrayListOf(
        DataModel(
            "Item 0",
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
        ),
        DataModel(
            "Item 1",
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam"
        ),
        DataModel(
            "Item 2",
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut"
        ),
        DataModel(
            "Item 3",
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
        ),
        DataModel(
            "Item 4",
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
        ),
        DataModel(
            "Item 5",
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
        ),
        // ...
    )
}Code language: Kotlin (kotlin)

We call this method in the constructor, so whenever the screen appears, we load the data.

class ExpandableListViewModel : ViewModel() {

    // ...
    
    init {
        getData()
    }

    private fun getData() {
        // ...
    }

}Code language: Kotlin (kotlin)

Lastly, we create another method for when we tap the view. This method will check if the id exists in the itemIdsList; if not, it will be added.

class ExpandableListViewModel : ViewModel() {

    // ...

    private val itemIdsList = MutableStateFlow(listOf<Int>())
    val itemIds: StateFlow<List<Int>> get() = itemIdsList

    init {
        getData()
    }

    private fun getData() {
        // ...
    }


    fun onItemClicked(itemId: Int) {
        itemIdsList.value = itemIdsList.value.toMutableList().also { list ->
            if (list.contains(itemId)) {
                list.remove(itemId)
            } else {
                list.add(itemId)
            }
        }
    }

}Code language: Kotlin (kotlin)

So, in the end, our ViewModel will look like this:

class ExpandableListViewModel : ViewModel() {

    private val itemsList = MutableStateFlow(listOf<DataModel>())
    val items: StateFlow<List<DataModel>> get() = itemsList

    private val itemIdsList = MutableStateFlow(listOf<Int>())
    val itemIds: StateFlow<List<Int>> get() = itemIdsList

    init {
        getData()
    }

    private fun getData() {
        viewModelScope.launch {
            withContext(Dispatchers.Default) {
                itemsList.emit(Data.items)
            }
        }
    }


    fun onItemClicked(itemId: Int) {
        itemIdsList.value = itemIdsList.value.toMutableList().also { list ->
            if (list.contains(itemId)) {
                list.remove(itemId)
            } else {
                list.add(itemId)
            }
        }
    }

}Code language: Kotlin (kotlin)
Here's what the file looks like once we've added all the business logic code for the expandable list

Making the Expandable List UI

Let’s begin by making the view that we tap to expand or collapse the expandable view.

It’s a clickable Box with Text in it.

// ...

@Composable
fun HeaderView(questionText: String, onClickItem: () -> Unit) {
    Box(
        modifier = Modifier
            .background(colorResource(R.color.colorPrimary))
            .clickable(
                indication = null, // Removes the ripple effect on tap
                interactionSource = remember { MutableInteractionSource() }, // Removes the ripple effect on tap
                onClick = onClickItem
            )
            .padding(8.dp)
    ) {
        Text(
            text = questionText,
            fontSize = 17.sp,
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth()
        )
    }
}


@Preview(showBackground = true)
@Composable
fun HeaderViewPreview() {
    HeaderView("Question") {}
}Code language: Kotlin (kotlin)
Creating the header view for the expandable list

Next, we create the expandable view.

We create an EnterTransition that expands the view with 300 ms duration, and we apply a fade-in effect to make the transition look cooler.

Then we do an ExitTransition for the collapsing animation.

Lastly, we use the AnimatedVisibility to add the transitions to the view and check whether we should hide it or not with the isExpanded parameter.

// ...

@Composable
fun ExpandableView(answerText: String, isExpanded: Boolean) {
    // Opening Animation
    val expandTransition = remember {
        expandVertically(
            expandFrom = Alignment.Top,
            animationSpec = tween(300)
        ) + fadeIn(
            animationSpec = tween(300)
        )
    }

    // Closing Animation
    val collapseTransition = remember {
        shrinkVertically(
            shrinkTowards = Alignment.Top,
            animationSpec = tween(300)
        ) + fadeOut(
            animationSpec = tween(300)
        )
    }

    AnimatedVisibility(
        visible = isExpanded,
        enter = expandTransition,
        exit = collapseTransition
    ) {
        Box(
            modifier = Modifier
                .background(colorResource(R.color.colorPrimaryDark))
                .padding(15.dp)
        ) {
            Text(
                text = answerText,
                fontSize = 16.sp,
                color = Color.White,
                modifier = Modifier
                    .fillMaxWidth()
            )
        }
    }
}


@Preview(showBackground = true)
@Composable
fun ExpandableViewPreview() {
    ExpandableView("Answer", true)
}Code language: Kotlin (kotlin)
Creating the expandable view for the expandable list

Now, let’s put them together using a Column

@Composable
fun ExpandableContainerView(itemModel: DataModel, onClickItem: () -> Unit, expanded: Boolean) {
    Box(
        modifier = Modifier
            .background(colorResource(R.color.colorPrimaryDark))
    ) {
        Column {
            HeaderView(questionText = itemModel.question, onClickItem = onClickItem)
            ExpandableView(answerText = itemModel.answer, isExpanded = expanded)
        }
    }
}


@Preview(showBackground = true)
@Composable
fun ExpandableContainerViewPreview() {
    ExpandableContainerView(
        itemModel = DataModel("Question", "Answer"),
        onClickItem = {},
        expanded = true
    )
}Code language: Kotlin (kotlin)
Combining the views to create the expandable row

Next, we initialize the view model, which we pass to the composable function MainScreen

class MainActivity : ComponentActivity() {

    private val viewModel by viewModels<ExpandableListViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen(viewModel)
        }
    }
}


@Composable
fun MainScreen(viewModel: ExpandableListViewModel) {
    Scaffold(
        topBar = { TopBar() }
    ) { padding ->  // We need to pass scaffold's inner padding to the content
        // ...
    }
}


@Preview(showBackground = true)
@Composable
fun MainScreenPreview() {
    val viewModel = ExpandableListViewModel()
    MainScreen(viewModel = viewModel)
}


@Composable
fun TopBar() {
    TopAppBar(
        title = { Text(text = stringResource(R.string.app_name), fontSize = 18.sp) },
        backgroundColor = colorResource(id = R.color.colorPrimary),
        contentColor = Color.White
    )
}


@Preview(showBackground = false)
@Composable
fun TopBarPreview() {
    TopBar()
}Code language: Kotlin (kotlin)
Initializing the view model in the composable view

Finally, we use LazyColumn to create the list, and we loop the list using itemsIndexed to pass the index as an item id.

Finally, we use the LazyColumn composable function for the list, and we loop through the list using the itemsIndexed because we want to pass the index as an item id.

@Composable
fun MainScreen(viewModel: ExpandableListViewModel) {

    val itemIds by viewModel.itemIds.collectAsState()

    Scaffold(
        topBar = { TopBar() }
    ) { padding ->  // We need to pass scaffold's inner padding to the content
        LazyColumn(modifier = Modifier.padding(padding)) {
            itemsIndexed(viewModel.items.value) { index, item ->
                ExpandableContainerView(
                    itemModel = item,
                    onClickItem = { viewModel.onItemClicked(index) },
                    expanded = itemIds.contains(index)
                )
            }
        }
    }
}Code language: Kotlin (kotlin)
Creating the expandable list using LazyColumn and itemsIndexed

Note: If you run the app on an emulator or device, you might notice some lagging when you scroll. This is normal. When you run a debug version of a compose app, the performance might be slower. This is because Compose translates bytecode in runtime using JIT. Make sure you’re also using the R8 compiler in the release build. That’s extremely important to improve general performance.

You can find the final project here

If you have any questionsplease feel free to leave a comment below

Subscribe
Notify of
guest
1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments