下拉刷新指示器与可滚动选项卡行重叠。

11
我开始学习Jetpack Compose。我制作了这个应用程序,在其中探索不同的日常使用情况,该项目中的每个特性模块都旨在处理不同的场景。
其中一个特性模块-chatexample特性模块,尝试实现一个简单的ViewPager,其中每个页面都是一个Fragment,第一页“消息”应该显示一个分页的RecyclerView,包装在SwipeRefreshLayout中。现在,目标是使用Jetpack Compose实现所有这些。这是我现在遇到的问题:

enter image description here

我正在使用PullRefreshIndicator来实现下拉刷新操作,一切都很顺利,但是我无法弄清楚为什么进度条会停留在顶部。

到目前为止,我尝试了以下方法:从父级Scaffold一路传递Modifier。确保明确设置大小以适应最大高度和宽度。在when语句中添加一个空的Box,但是迄今为止什么都没有起作用,我猜如果我看到ViewModel不应该刷新,那么可以删除PullRefreshIndicator,但我认为这不是正确的做法。

为了快速解释我在这里使用的组件,我有:

<Surface>
   <Scaffold> // Set with a topBar
       <Column>
           <ScrollableTabRow>
              <Tab/> // Set for the first "Messages" tab
              <Tab/> // Set for the second "Dashboard" tab
           </ScrollableTabRow>
           <HorizontalPager>
                // ChatExampleScreen
                <Box> // A Box set with the pullRefresh modifier
                   // Depending on the ChatExamleViewModel we might pull different composables here
                   </PullRefreshIndicator>
                </Box>
                // Another ChatExampleScreen for the second tab
           </HorizontalPager>
       </Column>
   <Scaffold> 
</Surface>

说实话,我不明白完全不同的Composable(ChatExampleScreen)中的PullRefreshIndicator如何与外部的ScrollableTabRow重叠。希望这能让UI更易理解。欢迎任何提示、建议或推荐。谢谢!
编辑:为了完全清楚,我在这里尝试实现每个页面都有一个PullRefreshIndicator。就像这样:

enter image description here

在每个页面上,您向下拉动,会看到进度条出现,完成后它会消失,在同一页内。不与上面的标签重叠。

1
我几天前也遇到了同样的问题。通过使用Zindex,我解决了这个问题。将TabIndicator的Zindex设置为正整数,将RefreshIndicator的Zindex设置为任何负整数进行测试。希望这可以帮到你。 - Syed Ibrahim
添加您的代码以便我们更改并测试它 - Syed Ibrahim
@SyedIbrahim - 上面有一个指向 GH 存储库的链接。让我在这里分享它:https://github.com/4gus71n/TheOneApp - 4gus71n
@4gus71n,只是想澄清一下,你确定(“确定”可能不是正确的词)还是已经决定了你的架构?你的ViewModelTabs共享? - z.g.y
@z.y 是的,完全正确。哦,你是说因为这两个“ChatExampleScreen”页面使用了相同的“ViewModel”,所以下拉刷新指示器才无法消失或正确渲染?嗯!我不知道这一点! - 4gus71n
显示剩余5条评论
6个回答

5
在我这种情况下,相对较容易的解决方法是给包含我的可垂直滚动Composable和PullRefreshIndicator的Box一个-1f的zIndex:
Box(Modifier.fillMaxSize().zIndex(-1f)) {
    LazyColumn(...)
    PullRefreshIndicator(...)
}

这对我已经起作用了。我的设置与原帖中非常相似,包含一个ScrollableTabRow和一个HorizontalPager,各个选项卡上都有可刷新列表的Scaffold。


1
这是目前最简洁的解决方案。你使用zIndex的原因是什么?你是如何想出这个解决方案的? - 4gus71n
2
默认情况下,z-index由声明组合件的顺序确定(最后声明的具有最高的zIndex并在顶部绘制)。此处提出的可接受解决方案旨在通过首先声明PullRefreshIndicator但使用ConstraintLayout而不是Column来使其zIndex低于ScrollableTabRow,以便正确定位这两个内容。因此,我想手动设置zIndex可以实现相同的结果,而无需重构我的布局,它真的有效。 - Dominik G.
@4gus71n,解决方案和解释非常棒,代码运行得很好。但是当我向下拉并释放时,指示器仍然存在,没有如预期般消失。有任何线索吗? - Tonnie

5

还有另一种解决此问题的方法,即在选项卡内容容器上使用.clipToBounds()修饰符。


我将此标记为已接受的答案,之前的解决方案导致了一个“垂直可滚动组件被测量为具有无限最大高度约束,这是不允许的”错误。 - 4gus71n

2

我想保留我的第一个答案,因为我觉得它对未来的读者仍然有用,所以这里是另一个你可能考虑的答案。

选项卡中的一个Box具有滚动修饰符,因为根据Accompanist Docs和实际功能。

…内容需要“垂直可滚动”,才能让SwipeRefresh()能够响应滑动手势。像LazyColumn这样的布局自动垂直可滚动,但其他布局如Column或LazyRow则不行。在这些情况下,您可以提供Modifier.verticalScroll修饰符…

这是accompanist文档关于API迁移的内容,但它仍适用于compose框架中的当前版本。

我理解的方式是,PullRefresh需要存在滚动事件才能手动激活(即具有垂直滚动修饰符或LazyColumn的布局/容器),这将在屏幕上消耗拖动/滑动事件。

这是简短的工作示例。所有这些都可以复制并粘贴。

活动:

class PullRefreshActivity: ComponentActivity() {

    private val viewModel: MyViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    Scaffold(
                        modifier = Modifier.fillMaxSize(),
                        topBar = { TopAppBarSample() }
                    ) {
                        MyScreen(
                            modifier = Modifier.padding(it),
                            viewModel = viewModel
                        )
                    }
                }
            }
        }
    }
}

一些数据类:

data class MessageItems(
    val message: String = "",
    val author: String = ""
)

data class DashboardBanner(
    val bannerMessage: String = "",
    val content: String = ""
)

视图模型:

class MyViewModel: ViewModel() {

    var isLoading by mutableStateOf(false)

    private val _messageState = MutableStateFlow(mutableStateListOf<MessageItems>())
    val messageState = _messageState.asStateFlow()

    private val _dashboardState = MutableStateFlow(DashboardBanner())
    val dashboardState = _dashboardState.asStateFlow()

    fun fetchMessages() {

        viewModelScope.launch {
            isLoading = true

            delay(2000L)

            _messageState.update {
                it.add(
                    MessageItems(
                        message = "Hello First Message",
                        author = "Author 1"
                    ),
                )
                it.add(
                    MessageItems(
                        message = "Hello Second Message",
                        author = "Author 2"
                    )
                )

                it
            }
            isLoading = false
        }
    }

    fun fetchDashboard() {

        viewModelScope.launch {
            isLoading = true

            delay(2000L)

            _dashboardState.update {
                it.copy(
                    bannerMessage = "Hello World!!",
                    content = "Welcome to Pull Refresh Content!"
                )
            }
            isLoading = false
        }
    }
}

选项卡屏幕组件:

@Composable
fun MessageTab(
    myViewModel : MyViewModel
) {
    val messages by myViewModel.messageState.collectAsState()

    LazyColumn(
        modifier = Modifier.fillMaxSize()
    ) {
        items(messages) { item ->
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .border(BorderStroke(Dp.Hairline, Color.DarkGray)),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(text = item.message)
                Text(text = item.author)
            }
        }
    }
}

@Composable
fun DashboardTab(
    myViewModel: MyViewModel
) {

    val banner by myViewModel.dashboardState.collectAsState()

    Box(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState()),
        contentAlignment = Alignment.Center
    ) {
        Column {
            Text(
                text = banner.bannerMessage,
                fontSize = 52.sp
            )

            Text(
                text = banner.content,
                fontSize = 16.sp
            )
        }
    }
}

最后,包含PullRefreshPager/Tab组件的可组合项,它们都是ConstraintLayout的直接子级。因此,为了实现在Tabs上方但仍在HorizontalPager后面的PullRefresh,首先我必须将HorizontalPager作为第一个子元素,将PullRefresh作为第二个子元素,将Tabs作为最后一个子元素,并相应地约束它们以保持选项卡分页器的视觉排列。
@OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class)
@Composable
fun MyScreen(
    modifier : Modifier = Modifier,
    viewModel: MyViewModel
) {
    val refreshing = viewModel.isLoading
    val pagerState = rememberPagerState()

    val pullRefreshState = rememberPullRefreshState(
        refreshing = refreshing,
        onRefresh = {
            when (pagerState.currentPage) {
                0 -> {
                    viewModel.fetchMessages()
                }
                1 -> {
                    viewModel.fetchDashboard()
                }
            }
        },
        refreshingOffset = 100.dp // just an arbitrary offset where the refresh will animate
    )

    ConstraintLayout(
        modifier = modifier
            .fillMaxSize()
            .pullRefresh(pullRefreshState)
    ) {
        val (pager, pullRefresh, tabs) = createRefs()

        HorizontalPager(
            count = 2,
            state = pagerState,
            modifier = Modifier.constrainAs(pager) {
                top.linkTo(tabs.bottom)
                start.linkTo(parent.start)
                end.linkTo(parent.end)
                bottom.linkTo(parent.bottom)
                height = Dimension.fillToConstraints
            }
        ) { page ->
            when (page) {
                0 -> {
                    MessageTab(
                        myViewModel = viewModel
                    )
                }
                1 -> {
                    DashboardTab(
                        myViewModel = viewModel
                    )
                }
            }
        }

        PullRefreshIndicator(
            modifier = Modifier.constrainAs(pullRefresh) {
                top.linkTo(parent.top)
                start.linkTo(parent.start)
                end.linkTo(parent.end)
            },
            refreshing = refreshing,
            state = pullRefreshState,
        )

        ScrollableTabRow(
            modifier = Modifier.constrainAs(tabs) {
                top.linkTo(parent.top)
                start.linkTo(parent.start)
                end.linkTo(parent.end)
            },
            selectedTabIndex = pagerState.currentPage,
            indicator = { tabPositions ->
                TabRowDefaults.Indicator(
                    modifier = Modifier.tabIndicatorOffset(
                        currentTabPosition = tabPositions[pagerState.currentPage],
                    )
                )
            },
        ) {
            Tab(
                selected = pagerState.currentPage == 0,
                onClick = {},
                text = {
                    Text(
                        text = "Messages"
                    )
                }
            )

            Tab(
                selected = pagerState.currentPage == 1,
                onClick = {},
                text = {
                    Text(
                        text = "Dashboard"
                    )
                }
            )
        }
    }
}

输出:

enter image description here

<Surface>
    <Scaffold>
        <ConstraintLayout>

            // top to ScrollableTabRow's bottom
            // start, end, bottom to parent's start, end and bottom
            // 0.dp (view), fillToConstraints (compose)
            <HorizontalPager>
                <PagerScreens/>
            </HorizontalPager>

            // top, start, end of parent
            <PullRefreshIndicator/>

            // top, start and end of parent
            <ScrollableTabRow>
                <Tab/> // Set for the first "Messages" tab
                <Tab/> // Set for the second "Dashboard" tab
            </ScrollableTabRow>
        </ConstraintLayout>
   <Scaffold>
</Surface>

1
那么,没有办法将PullRefreshIndicator实际移动到每个页面内部,就像我发布的截图中那样吗? - 4gus71n
我的意思是,我在上面的gif中看到进度条仍然与TabLayout重叠。 - 4gus71n
@4gus71n,请查看我的更新答案,使用ConstraintLayout - z.g.y
感谢您在这个问题上提供的所有帮助 - 只有一个好奇的问题,您对Jetpack Compose有什么看法?我的意思是,如果使用XML,整个事情会更简单。我认为Jetpack Compose并没有给我们提供需要更少代码或更易理解的解决方案。 - 4gus71n
1
说实话,尽管我在Compose方面的技能有限,但我已经遇到了很多它的细微差别和挑战,你可以查看我的最近经历,例如LazyColumn第一项动画、Toast导致不想要的重新组合等。但考虑到J.C只存在了几年而XML则是古老的,我认为J.C还有很长的路要走......在我看来,起初可能很困难,但一旦掌握了技巧,一旦使用Jetpack,就再也回不去了。 - z.g.y
关于代码量,我的看法是不仅仅是在compose中,而是在声明式编程中,我认为即使使用React或Flutter也期望达到相同的效果,因为所有东西都在一个地方声明/编码。 - z.g.y

1
我认为将PullRefresh api和Compose/Accompanist Tab/Pager api一起使用没有任何问题,看起来PullRefresh只是尊重它所放置的布局/容器的结构。
考虑以下代码,没有选项卡、分页器,只是一个简单的小部件设置,与您的设置完全相同。
Column(
        modifier = Modifier.padding(it)
    ) {

        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(80.dp)
                .background(Color.Blue)
        )

        val pullRefreshState = rememberPullRefreshState(
            refreshing = false,
            onRefresh = { viewModel.fetchMessages() }
        )

        Box(
            modifier = Modifier.pullRefresh(pullRefreshState)
        ) {

            PullRefreshIndicator(
                modifier = Modifier.align(Alignment.TopCenter),
                refreshing = false,
                state = pullRefreshState,
            )
        }
    }

它是什么样子的。

enter image description here

PullRefresh 放置在一个组件 (Box) 内,该组件位于 Column 垂直布局中另一个组件下方,由于它在另一个小部件下方,其初始位置不会像图像示例一样被隐藏。

根据您的设置,由于我注意到 ViewModel 被选项卡共享,这也是我确认您是否决定好了架构的原因,因为我所能想到的唯一修复方法是将 PullRefresh 向上移动到组合小部件的顺序中。

我做的第一个更改是在您的 ChatExampleScreen 组合中,最终它变成了这样,所有的 PullRefresh 组件都被删除了。

@Composable
fun ChatExampleScreen(
    chatexampleViewModel: ChatExampleViewModel,
    modifier: Modifier = Modifier
) {
    val chatexampleViewModelState by chatexampleViewModel.state.observeAsState()

    Box(
        modifier = modifier
            .fillMaxSize()
    ) {

        when (val result = chatexampleViewModelState) {
            is ChatExampleViewModel.State.SuccessfullyLoadedMessages -> {
                ChatExampleScreenSuccessfullyLoadedMessages(
                    chatexampleMessages = result.list,
                    modifier = modifier,
                )
            }
            is ChatExampleViewModel.State.NoMessagesFetched -> {
                ChatExampleScreenEmptyState(
                    modifier = modifier
                )
            }
            is ChatExampleViewModel.State.NoInternetConnectivity -> {
                NoInternetConnectivityScreen(
                    modifier = modifier
                )
            }
            else -> {
                // Agus - Do nothing???
                Box(modifier = modifier.fillMaxSize())
            }
        }
    }
}

在你的Activity中,我将所有的setContent{...}范围移动到另一个名为ChatTabsContent的函数中,并将包括PullRefresh组件在内的所有内容都放在该函数内部。

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ChatTabsContent(
    modifier : Modifier = Modifier,
    viewModel : ChatExampleViewModel
) {
    val chatexampleViewModelIsLoadingState by viewModel.isLoading.observeAsState()

    val pullRefreshState = rememberPullRefreshState(
        refreshing = chatexampleViewModelIsLoadingState == true,
        onRefresh = { viewModel.fetchMessages() }
    )

    Box(
        modifier = modifier
            .pullRefresh(pullRefreshState)
    ) {

        Column(
            Modifier
                .fillMaxSize()
        ) {
            val pagerState = rememberPagerState()

            ScrollableTabRow(
                selectedTabIndex = pagerState.currentPage,
                indicator = { tabPositions ->
                    TabRowDefaults.Indicator(
                        modifier = Modifier.tabIndicatorOffset(
                            currentTabPosition = tabPositions[pagerState.currentPage],
                        )
                    )
                }
            ) {
                Tab(
                    selected = pagerState.currentPage == 0,
                    onClick = { },
                    text = {
                        Text(
                            text = "Messages"
                        )
                    }
                )
                Tab(
                    selected = pagerState.currentPage == 1,
                    onClick = { },
                    text = {
                        Text(
                            text = "Dashboard"
                        )
                    }
                )
            }

            HorizontalPager(
                count = 2,
                state = pagerState,
                modifier = Modifier.fillMaxWidth(),
            ) { page ->
                when (page) {
                    0 -> {
                        ChatExampleScreen(
                            chatexampleViewModel = viewModel,
                            modifier = Modifier.fillMaxSize()
                        )
                    }
                    1 -> {
                        ChatExampleScreen(
                            chatexampleViewModel = viewModel,
                            modifier = Modifier.fillMaxWidth()
                        )
                    }
                }
            }
        }

        PullRefreshIndicator(
            modifier = Modifier.align(Alignment.TopCenter),
            refreshing = chatexampleViewModelIsLoadingState == true,
            state = pullRefreshState,
        )
    }
}

这最终变成了这样。
 setContent {
        TheOneAppTheme {
            // A surface container using the 'background' color from the theme
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colors.background
            ) {
                Scaffold(
                    modifier = Modifier.fillMaxSize(),
                    topBar = { TopAppBarSample() }
                ) {

                    ChatTabsContent(
                        modifier = Modifier.padding(it),
                        viewModel = viewModel
                    )
                }
            }
        }
    }

结果:

输入图像描述

结构性变化。

<Surface>
    <Scaffold> // Set with a topBar
        <Box>
            <Column>
                <ScrollableTabRow>
                    <Tab/> // Set for the first "Messages" tab
                    <Tab/> // Set for the second "Dashboard" tab
                </ScrollableTabRow>
                <HorizontalPager>
                    <Box/>
                </HorizontalPager>
            </Column>

            // pull refresh is now at the most "z" index of the 
            // box, overlapping the content (tabs/pager)
            <PullRefreshIndicator/> 
        </Box>
    <Scaffold>
</Surface>

我还没有探索过这个API,但看起来应该直接在一个z方向的布局/容器父级中使用,例如像Box作为最后一个子元素。


非常感谢您提供如此详细的答案。我的问题是:一开始我想在每个ChatExampleScreen页面上都有一个PullRefreshIndicator。现在我看到ProgressBar显示在ScrollableTabRow的顶部,是否有办法将PullRefreshIndicator放置在每个页面中?抱歉,我之前应该表述得更清楚。如果不清楚,请告诉我。 - 4gus71n
我将更新问题并添加截图,以确保完全清晰。 - 4gus71n
1
完成。如果不通顺请告诉我。 - 4gus71n

1
我只是想在这里分享更多关于这个问题的细节以及解决方案。我非常感激上面分享的解决方案,这些绝对是找出问题的关键。
这里的最基本解决方案是,在ChatScreenExample组合中,用ConstraintLayout替换Box

enter image description here

为什么?因为正如 @z.y 上面分享的那样,PullRefreshIndicator 需要包含在“垂直可滚动”的 composable 中,而 Box composable 虽然可以设置 vericalScroll() 修饰符,但我们需要确保限制内容的高度,这就是为什么我们不得不改用 ConstraintLayout 的原因。如果我漏了什么,请随时纠正我。

0
不需要使用 ConstraintLayout,只需使用 Scaffold()。

1
你的回答可以通过提供更多支持信息来改进。请编辑以添加进一步的细节,例如引用或文档,以便他人可以确认你的答案是正确的。您可以在帮助中心中找到有关如何编写良好答案的更多信息。 - Community

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