Заголовки и подзаголовки LazyColum

#android #android-jetpack-compose #android-jetpack #lazycolumn

Вопрос:

Я пытаюсь создать a LazyColumn с липким заголовком и липким подзаголовком. Функциональность, которую я хотел бы реализовать, продемонстрирована в этом GIF.

Мой исходный код выглядит следующим образом:

Элемент данных, который будет использоваться

 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)
 

Пример использования элемента

 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")
 

Список, составляющий

 @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)
                        }
                    }
                }
            }
        }
    }
}
 

Ошибка

java.lang.Исключение IllegalStateException: Вложение прокручиваемых в одном направлении макетов, таких как LazyColumn и столбец(модификатор.verticalScroll ()), не допускается. Если вы хотите добавить заголовок перед списком элементов, пожалуйста, посмотрите на компонент LazyColumn, который имеет DSL api, который позволяет сначала добавить заголовок с помощью функции item (), а затем список элементов с помощью элементов().

в androidx.сочиняй.основа.ScrollKt.assertNotNestingScrollableContainers-K40F9xA(Scroll.kt:370) в androidx.compose.foundation.lazy.LazyListKt$LazyList$1.вызов-0kLqBqw(LazyList.kt:96) в androidx.compose.foundation.lazy.LazyListKt$LazyList$1.вызовите(LazyList.kt:95) в androidx.compose.ui.layout.SubcomposeLayoutState$createMeasurePolicy$1.мера-3p2s80s(SubcomposeLayout.kt:345) в узле androidx.compose.ui.Незаменимый.мера-BRTryo0(незаменимый.кт:43) в androidx.сочинять.основа.расположение.PaddingValuesModifier.measure-3p2s80s(Заполнение.кт:417) в androidx.составьте.ui.узел.Измененный layoutnode.measure-BRTryo0(измененный layoutnode.kt:39) в androidx.compose.ui.графика.SimpleGraphicsLayerModifier.measure-3p2s80s(GraphicsLayerModifier.kt:219) в узле androidx.compose.ui.ModifiedLayoutNode.measure-BRTryo0(ModifiedLayoutNode.kt:39) в узле androidx.compose.ui.Делегирование lay-outnodewrapper.measure-BRTryo0(делегирование Lay-outnodewrapper.kt:116) в узле androidx.compose.ui.Делегирование Layoutnodewrapper.measure-BRTryo0(Делегирование layoutnodewrapper.kt:116) в androidx.составьте.ui.узел.Делегированиеlayoutnodewrapper.measure-BRTryo0(делегированиеlayoutnodewrapper.kt:116) в узле androidx.compose.ui.узел.Outermeasureplaceable$переизмерить$3.вызовите(outermeasureplaceable.kt:100) в узле androidx.compose.ui.OuterMeasurablePlaceable$повторное измерение$3.вызовите(OuterMeasurablePlaceable.kt:99) в androidx.составьте.время выполнения.моментальные снимки.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:128) в узле androidx.compose.ui.OwnerSnapshotObserver.observeReads$ui_release(OwnerSnapshotObserver.kt:75) в androidx.составьте.ui.узел.OwnerSnapshotObserver.observeMeasureSnapshotReads$ui_release(OwnerSnapshotObserver.kt:63) в узле androidx.compose.ui.outermeasureplaceable.remeasure-BRTryo0(outermeasureplaceable.kt:99) в узле androidx.compose.ui.outermeasureplaceable.measure-BRTryo0(outermeasureplaceable.kt:71) в узле androidx.compose.ui.LayoutNode.measure-BRTryo0(LayoutNode.kt:1227) в androidx.compose.основа.расположение.RowColumnImplKt$rowColumnMeasurePolicy$1.мера-3p2s80s(RowColumnImpl.kt:89) в узле androidx.compose.ui.Незаменимый.мера-BRTryo0(незаменимый.кт:43) в androidx.составьте.ui.узел.Outermeasureplaceable$переизмерить$3.вызовите(outermeasureplaceable.kt:100) в узле androidx.compose.ui.OuterMeasurablePlaceable$переизмерить$3.вызовите(OuterMeasurablePlaceable.kt:99) в androidx.составьте.время выполнения.моментальные снимки.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:128) в узле androidx.compose.ui.OwnerSnapshotObserver.observeReads$ui_release(OwnerSnapshotObserver.kt:75) в узле androidx.compose.ui.node.OwnerSnapshotObserver.observeMeasureSnapshotReads$ui_release(OwnerSnapshotObserver.kt:63) в androidx.составьте.ui.узел.outermeasureplaceable.remeasure-BRTryo0(outermeasureplaceable.kt:99) в узле androidx.compose.ui.outermeasureplaceable.measure-BRTryo0(outermeasureplaceable.kt:71) в узле androidx.compose.ui.LayoutNode.measure-BRTryo0(LayoutNode.kt:1227) в androidx.compose.фундамент.ленивый.LazyMeasuredItemProvider.getAndMeasure-ZjPyQlc(LazyMeasuredItemProvider.kt:50) в androidx.compose.foundation.lazy.LazyListMeasureKt.measureLazyList-9CW8viI(LazyListMeasure.kt:145) в androidx.compose.foundation.lazy.LazyListKt$LazyList$1.вызовите-0kLqBqw(LazyList.kt:152) в androidx.составить.foundation.lazy.LazyListKt$LazyList$1.вызовите(LazyList.kt:95) в androidx.compose.ui.layout.SubcomposeLayoutState$createMeasurePolicy$1.мера-3p2s80s(SubcomposeLayout.kt:345)

Возможно ли реализовать такой список в Compose?

Комментарии:

1. Вы не можете сделать это с помощью LazyColumn из коробки, так как вы пытаетесь использовать 2 разных типа липких заголовков одновременно. Ваше исключение связано с тем, что составная часть с вертикальной прокруткой (LazyList) находится внутри другой составной части с вертикальной прокруткой (LazyList). Вам нужно будет написать собственное решение.

2. @Francesc У вас есть какой-нибудь пример чего-нибудь, сделанного на заказ для Compose? Просто для того, чтобы сначала у меня появилась идея.

3. Нет, извините, у меня нет примеров такого рода, возможно, вам придется изучить реализацию липких заголовков LazyList из библиотек Compose и расширить ее.

Ответ №1:

Решение, которое я нашел, состоит в том, чтобы добавить элемент заголовка выше LazyColumn и динамически управлять содержимым и видимостью внутренних заголовков.

Внимательно ознакомьтесь с комментариями в приведенном ниже коде. Вот код:

 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))
        }
    }
}
 

Другая часть отсутствует-это данные, которые показаны ниже:

     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")
    )
}
 

Хотя это и не идеально, это хорошее начало! Весь проект можно найти здесь.