Issue
I am trying to create a LazyColumn
with a sticky header and a sticky sub-header.
The functionality I would like to achieve what is demonstrated in this GIF.
My source code is as follows:
The data item which will be used
class Item(
val type: String, // Will be used as header
val subType: String, // Will be used as sub-header
val title: String,
val description: String,
val imageUrl: String)
An example of the item usage
Item("Movie", "Action",
"Shang-Chi and the Legend of the Ten Rings",
"Shang-Chi, the master of unarmed weaponry-based Kung Fu, is forced to " +
"confront his past after being drawn into the Ten Rings organization.",
"https://threepixelslab.gr/wp-content/uploads/2021/04/Shang-Chi-and-the-Legend-of-the-Ten-Rings.jpg")
The list composing
@Composable
fun MainList(data: List<Item>) {
val mainGroup = data.groupBy { it.type }
LazyColumn {
mainGroup.forEach { (type, groupedData) ->
val subGroup = groupedData.groupBy { it.subType }
stickyHeader {
Header(text = type)
}
item {
LazyColumn {
subGroup.forEach { (subType, subGroupedData) ->
stickyHeader { Header(text = subType) }
items(subGroupedData) {
SimpleItem(item = it)
}
}
}
}
}
}
}
The error
java.lang.IllegalStateException: Nesting scrollable in the same direction layouts like LazyColumn and Column(Modifier.verticalScroll()) is not allowed. If you want to add a header before the list of items please take a look on LazyColumn component which has a DSL api which allows to first add a header via item() function and then the list of items via items().
at androidx.compose.foundation.ScrollKt.assertNotNestingScrollableContainers-K40F9xA(Scroll.kt:370) at androidx.compose.foundation.lazy.LazyListKt$LazyList$1.invoke-0kLqBqw(LazyList.kt:96) at androidx.compose.foundation.lazy.LazyListKt$LazyList$1.invoke(LazyList.kt:95) at androidx.compose.ui.layout.SubcomposeLayoutState$createMeasurePolicy$1.measure-3p2s80s(SubcomposeLayout.kt:345) at androidx.compose.ui.node.InnerPlaceable.measure-BRTryo0(InnerPlaceable.kt:43) at androidx.compose.foundation.layout.PaddingValuesModifier.measure-3p2s80s(Padding.kt:417) at androidx.compose.ui.node.ModifiedLayoutNode.measure-BRTryo0(ModifiedLayoutNode.kt:39) at androidx.compose.ui.graphics.SimpleGraphicsLayerModifier.measure-3p2s80s(GraphicsLayerModifier.kt:219) at androidx.compose.ui.node.ModifiedLayoutNode.measure-BRTryo0(ModifiedLayoutNode.kt:39) at androidx.compose.ui.node.DelegatingLayoutNodeWrapper.measure-BRTryo0(DelegatingLayoutNodeWrapper.kt:116) at androidx.compose.ui.node.DelegatingLayoutNodeWrapper.measure-BRTryo0(DelegatingLayoutNodeWrapper.kt:116) at androidx.compose.ui.node.DelegatingLayoutNodeWrapper.measure-BRTryo0(DelegatingLayoutNodeWrapper.kt:116) at androidx.compose.ui.node.OuterMeasurablePlaceable$remeasure$3.invoke(OuterMeasurablePlaceable.kt:100) at androidx.compose.ui.node.OuterMeasurablePlaceable$remeasure$3.invoke(OuterMeasurablePlaceable.kt:99) at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:128) at androidx.compose.ui.node.OwnerSnapshotObserver.observeReads$ui_release(OwnerSnapshotObserver.kt:75) at androidx.compose.ui.node.OwnerSnapshotObserver.observeMeasureSnapshotReads$ui_release(OwnerSnapshotObserver.kt:63) at androidx.compose.ui.node.OuterMeasurablePlaceable.remeasure-BRTryo0(OuterMeasurablePlaceable.kt:99) at androidx.compose.ui.node.OuterMeasurablePlaceable.measure-BRTryo0(OuterMeasurablePlaceable.kt:71) at androidx.compose.ui.node.LayoutNode.measure-BRTryo0(LayoutNode.kt:1227) at androidx.compose.foundation.layout.RowColumnImplKt$rowColumnMeasurePolicy$1.measure-3p2s80s(RowColumnImpl.kt:89) at androidx.compose.ui.node.InnerPlaceable.measure-BRTryo0(InnerPlaceable.kt:43) at androidx.compose.ui.node.OuterMeasurablePlaceable$remeasure$3.invoke(OuterMeasurablePlaceable.kt:100) at androidx.compose.ui.node.OuterMeasurablePlaceable$remeasure$3.invoke(OuterMeasurablePlaceable.kt:99) at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:128) at androidx.compose.ui.node.OwnerSnapshotObserver.observeReads$ui_release(OwnerSnapshotObserver.kt:75) at androidx.compose.ui.node.OwnerSnapshotObserver.observeMeasureSnapshotReads$ui_release(OwnerSnapshotObserver.kt:63) at androidx.compose.ui.node.OuterMeasurablePlaceable.remeasure-BRTryo0(OuterMeasurablePlaceable.kt:99) at androidx.compose.ui.node.OuterMeasurablePlaceable.measure-BRTryo0(OuterMeasurablePlaceable.kt:71) at androidx.compose.ui.node.LayoutNode.measure-BRTryo0(LayoutNode.kt:1227) at androidx.compose.foundation.lazy.LazyMeasuredItemProvider.getAndMeasure-ZjPyQlc(LazyMeasuredItemProvider.kt:50) at androidx.compose.foundation.lazy.LazyListMeasureKt.measureLazyList-9CW8viI(LazyListMeasure.kt:145) at androidx.compose.foundation.lazy.LazyListKt$LazyList$1.invoke-0kLqBqw(LazyList.kt:152) at androidx.compose.foundation.lazy.LazyListKt$LazyList$1.invoke(LazyList.kt:95) at androidx.compose.ui.layout.SubcomposeLayoutState$createMeasurePolicy$1.measure-3p2s80s(SubcomposeLayout.kt:345)
Is it possible to implement this kind of list in Compose?
Solution
The solution I found is to add a header item above LazyColumn
and control dynamically the inner headers' content and visibility.
Take a closer look into the comments in the code below. Here is the code:
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberImagePainter
import coil.transform.CircleCropTransformation
import kotlinx.coroutines.launch
import learning.android.miltiheaderlist.ui.theme.MiltiHeaderListTheme
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.N)
@ExperimentalFoundationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MiltiHeaderListTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
MainList(getTheData())
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.N)
@ExperimentalFoundationApi
@Composable
fun MainList(data: List<Item>) {
// Use state to achieve re-compose
val listState = rememberLazyListState()
val header = remember {
mutableStateOf(data[0].type)
}
// Use state to understand if the user is moving to the start or the end of the list
val firstItemIndex = remember {
mutableStateOf(0)
}
// create a list which is a mirror of the LazyColumn
val dataList = mutableListOf<Item>()
val groupHeader = data.groupBy { it.type }
groupHeader.forEach { (header, items) ->
dataList.add(Item(type = header))
val groupSubHeader = items.groupBy { it.subType }
groupSubHeader.forEach { (subheader, items2) ->
dataList.add(Item(type = header, subType = subheader))
dataList.addAll(items2)
}
}
val mainGroup = data.groupBy { it.type }
Column() {
// This header's content will be updated while scrolling
Header(header = header.value)
LazyColumn(state = listState) {
mainGroup.forEach { (type, groupedData) ->
if (header.value !== type) {
// This header visibility will be updated while scrolling
stickyHeader { Header(header = type) }
}
val subGroup = groupedData.groupBy { it.subType }
subGroup.forEach { (subtype, subGroupedData)->
stickyHeader {
// Create only the subheader item
Header(subheader = "$subtype (${listState.firstVisibleItemIndex})")
}
items(items = subGroupedData) {
//listState.firstVisibleItemIndex.toString()
SimpleItem(item = it)
/* This is doing the magic for visibility and content of the above headers
depending on the user scrolling action
*/
if (listState.firstVisibleItemIndex < firstItemIndex.value) { // move to start
if (listState.firstVisibleItemIndex > 0) {
header.value = dataList[listState.firstVisibleItemIndex - 1].type
}
}
else if (listState.firstVisibleItemIndex > firstItemIndex.value) { // move to the end
/*
When you scroll to start and you go to the point where the stickyHeader should
become visible by default it behaves like scrolling to the end. When this happens
you enter in this else-if block but the firstVisibleItemIndex is increased by 2
(you added a new item, the stickyHeader). So, do the following action whenever
all these things did not happen.
*/
if (listState.firstVisibleItemIndex - firstItemIndex.value != 2){
header.value = dataList[listState.firstVisibleItemIndex].type
}
}
firstItemIndex.value = listState.firstVisibleItemIndex
}
}
}
}
}
}
@Composable
fun SimpleItem(item : Item) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp)
.clickable { },
elevation = 10.dp
) {
Row() {
Image(
painter = rememberImagePainter(
data = item.imageUrl,
builder = {
transformations(CircleCropTransformation())
}
),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.padding(5.dp)
)
Column() {
Text(text = item.title, fontSize = 15.sp)
Text(text = item.description, fontSize = 10.sp)
}
}
}
}
@Composable
fun Header(header: String = "", subheader: String = "") {
if (header.isNotEmpty()) {
Card(
modifier = Modifier
.fillMaxWidth(),
elevation = 5.dp,
backgroundColor = Color.Red
) {
Text(text = header, fontSize = 20.sp, modifier = Modifier.padding(5.dp))
}
}
if (subheader.isNotEmpty()) {
Card(
modifier = Modifier
.fillMaxWidth(),
elevation = 5.dp,
backgroundColor = Color.Red
) {
Text(text = subheader, fontSize = 20.sp, modifier = Modifier.padding(5.dp))
}
}
}
The other part is missing is the data, which are shown below:
class Item(
val type: String = "",
val subType: String = "",
val title: String = "",
val description: String = "",
val imageUrl: String = ""
)
fun getTheData(): List<Item> {
return listOf(
Item("Movie", "Action",
"Shang-Chi and the Legend of the Ten Rings",
"Shang-Chi, the master of unarmed weaponry-based Kung Fu, is forced to " +
"confront his past after being drawn into the Ten Rings organization.",
"https://threepixelslab.gr/wp-content/uploads/2021/04/Shang-Chi-and-the-Legend-of-the-Ten-Rings.jpg"),
Item("Movie", "Action",
"The Matrix Resurrections",
"The plot is currently unknown.",
"https://media.oneman.gr/onm-images/matrix-3.jpg"),
Item("Movie", "Action",
"Free Guy",
"A bank teller discovers that he's actually an NPC inside a brutal, open world video game.",
"https://www.athinorama.gr/lmnts/events/cinema/10072050/Poster.jpg"),
Item("Movie", "Action",
"The Suicide Squad",
"Supervillains Harley Quinn, Bloodsport, Peacemaker and a collection of nutty " +
"cons at Belle Reve prison join the super-secret, super-shady Task Force X as they " +
"are dropped off at the remote, enemy-infused island of Corto Maltese.",
"https://sm.ign.com/t/ign_gr/movie/s/suicide-sq/suicide-squad-2_9h82.200.jpg"),
Item("Movie", "Action",
"Kate",
"A female assassin has 24 hours to get vengeance on her murderer before she dies.",
"https://image.tmdb.org/t/p/w185/uQWgSRXeYRWCvGIX9LDNBW6XBYD.jpg"),
Item("Movie", "Horror",
"Candyman",
"A sequel to the horror film Candyman (1992) that returns to the now-gentrified " +
"Chicago neighborhood where the legend began.",
"https://upreviews.net/images/artwork/upreviews_-KiisqOOM-GwNoLaCO_V.jpg"),
Item("Movie", "Horror",
"Don't Breathe 2",
"The sequel is set in the years following the initial deadly home invasion, " +
"where Norman Nordstrom (Stephen Lang) lives in quiet solace until his past sins " +
"catch up to him.",
"https://images-na.ssl-images-amazon.com/images/I/914Wg3bzCGL._RI_.jpg"),
Item("Movie", "Horror",
"Last Night in Soho",
"An aspiring fashion designer is mysteriously able to enter the 1960s where " +
"she encounters a dazzling wannabe singer. But the glamour is not all it appears " +
"to be and the dreams of the past start to crack and splinter into something darker.",
"https://deadline.com/wp-content/uploads/2021/06/last-night-in-soho-crop-excl-2.jpg"),
Item("Movie", "Horror",
"Malignant",
"Madison is paralyzed by shocking visions of grisly murders, and her torment " +
"worsens as she discovers that these waking dreams are in fact terrifying realities.",
"https://media.oneman.gr/onm-images/HMKQGC5Q2NGSBCA3TIGZARMZFU.jpg"),
Item("Movie", "Animation",
"The Witcher: Nightmare of the Wolf",
"Escaping from poverty to become a witcher, Vesemir slays monsters for coin and " +
"glory, but when a new menace rises, he must face the demons of his past.",
"https://upload.wikimedia.org/wikipedia/en/thumb/4/4d/The_Witcher_Nightmare_of_the_Wolf.jpg/220px-The_Witcher_Nightmare_of_the_Wolf.jpg"),
Item("Movie", "Animation",
"PAW Patrol: The Movie",
"Ryder and the pups are called to Adventure City to stop Mayor Humdinger " +
"from turning the bustling metropolis into a state of chaos.",
"https://dx35vtwkllhj9.cloudfront.net/paramountpictures/paw-patrol-the-movie/images/regions/us/share-tout2.png"),
Item("Movie", "Musical",
"Cinderella",
"A modern movie musical with a bold take on the classic fairy tale. Our " +
"ambitious heroine has big dreams and with the help of her fab Godmother, " +
"she perseveres to make them come true.",
"https://lumiere-a.akamaihd.net/v1/images/g_cinderella1950_03_17805_4c9a7fe6.jpeg"),
Item("Book", "Horror",
"My Heart Is a Chainsaw",
"In her quickly gentrifying rural lake town Jade sees recent events only " +
"her encyclopedic knowledge of horror films could have prepared her for",
"https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1623264202l/55711617.jpg"),
Item("Book", "Horror",
"The Dead and the Dark",
"Courtney Gould’s thrilling debut The Dead and the Dark is about the things " +
"that lurk in dark corners, the parts of you that can’t remain hidden, and about " +
"finding home in places―and people―you didn’t expect.",
"https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1599058814l/53141419.jpg"),
Item("Book", "Horror",
"Billy Summers",
"Billy Summers is a man in a room with a gun. He’s a killer for hire and the " +
"best in the business.",
"https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1618151020l/56852407.jpg"),
Item("Book", "Horror",
"A Lesson in Vengeance",
"Perched in the Catskill mountains, the centuries-old, ivy-covered campus was " +
"home until the tragic death of her girlfriend.",
"https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1605799295l/50999821.jpg"),
Item("Book", "Horror",
"Velvet Was the Night",
"From the New York Times bestselling author of Mexican Gothic comes a " +
"“delicious, twisted treat for lovers of noir” about a daydreaming secretary, a " +
"lonesome enforcer, and the mystery of a missing woman they’re both desperate to find.",
"https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1617426360l/54746205.jpg"),
Item("Book", "Comic",
"Star Wars: War Of The Bounty Hunters - Boushh",
"THE SECRET ORIGIN OF BOUSHH! A “WAR OF THE BOUNTY HUNTERS” TIE-IN!",
"https://i.annihil.us/u/prod/marvel/i/mg/f/90/6142606a6160a/clean.jpg"),
Item("Book", "Comic",
"X-Men: The Trial of Magneto",
"Heroes of the Marvel Universe came to Krakoa for a memorial.",
"https://i.annihil.us/u/prod/marvel/i/mg/7/03/61426068ad880/clean.jpg"),
Item("Book", "Comic",
"Extreme Carnage: Agony",
"As the odds (and symbiotes!) stack against our heroes, is there any way " +
"they can win against Carnage?",
"https://i.annihil.us/u/prod/marvel/i/mg/3/80/614260a6d95c8/clean.jpg"),
Item("Book", "Comic",
"The Last Annihilation: Wakanda",
"With the universe itself at stake, Black Panther enlists the might of the " +
"Intergalactic Empire of Wakanda to help stop the dreaded Dormammu!",
"https://i.annihil.us/u/prod/marvel/i/mg/3/90/61426088ebb66/clean.jpg"),
Item("Book", "Comic",
"Black Widow",
"FRIEND OR FOE?",
"https://i.annihil.us/u/prod/marvel/i/mg/6/90/612e8e0826ce5/clean.jpg")
)
}
Although it is not perfect it is a good start! The whole project could be found here and a description and usage example could be found here
Answered By - LiTTle
Answer Checked By - Clifford M. (JavaFixing Volunteer)