HorizontalPager with LazyColumn inside another LazyColumn - Jetpack Compose
Asked Answered
S

2

18

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

Shavon answered 14/5, 2022 at 14:2 Comment(2)
check out this video section for reasons and possible solutionsOlivette
@PylypDukhov Thanks, I edited my answer and explained why setting a fixed height is not working in this caseShavon
B
25

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

    }
}
Boresome answered 21/5, 2022 at 13:10 Comment(11)
Hey, thank you. This is a really clean solution and works as expected. Is it possible to keep the header above the first item? So the header should only pop up when the user scrolled back all the way to the top. (like in the gif I posted in my question).Shavon
@Shavon I've updated the code. Generally this kind of behaviors can be achieved by custom NestedScrollConnections.Boresome
Works perfectly, thank you so much. I was so close to giving up and not using a HorizontalPager. I found the documentation to netstedScollConnections pretty hard to understand. I will give you the bounty reward in 6hShavon
This is actually a clean implementation, But if while scrolling up , i want the Header text also to slide to Topbar and when scrolling down , should hide the header text from topbar. Please suggest how to acheive it.Danell
Is there a way where we can imeplement rememberLazyListState() instead rememberScrollState() in first Column, because I want to get some methods like first item offset?Allergic
this works great. where is the Row in the outer level though? i just see ColumnsLlovera
@Llovera Yeah I meant Column. I've updated the answer.Boresome
Just a minor gripe, as the HorizontalPager swipes there's a large delay before the LazyColumn can be interacted with again. Any way to either minimize this delay or enable intercept swiping on the LazyColumn?Facing
@Facing I believe that has been fixed in 1.7.0-alpha03 by this CL.Boresome
Thanks, I just tried 1.7.0-alpha03 but the problem still persists. No difference in 1.7.0-alpha05 as wellFacing
@Facing In that case please start a new question with a detailed description of the problem.Boresome
B
5

Simple impemation of that behavior with Material3

  1. Go to build.gradle and import accompanist pager
    Since Compose 1.4 Pager is part of Compose foundation
  2. Create PagerScreen with PagerState, CoroutineScope, ScrollBehavior, TabRow and HorizontalPager, you can use different scrolBehaviors provided by TopAppBarDefaults or implement your custom ScrollBehavior:
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun PagerScreen() {
    // Tabs for pager
    val tabData = listOf(
        "Tab 1",
        "Tab 2",
    )

    // Pager state
    val pagerState = rememberPagerState()

    // Coroutine scope for scroll pager
    val coroutineScope = rememberCoroutineScope()

    // Scroll behavior for TopAppBar
    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())

    Scaffold(
        topBar = {
            TopAppBar(
                scrollBehavior = scrollBehavior,
                title = {
                    Text(text = "Top app bar")
                },
                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
                    scrolledContainerColor = MaterialTheme.colorScheme.surface
                ),
            )
        },
        content = { innerPadding ->
            Column(
                modifier = Modifier
                    .padding(innerPadding)
                    .fillMaxSize(),
                content = {
                    TabRow(
                        selectedTabIndex = pagerState.currentPage,
                        tabs = {
                            tabData.forEachIndexed { index, title ->
                                Tab(
                                    text = { Text(title) },
                                    selected = pagerState.currentPage == index,
                                    onClick = {
                                        coroutineScope.launch {
                                            pagerState.animateScrollToPage(index)
                                        }
                                    },
                                )
                            }
                        }
                    )

                    HorizontalPager(
                        modifier = Modifier.fillMaxSize(),
                        pageCount = tabData.size,
                        state = pagerState,
                    ) { tabId ->
                        when (tabId) {
                            0 -> Tab1(scrollBehavior = scrollBehavior)
                            1 -> Tab2(scrollBehavior = scrollBehavior)
                        }
                    }
                }
            )
        }
    )
}
  1. Create tabs for HorizontalPager:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Tab1(scrollBehavior: TopAppBarScrollBehavior) {
    // List items
    val listItems = listOf(
        "test 1 tab 1",
        "test 2 tab 1",
        "test 3 tab 1",
        "test 4 tab 1",
        "test 5 tab 1",
        "test 6 tab 1",
        "test 7 tab 1",
        "test 8 tab 1",
        "test 9 tab 1",
        "test 10 tab 1",
        "test 11 tab 1",
        "test 12 tab 1",
    )

    val listState = rememberLazyListState()

    LazyColumn(
        modifier = Modifier
            .fillMaxWidth()
            .nestedScroll(scrollBehavior.nestedScrollConnection),
        state = listState,
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        content = {
            items(items = listItems) { item ->
                Card(
                    modifier = Modifier
                        .height(80.dp)
                        .fillMaxWidth(),

                    content = { Text(text = item) }
                )
            }
        }
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Tab2(scrollBehavior: TopAppBarScrollBehavior) {
    // List items
    val listItems = listOf(
        "test 1 tab 2",
        "test 2 tab 2",
        "test 3 tab 2",
        "test 4 tab 2",
        "test 5 tab 2",
        "test 6 tab 2",
        "test 7 tab 2",
        "test 8 tab 2",
        "test 9 tab 2",
        "test 10 tab 2",
        "test 11 tab 2",
        "test 12 tab 2",
    )

    val listState = rememberLazyListState()

    LazyColumn(
        modifier = Modifier
            .fillMaxWidth()
            .nestedScroll(scrollBehavior.nestedScrollConnection),
        state = listState,
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        content = {
            items(items = listItems) { item ->
                Card(
                    modifier = Modifier
                        .height(80.dp)
                        .fillMaxWidth(),

                    content = { Text(text = item) }
                )
            }
        }
    )
}

Full code:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.ExperimentalFoundationApi
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.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import com.andreirozov.pager.ui.theme.PagerTheme
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PagerTheme {
                Surface(modifier = Modifier.fillMaxSize()) {
                    PagerScreen()
                }

            }
        }
    }
}

@OptIn(ExperimentalPagerApi::class, ExperimentalMaterial3Api::class)
@Composable
fun PagerScreen() {
    // Tabs for pager
    val tabData = listOf(
        "Tab 1",
        "Tab 2",
    )

    // Pager state
    val pagerState = rememberPagerState()

    // Coroutine scope for scroll pager
    val coroutineScope = rememberCoroutineScope()

    // Scroll behavior for TopAppBar
    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())

    Scaffold(
        topBar = {
            TopAppBar(
                scrollBehavior = scrollBehavior,
                title = {
                    Text(text = "Top app bar")
                },
                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
                    scrolledContainerColor = MaterialTheme.colorScheme.surface
                ),
            )
        },
        content = { innerPadding ->
            Column(
                modifier = Modifier
                    .padding(innerPadding)
                    .fillMaxSize(),
                content = {
                    TabRow(
                        selectedTabIndex = pagerState.currentPage,
                        tabs = {
                            tabData.forEachIndexed { index, title ->
                                Tab(
                                    text = { Text(title) },
                                    selected = pagerState.currentPage == index,
                                    onClick = {
                                        coroutineScope.launch {
                                            pagerState.animateScrollToPage(index)
                                        }
                                    },
                                )
                            }
                        }
                    )

                    HorizontalPager(
                        modifier = Modifier.fillMaxSize(),
                        count = tabData.size,
                        state = pagerState,
                    ) { tabId ->
                        when (tabId) {
                            0 -> Tab1(scrollBehavior = scrollBehavior)
                            1 -> Tab2(scrollBehavior = scrollBehavior)
                        }
                    }
                }
            )
        }
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Tab1(scrollBehavior: TopAppBarScrollBehavior) {
    // List items
    val listItems = listOf(
        "test 1 tab 1",
        "test 2 tab 1",
        "test 3 tab 1",
        "test 4 tab 1",
        "test 5 tab 1",
        "test 6 tab 1",
        "test 7 tab 1",
        "test 8 tab 1",
        "test 9 tab 1",
        "test 10 tab 1",
        "test 11 tab 1",
        "test 12 tab 1",
    )

    val listState = rememberLazyListState()

    LazyColumn(
        modifier = Modifier
            .fillMaxWidth()
            .nestedScroll(scrollBehavior.nestedScrollConnection),
        state = listState,
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        content = {
            items(items = listItems) { item ->
                Card(
                    modifier = Modifier
                        .height(80.dp)
                        .fillMaxWidth(),

                    content = { Text(text = item) }
                )
            }
        }
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Tab2(scrollBehavior: TopAppBarScrollBehavior) {
    // List items
    val listItems = listOf(
        "test 1 tab 2",
        "test 2 tab 2",
        "test 3 tab 2",
        "test 4 tab 2",
        "test 5 tab 2",
        "test 6 tab 2",
        "test 7 tab 2",
        "test 8 tab 2",
        "test 9 tab 2",
        "test 10 tab 2",
        "test 11 tab 2",
        "test 12 tab 2",
    )

    val listState = rememberLazyListState()

    LazyColumn(
        modifier = Modifier
            .fillMaxWidth()
            .nestedScroll(scrollBehavior.nestedScrollConnection),
        state = listState,
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        content = {
            items(items = listItems) { item ->
                Card(
                    modifier = Modifier
                        .height(80.dp)
                        .fillMaxWidth(),

                    content = { Text(text = item) }
                )
            }
        }
    )
}

Result:

enter image description here

Bissextile answered 26/1, 2023 at 21:0 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.