Multi-Section collapsable List in Jetpack Compose
Our application is built using Jetpack Compose and this article explains how we can build a performant multi-section list.
LazyList has a limitation that it can’t contain other lists of undefined height which can scroll in the same direction. Read more about the same here: Avoid nesting scrollable. Basically, nested scrolling is not supported (except when you fix the height for the inner lists).
This article explains the design choices we had to make to build a complex UI with LazyColumn.
Below are the basic requirements for the screen:
- We wanted to build a screen for a restaurant menu listing where users could easily browse through the items of a restaurant.
- The items are grouped into sections, each section can have sub-sections or items directly inside them.
- Any Section or subsection can be collapsed and expanded by the user.
- Each subsection has items.
- If there are out-of-stock items in a section or a sub-section, they are collapsed into a separate section at the end.
- There is a menu bottom sheet, from which the user can click on a category and jump to that position in the UI.
Below is a recording of the actual implementation of these requirements
We wanted to build a view with these requirements using LazyColumn. We also wanted to keep the code easy and intuitive to understand for all the developers.
As you can see in the image above, we display a list of Categories on the screen, each category can have items and/or SubCategories. SubSections contain Items.
Defining each section as a Composable and using these inside the LazyColumn is not a performant solution as there can be hundreds of items within a category and there would be no recycling as all of these items need to be rendered and need to be in memory when any portion of the category needs to rendered. This approach is not performant in terms of memory as well. The only performant approach we could think of was to have all of the items as the immediate children of the LazyList rather than the whole category. That way, there is no need to render the entire section, nor the limitation of not recycling the views would be there.
Keeping these requirements in mind, these would be the interactable components:
- Category
- SubCategory
- Item
We display a list of Categories on the screen, each category can have items and/or SubCategories. SubSections contain Items.
Other UI elements are:
- CategoryHeader (Name of the section and count is displayed here)
- SubCategoryHeader
- InterCategorySpace
- SeparatorView (A thin line between sub-categories / Items)
Jumping to the code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// LazyListScopeExtentions.kt
fun LazyListScope.Categories(
categories: List<CategoryRenderData>,
isExpanded: (id: String) -> StateFlow<Boolean>?,
onToggle: (id: String) -> Unit,
isOOSExpanded: (id: String) -> StateFlow<Boolean>?,
onOOSToggle: (id: String) -> Unit,
) {
categories.forEach { menuCategoryRenderData ->
val hasSubSections = menuCategoryRenderData.subCategoryList.isNotEmpty()
val collapseAble = !hasSubSections
val isExpandedValue = isExpanded(menuCategoryRenderData.id)?.value ?: false
SectionHeader(name = "${menuCategoryRenderData.displayName} (${menuCategoryRenderData.totalItemsCount})",
id = menuCategoryRenderData.id,
isExpanded = isExpandedValue,
collapseAble = collapseAble,
onToggle = {
if (collapseAble) {
onToggle(menuCategoryRenderData.id)
}
})
// Show the contents only if it is expanded or if it has sub sections
if (isExpandedValue || hasSubSections) {
if (hasSubSections) {
menuCategoryRenderData.subCategoryList.forEachIndexed { index, subCategoryRenderData ->
SubCategories(
menuSubCategoryRenderData = subCategoryRenderData,
isExpanded = isExpanded(subCategoryRenderData.id),
showSeparator = index != menuCategoryRenderData.subCategoryList.size - 1,
onToggle = {
onToggle(subCategoryRenderData.id)
},
showTopPadding = index != 0,
onShowOOSToggle = onOOSToggle,
isOOSExpanded = isOOSExpanded(subCategoryRenderData.id),
)
}
} else {
if (menuCategoryRenderData.inStockItems.isNotEmpty()) {
ListofItems(
menuCategoryRenderData.inStockItems,
menuCategoryRenderData.id
)
}
// Display OOS items in a section, similar logic as above
}
}
InterCategorySpace(menuCategoryRenderData.id)
}
}
fun LazyListScope.SubCategories(
menuSubCategoryRenderData: SubCategoryRenderData,
isExpanded: StateFlow<Boolean>?,
showSeparator: Boolean,
showTopPadding: Boolean,
isOOSExpanded: StateFlow<Boolean>?,
onToggle: () -> Unit,
onShowOOSToggle: (id: String) -> Unit,
) {
val isExpandedValue = isExpanded?.value ?: false
val isOOSExpandedValue = isOOSExpanded?.value ?: false
SubSectionHeader(
"${menuSubCategoryRenderData.displayName} (${menuSubCategoryRenderData.totalItemsCount})",
menuSubCategoryRenderData.id,
isExpandedValue,
showTopPadding = showTopPadding,
onToggle = onToggle
)
if (isExpandedValue) {
ListofItems(
menuSubCategoryRenderData.inStockItems,
menuSubCategoryRenderData.id
)
// Display OOS items in a section, similar logic as above
}
if (showSeparator) {
SeparatorView(menuSubCategoryRenderData.id)
}
}
fun LazyListScope.ListofItems(
items: List<Item>,
sectionId: String
) {
items.forEachIndexed { index, item ->
item(key = item.id + sectionId, contentType = "Item") {
// Display actual item here
/*Item(
...
)*/
}
}
}
fun LazyListScope.SeparatorView(id: String) {
item("SeparatorView$id", contentType = "SeparatorView") {
Spacer(modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.Gray)
)
}
}
fun LazyListScope.InterCategorySpace(id: String) {
item(key = "SectionSpace$id", contentType = "InterCategorySpace") {
Spacer(modifier = Modifier
.height(8.dp)
.fillMaxWidth()
.background(Color.Gray)
)
}
}
fun LazyListScope.SectionHeader(name: String, id: String, isExpanded: Boolean, collapseAble: Boolean, onToggle: () -> Unit) {
item(key = "SectionHeader$id", contentType = "SectionHeader") {
Row(modifier = Modifier
.fillMaxWidth()
.noRippleClickable { onToggle() },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.fillMaxWidth(0.85f),
text = name,
maxLines = 2
)
if (collapseAble) {
val rotationAngle = animateFloatAsState(if (isExpanded) 180f else 0f)
Icon(
painter = painterResource(com.phonepe.app.address.R.drawable.ic_chevron_down),
contentDescription = "arrow",
modifier = Modifier
.rotate(rotationAngle.value)
)
}
}
}
}
fun LazyListScope.SubSectionHeader(name: String, id: String, isExpanded: Boolean, showTopPadding: Boolean, onToggle: () -> Unit) {
item(key = "SubSectionHeader$id", contentType = "SubSectionHeader") {
// Similar to SectionHeader with a lesser font weightage
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// MultiSectionListView.kt
@Composable
fun CategoryListView(categories: List<CategoryRenderData> = listOf()) {
// These maps should ideally be in View model
val expandedSectionIdsMap: SnapshotStateMap<String, StateFlow<Boolean>> = remember {
mutableStateMapOf<String, StateFlow<Boolean>>()
}
val expandedOOSSectionIdsMap: SnapshotStateMap<String, StateFlow<Boolean>> = remember {
mutableStateMapOf<String, StateFlow<Boolean>>()
}
LazyColumn(content = {
Categories(
categories = categories,
isExpanded = {
expandedSectionIdsMap[it]
},
onToggle = {
expandedSectionIdsMap[it] = MutableStateFlow(
expandedSectionIdsMap[it]?.value?.not() ?: true
)
},
isOOSExpanded = {
expandedOOSSectionIdsMap[it]
},
onOOSToggle = {
expandedOOSSectionIdsMap[it] = MutableStateFlow(
expandedOOSSectionIdsMap[it]?.value?.not() ?: true
)
}
)
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// SampleRenderDataModels.kt
data class CategoryRenderData(
val id: String,
val displayName: String,
val subCategoryList: List<SubCategoryRenderData>,
val inStockItems: List<Item>,
val oosItems: List<Item>,
val totalItemsCount: Int?,
val hasOnlyOutOfStockItems: Boolean = false
)
data class SubCategoryRenderData(
val id: String,
val displayName: String,
val inStockItems: List<Item>,
val oosItems: List<Item>,
val totalItemsCount: Int?,
val hasOnlyOutOfStockItems: Boolean = false
)
data class Item(
val id: String
)
As you can see from the code, CategoryListView uses a single LazyColumn (In fact, in the actual implementation of full screen with other things on the top of the sections like search, store info etc, the entire screen is a single LazyColumn). We just call the Categories extension function for each of the Categories we need to render. These are not Composble functions which emit UI however.
We define extension functions for different components, some of the components like Categories (LazyListScope.Categories) and SubCategories don’t directly emit any UI, these call other functions like SectionHeader and ListofItems that emit UI (only the functions using item/items function of LazyList are emitting UI).
By defining the above constructs we were able to control the order in which the different components are rendered. If a category is collapsed, there will not be any items/subcategories rendered for that section. On expansion, all the content inside a category is rendered.
This also makes sure that there is granular recycling of items as, everything from items, category separators, and lines between the items/subCategories are all immediate children of the LazyList.
Another important bit it to handle the toggling of categories/subCategories or out-of-stock sections. This is achieved by having a SnapshotStateMap of category/subCategory ids and **StateFlow
We needed similar expand and collapse functionality for the out-of-stock item section at the end. An exactly similar pattern is used to accomplish that.
As you can from the code, what items/subCategories need to be rendered in which order and toggling of the categories/subCategories can be achieved easily with the above approach. It also takes care of performance as each small item in the LazyList can be reused as all of them are immediate children of the LazyList.