androidkotlinandroid-jetpack-composelazycolumnjetpack-compose-accompanist

HorizontalPager with LazyColumn inside another LazyColumn - Jetpack Compose


I want a similiar effect to TikToks profile screen. On top is the ProfilPicture and username, below that is a stickyHeader with a TabRow (Posts, Drafts, Likes, Favorites) and below that is a HorizontalPager with the 4 Screens (Posts, Drafts, Likes, Favorites), each of these screens contain a list.

If I build this in Compose I get a crash because I cannot nest two LazyColumns inside each other.

Here is a short version of what I try to do:

val tabList = listOf("Posts", "Drafts", "Likes", "Favorites")
val pagerState: PagerState = rememberPagerState(initialPage = 0)
val coroutineScope = rememberCoroutineScope()

LazyColumn(modifier = Modifier.fillMaxSize()) {
    item {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(50.dp),
            contentAlignment = Alignment.Center
        ) {
            //Profile Header (Picture, Username, Followers, etc)
            Text(text = "Profile Picture")
        }
    }

    stickyHeader {
        TabRow(
            modifier = Modifier.fillMaxWidth(),
            backgroundColor = Color.Black,
            contentColor = Color.White,
            selectedTabIndex = pagerState.currentPage,
            indicator = { tabPositions ->
                TabRowDefaults.Indicator(
                    Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)
                )
            }
        ) {
            // Add tabs for all of our pages
            tabList.forEachIndexed { index, title ->
                Tab(
                    text = { Text(title) },
                    selected = pagerState.currentPage == index,
                    onClick = {
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(index)
                        }
                    },
                )
            }
        }
    }
    item {
        HorizontalPager(
            state = pagerState,
            count = tabList.size
        ) { page: Int ->
            when (page) {
                0 -> PostsList()
                1 -> DraftsList()
                2 -> LikesList()
                else -> FavoritesList()
            }
        }
    }
}

and inside the PostList() composable for example is:

@Composable
fun PostList(){
    LazyColumn() {
        items(50){ index ->
            Button(onClick = { /*TODO*/ },
                modifier = Modifier.fillMaxWidth()) {
                Text(text = "Button $index")
            }
        }
    }
}

Here is the crash I get:

Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a header before the list of items please add a header as a separate item() before the main items() inside the LazyColumn scope. There are could be other reasons for this to happen: your ComposeView was added into a LinearLayout with some weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a custom layout. Please try to remove the source of infinite constraints in the hierarchy above the scrolling container.

EDIT: Giving the child LazyColumn a fixed height prevents the app from crashing but is not a very satisfying solution. When the 4 Lists in the HorizontalPager have different sizes it gives a weird and buggy behaviour and just doesnt look right. Another thing that I tried was to use FlowRow instead of LazyColumn, this also seemed to work and fixed the crash but also here I get a weird behaviour, the Lists in HorizontalPager are scrolling synchonously at the same time, which is not what I want.

The HorizontalPager is what makes this task so difficult, without it is not a problem at all.

Here is the test project: https://github.com/DaFaack/TikTokScrollBehaviourCompose

This is how it looks like when I give the LazyColumn a fixed height of 2500.dp, only with such a large height it gives the desired scroll behaviour. The downside here is that even if the List is empty it has a height of 2500 and that causes a bad user experience because it allows the user to scroll even though the list is empty

enter image description here


Solution

  • In this case, using a scrollable Column instead of LazyColumn in the outer level is easier.

    This should achieve what you want:

    package com.fujigames.nestedscrolltest
    
    import android.os.Bundle
    import androidx.activity.ComponentActivity
    import androidx.activity.compose.setContent
    import androidx.compose.foundation.background
    import androidx.compose.foundation.layout.*
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.rememberScrollState
    import androidx.compose.foundation.verticalScroll
    import androidx.compose.material.*
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.remember
    import androidx.compose.runtime.rememberCoroutineScope
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.geometry.Offset
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
    import androidx.compose.ui.input.nestedscroll.NestedScrollSource
    import androidx.compose.ui.input.nestedscroll.nestedScroll
    import androidx.compose.ui.unit.dp
    import com.fujigames.nestedscrolltest.ui.theme.NestedScrollTestTheme
    import com.google.accompanist.flowlayout.FlowRow
    import com.google.accompanist.pager.ExperimentalPagerApi
    import com.google.accompanist.pager.HorizontalPager
    import com.google.accompanist.pager.pagerTabIndicatorOffset
    import com.google.accompanist.pager.rememberPagerState
    import kotlinx.coroutines.launch
    
    class MainActivity : ComponentActivity() {
        @OptIn(ExperimentalPagerApi::class)
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                NestedScrollTestTheme {
                    BoxWithConstraints {
                        val screenHeight = maxHeight
                        val scrollState = rememberScrollState()
                        Column(
                            modifier = Modifier
                                .fillMaxSize()
                                .verticalScroll(state = scrollState)
                        ) {
                            Box(
                                modifier = Modifier
                                    .height(200.dp)
                                    .fillMaxWidth()
                                    .background(Color.LightGray), contentAlignment = Alignment.Center
                            ) {
                                Text(text = "HEADER")
                            }
    
                            Column(modifier = Modifier.height(screenHeight)) {
                                val tabList = listOf("Tab1", "Tab2")
                                val pagerState = rememberPagerState(initialPage = 0)
                                val coroutineScope = rememberCoroutineScope()
    
                                TabRow(
                                    modifier = Modifier.fillMaxWidth(),
                                    backgroundColor = Color.White,
                                    contentColor = Color.Black,
                                    selectedTabIndex = pagerState.currentPage,
                                    // Override the indicator, using the provided pagerTabIndicatorOffset modifier
                                    indicator = { tabPositions ->
                                        TabRowDefaults.Indicator(
                                            Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)
                                        )
                                    }
                                ) {
                                    tabList.forEachIndexed { index, title ->
                                        Tab(
                                            text = { Text(title) },
                                            selected = pagerState.currentPage == index,
                                            onClick = {
                                                coroutineScope.launch {
                                                    pagerState.animateScrollToPage(index)
                                                }
                                            },
                                        )
                                    }
                                }
    
                                HorizontalPager(
                                    state = pagerState,
                                    count = tabList.size,
                                    modifier = Modifier
                                        .fillMaxHeight()
                                        .nestedScroll(remember {
                                            object : NestedScrollConnection {
                                                override fun onPreScroll(
                                                    available: Offset,
                                                    source: NestedScrollSource
                                                ): Offset {
                                                    return if (available.y > 0) Offset.Zero else Offset(
                                                        x = 0f,
                                                        y = -scrollState.dispatchRawDelta(-available.y)
                                                    )
                                                }
                                            }
                                        })
                                ) { page: Int ->
                                    when (page) {
                                        0 -> ListLazyColumn(50)
                                        1 -> ListFlowRow(5)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    
    @Composable
    fun ListLazyColumn(items: Int) {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
            items(items) { index ->
                Button(
                    onClick = { /*TODO*/ },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text(text = "Button $index")
                }
            }
        }
    }
    
    @Composable
    fun ListFlowRow(items: Int) {
        FlowRow(modifier = Modifier.fillMaxSize()) {
            repeat(items) { index ->
                Button(
                    onClick = { /*TODO*/ },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text(text = "Button $index")
                }
            }
    
        }
    }