Building a Custom Animated Expandable/Collapsed List Component with Jetpack Compose Using LazyColumn

Ramadan Sayed
5 min readSep 27, 2024

--

One common feature in many applications is an expandable list, where users can click on an item to show more information. This article will guide you through building an animated expandable list using LazyColumn in Jetpack Compose. We'll break down each component of the code, explore how state management works, and create a smooth, interactive experience.

Overview

To create an animated expandable list, we need the following:

  1. LazyColumn: A scrollable list layout that displays items.
  2. State Management: To track whether each item is expanded or collapsed.
  3. Animation: To add smooth transitions for expanding and collapsing items.

Let’s start by breaking down the code and explaining each part before putting it all together in the final solution.

Code Breakdown

1: Main Composable Function: AnimateExpandableList

The AnimateExpandableList function is responsible for setting up the entire expandable list and managing the items' state.


data class ExpandableItem(
val title: String,
var isExpanded: Boolean = false
)

@Composable
fun AnimateExpandableList() {
val items = remember { List(20) { index -> ExpandableItem("Item #$index") } }
val expandedStates = remember { mutableStateListOf(*BooleanArray(items.size) { false }.toTypedArray()) }
val listState = rememberLazyListState()

LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
state = listState
) {
itemsIndexed(items, key = { index, _ -> index }) { index, item ->
ExpandableListItem(
item = item,
index = index,
isExpanded = expandedStates[index],
onExpandedChange = { expandedStates[index] = it }
)
}
}
}

Explanation:

1. Creating Items (val items):

  • remember { List(20) { index -> ExpandableItem("Item #$index") } }: This line creates a list of 20 items, each represented by an ExpandableItem object. The remember function ensures that the list is not regenerated on every recomposition, maintaining consistency across UI changes.

2. State Tracking (val expandedStates):

  • val expandedStates = remember { mutableStateListOf(*BooleanArray(items.size) { false }.toTypedArray()) }: This list keeps track of whether each item is expanded or collapsed.
  • remember: Preserves the state across recompositions.
  • mutableStateListOf(): Creates an observable list that triggers recompositions when updated.
  • BooleanArray(items.size) { false }: Initializes the list with false, meaning all items are initially collapsed.
  • * and .toTypedArray(): The spread operator (*) unpacks the array into individual elements, and .toTypedArray() converts the BooleanArray into a format compatible with mutableStateListOf().

3. List State (val listState):

  • rememberLazyListState(): Keeps track of the scroll position of the list, allowing us to control scrolling behavior if needed.

4. LazyColumn Setup:

  • LazyColumn(...): Creates a vertical list that fills the available space.
  • verticalArrangement = Arrangement.spacedBy(12.dp): Adds 12 dp of spacing between each list item.
  • contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp): Adds padding at the top and bottom to prevent clipping.
  • itemsIndexed(...): This function iterates through the items, passing each item's index and content to the ExpandableListItem composable. The expandedStates list keeps track of the expanded state of each item.

2: Expandable Item Composable: ExpandableListItem

The ExpandableListItem composable is responsible for rendering each item in the list and managing its expanded or collapsed state.

@Composable
fun ExpandableListItem(
item: ExpandableItem,
index: Int,
isExpanded: Boolean,
onExpandedChange: (Boolean) -> Unit
) {
val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() }
val rotationAngle by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)

Column(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, shape = RoundedCornerShape(12.dp))
.background(color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(12.dp))
.clickable(interactionSource = interactionSource, indication = null) {
onExpandedChange(!isExpanded)
}
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = item.title,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.graphicsLayer(rotationZ = rotationAngle)
)
}
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
Text(
text = "Details about ${item.title}:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ac ante sit amet est commodo placerat. Suspendisse potenti.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
Toast.makeText(context, "More Info clicked for item #$index", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.align(Alignment.End)
) {
Text("More Info")
}
}
}
}
}

Explanation:

1. Context and Interaction Handling:

  • val context = LocalContext.current: Gives access to the current context, which is needed for actions like showing a toast.
  • val interactionSource = remember { MutableInteractionSource() }: Tracks user interactions.

2. Rotation Animation (val rotationAngle):

  • animateFloatAsState(targetValue = if (isExpanded) 180f else 0f): Animates the rotation of the arrow icon. When the item is expanded, the icon rotates to 180°; otherwise, it returns to 0°.

3. Item Layout:

  • Column(...): The main container for each list item.
  • Modifier.clickable { onExpandedChange(!isExpanded) }: Handles the click event to toggle the expanded state.
  • Row(...): Contains the title and the rotating arrow icon.
  • Icon(...) and Text(...): Display the item icon and title.

4. Animated Visibility (AnimatedVisibility):

  • AnimatedVisibility(visible = isExpanded, ...): Controls the visibility of the item's expanded content.
  • enter and exit: Define the animations for expanding and collapsing, using effects like expandVertically(), fadeIn(), shrinkVertically(), and fadeOut() for a smooth transition.

5. Expanded Content:

  • It contains additional details and a button labeled “More Info”. When the button is clicked, a toast message is displayed.

Summary

Creating a custom animated expandable list with Jetpack Compose involves managing state effectively and using animations to enhance the user experience. The LazyColumn provides a scrollable list, while mutableStateListOf allows for efficient tracking of each item's expanded state. The ExpandableListItem component uses AnimatedVisibility and rotation animations to create a dynamic and interactive UI.

Connect with Me on LinkedIn

If you found this article helpful and want to stay updated with more insights and tips on Android development, Jetpack Compose, and other tech topics, feel free to connect with me on LinkedIn. I regularly publish articles, share my experiences, and engage with the developer community. Your feedback and interaction are always welcome!

Follow me on LinkedIn

--

--