HorizontalPager与位于另一个LazyColumn内部的LazyColumn - Jetpack Compose

14

我想要一个类似TikTok的个人资料界面效果。 顶部是ProfilPictureusername,下方是一个带有TabRowPostsDraftsLikesFavorites)的stickyHeader,再下方是一个带有4个屏幕(PostsDraftsLikesFavorites)的HorizontalPager,每个屏幕都包含一个列表。

如果我在Compose中构建这个,会崩溃,因为我不能把两个LazyColumns嵌套在一起。

以下是我尝试实现的简短版本:

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

例如,在PostList()组合中有:

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

这是我遇到的崩溃:

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.

编辑:LazyColumn 子元素设置固定高度可以避免应用崩溃,但并不是很令人满意的解决方案。 当 HorizontalPager 中的 4 个列表大小不同时,会出现奇怪和有缺陷的行为,看起来也不对。 我尝试的另一件事是使用 FlowRow 替换 LazyColumn,这似乎也可以解决崩溃,但在这里我也得到了奇怪的行为,HorizontalPager 中的列表同步滚动,这不是我想要的。

HorizontalPager 是使这个任务如此困难的原因,在没有它的情况下,这不是问题。

这是测试项目: https://github.com/DaFaack/TikTokScrollBehaviourCompose

这是当我给LazyColumn一个固定高度2500.dp时的外观,只有这样大的高度才能实现所需的滚动行为。 这里的缺点是即使列表为空,它也有2500的高度,这会导致糟糕的用户体验,因为它允许用户滚动,即使列表为空。

enter image description here


5
请查看此视频片段,了解原因和可能的解决方案。 - Phil Dukhov
@PylypDukhov 谢谢,我编辑了我的答案并解释了为什么在这种情况下设置固定高度不起作用。 - HavanaSun
2个回答

19
在这种情况下,在外层使用可滚动的 Column 而不是 LazyColumn 更容易。
这样应该能实现你想要的效果:
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")
            }
        }

    }
}

1
嘿,谢谢。这是一个非常干净的解决方案,而且按预期工作。 是否可以将标题保留在第一项上方? 因此,当用户向上滚动到顶部时,标题应该只弹出。(就像我在问题中发布的gif一样)。 - HavanaSun
1
@HavanaSun 我已经更新了代码。通常这种行为可以通过自定义的“NestedScrollConnection”来实现。 - H.D.
非常感谢,它完美地运行了。我差点放弃不使用HorizontalPager。 我发现nestedScrollConnections的文档很难理解。 6小时后我会给你赏金奖励。 - HavanaSun
这实际上是一个干净的实现方式,但如果在向上滚动时,我也想要标题文本滑到顶部栏,而向下滚动时,应该从顶部栏隐藏标题文本。请建议如何实现。 - prat
有没有一种方法可以在第一列中实现 rememberLazyListState() 而不是 rememberScrollState(),因为我想获取一些像第一个项目偏移这样的方法? - Svilen Rusev
显示剩余2条评论

3

使用 Material3 实现该行为的简单方法

  1. 打开 build.gradle 并导入 accompanist pager
    自从 Compose 1.4 版本,Pager 已经成为 Compose 基础的一部分
  2. 使用 PagerStateCoroutineScopeScrollBehaviorTabRowHorizontalPager 创建 PagerScreen,您可以使用由 TopAppBarDefaults 提供的不同的 scrolBehaviors 或实现自定义的 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. 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) }
                )
            }
        }
    )
}

完整代码:

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

结果:

enter image description here


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接