11

I'm starting to learn about Jetpack Compose. I put together this app where I explore different day-to-day use cases, each of the feature modules within this project is supposed to tackle different scenarios.

One of this feature modules – the chatexample feature module, tries to implement a simple ViewPager where each of the pages is a Fragment, the first page "Messages" is supposed to display a paginated RecyclerView wrapped around a SwipeRefreshLayout. Now, the goal is to implement all this using Jetpack Compose. This is the issue I'm having right now:

enter image description here

The PullRefreshIndicator that I'm using to implement the Pull-To-Refresh action works as expected and everything seems pretty straightforward so far, but I cannot figure out why the ProgresBar stays there on top.

So far I've tried; Carrying on the Modifier from the parent Scaffold all the way through. Making sure I explicitly set the sizes to fit the max height and width. Add an empty Box in the when statement - but nothing has worked so far, I'm guessing I could just remove the PullRefreshIndicator if I see that the ViewModel isn't supposed to be refreshing, but I don't think that's the right thing to do.

To quickly explain the Composables that I'm using here I have:

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

Honestly, I don't get how the PullRefreshIndicator that is in a completely different Composable (ChatExampleScreen) gets to overlap with the ScrollableTabRow that is outside.

Hope this makes digesting the UI a bit easier. Any tip, advice, or recommendation is appreciated. Thanks!

Edit: Just to be completely clear, what I'm trying to achieve here is to have a PullRefreshIndicator on each page. Something like this:

enter image description here

On each page, you pull down, see the ProgressBar appear, and when it is done, it goes away, within the same page. Not overlapping with the tabs above.

4gus71n
  • 3,717
  • 3
  • 39
  • 66
  • 1
    i faced the same problem a couple of days ago . By using Zindex i encountered this one set tabindicator Zindex as positive integer and Zindex of RefreshIndicator any negative integer test it. Hope this helps – Syed Ibrahim Nov 29 '22 at 04:02
  • add your code too so that we change it and test it – Syed Ibrahim Nov 30 '22 at 04:06
  • @SyedIbrahim – There's a link to a GH repo up there. Let me share it here: https://github.com/4gus71n/TheOneApp – 4gus71n Nov 30 '22 at 13:53
  • @4gus71n, just a clarification, are you sure ("sure" might not be the right word) or are you decided in your architecture? that your `ViewModel` is being shared by the `Tabs`? – z.g.y Dec 01 '22 at 14:04
  • @z.y Hey – No, my idea was to have a ViewPager with a TabLayout and each page being a completely independent screen. I re-used the `ChatExampleScreen` because I was too lazy to create yet another Composable haha. – 4gus71n Dec 02 '22 at 14:19
  • hmmmm, I thought everything was over, I didnt expect it to be more painful though ^_^, jk! , lemme try, but since we already established a good starting point, you might even solve it on your own! – z.g.y Dec 02 '22 at 14:31
  • @z.y – Don't overthink it much haha – the only thing I'm trying to figure out is how to have the PullRefreshIndicator work as expected on each page. Using the same or different ViewModels is the same. Let me know if the screen capture I put there doesn't make sense. – 4gus71n Dec 02 '22 at 14:37
  • @4gus71n yep! it makes sense, let me try to squeeze a bit more til the last drop, ^_^, worst case scenario the last thing I can argue is have a shared ViewModel, and just have those different tabs observe a specific state using the structure in my answer, and return a flag/enum to the PullRefresh scope what Tab is currently selected/active – z.g.y Dec 02 '22 at 14:41
  • Oh yes! before I try to become dry, would you be satisfied if we can keep the structure like my answer, and tell the `PullRefresh` scope which `Tab` is actually doing the pull? since you said "the only thing I'm trying to figure out is how to have the PullRefreshIndicator work as expected on each page". I'm thinking of having an enum that will represent each tab, and provide it to pull refresh and just do a when statement to call the appropriate viewModel function – z.g.y Dec 02 '22 at 14:43
  • @z.y Yeah, totally. Oh so you are saying that because these two `ChatExampleScreen` pages use the same `ViewModel` that's why the PullRefreshIndicator doesn't go away or renders properly? Uhm! Didn't know about that! – 4gus71n Dec 02 '22 at 14:46

6 Answers6

5

A comparatively easier solution in my case was to simply give the Box that contains my vertically scrollable Composable and my PullRefreshIndicator a zIndex of -1f:

Box(Modifier.fillMaxSize().zIndex(-1f)) {
    LazyColumn(...)
    PullRefreshIndicator(...)
}

And that already did the trick for me. I have a very similar setup to the OP, a Scaffold containing a ScrollableTabRow and a HorizontalPager with refreshable lists on the individual tabs.

Dominik G.
  • 610
  • 3
  • 7
  • 1
    Def this is the tidiest solution so far. Do you have a reason behind using the zIndex? How did you figure out this solution? – 4gus71n Dec 19 '22 at 22:19
  • 2
    The z indices by default are determined by the order in which the Composables are declared (the one declared latest has the highest zIndex and gets drawn on top). The accepted solution proposed here aimed at making the zIndex of the PullRefreshIndicator lower than the ScrollableTabRow by declaring it first but using a ConstraintLayout instead of a Column to still position the two correctly. So I figured setting the zIndex manually could achieve the same result without restructuring my layout and it worked. – Dominik G. Dec 20 '22 at 08:07
  • @4gus71n great solution and explanation, code working fun but when I pull down and release the indicator remains and is not disappearing as expectd. Any leads? – Tonnie Jan 29 '23 at 18:07
5

There is yet another solution to this problem, which is using a .clipToBounds() modifier over the tab content container.

Logain
  • 4,259
  • 1
  • 23
  • 32
  • I'm marking this as the accepted answer, the previous solution got me into a `Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed` error. – 4gus71n Mar 25 '23 at 17:41
2

I want to leave my first answer as I feel it will still be useful to future readers, so heres another one you might consider.

One of the Box in the tabs has a scroll modifier though, because according to the Accompanist Docs and the actual functionality.

… The content needs to be 'vertically scrollable' for SwipeRefresh() to be able to react to swipe gestures. Layouts such as LazyColumn are automatically vertically scrollable, but others such as Column or LazyRow are not. In those instances, you can provide a Modifier.verticalScroll modifier…

It's from accompanist documentation about the migration of the API but it still applies to this current one in compose framework.

The way I understand it is a scroll event should be present for the PullRefresh to get activated manually (i.e a layout/container with a vertical scroll modifier or a LazyColumn), something that will consume a drag/swipe event in the screen.

Here's the short working sample. All of these are copy-and-paste-able.

Activity:

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

Some data classes:

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

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

ViewModel:

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

Tab Screen Composables:

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

Finally, the composable that contains the PullRefresh and the Pager/Tab components, and all of them are direct children of a ConstraintLayout. So to achieve a PullRefresh behind the Tabs but still on top of the HorizontalPager, first I had to put the HorizontalPager as the first child, the PullRefresh as the second and the Tabs as the last one, constraining them accordingly to preserve the visual arrangement of a Tab Pager.

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

output:

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>
z.g.y
  • 5,512
  • 4
  • 10
  • 36
  • 1
    So, there's no way to actually move the PullRefreshIndicator to within each page, like in the screenshots I posted? – 4gus71n Dec 02 '22 at 21:44
  • I mean, I see in that gif up there that the ProgressBar still overlaps with the TabLayouts – 4gus71n Dec 02 '22 at 21:45
  • @4gus71n please see my updated answer using `ConstraintLayout` – z.g.y Dec 03 '22 at 11:44
  • Thanks for all the help with this issue – just one last thing, out of curiosity, what is your take on Jetpack Compose? I mean, this whole thing would have been much more simpler with XML I don't see that Jetpack Compose gives us a solution that requires less code or is more understandable/ – 4gus71n Dec 03 '22 at 22:28
  • 1
    TBH even with my limitted skillset in compose, I'm already encountering alot of its nuances and challenges, you can look up my recent encounters, LazyColumn first item animations, Toast causing unwanted recomopositions just to name a two, but considering J.C being around for a couple of years only vs XML being an ancient one, J.C will come a long way.., IMO its just difficult at first but once you get the hang of it, once you jetpack you never go back – z.g.y Dec 04 '22 at 01:39
  • regarding with the amount of code, my take on this is one is not just with compose but declarative in general, I think even with react or flutter the same thing is expected, since all things are decalred/coded in one place – z.g.y Dec 04 '22 at 12:30
1

I think there's nothing wrong with the PullRefresh api and the Compose/Accompanist Tab/Pager api being used together, it seems like the PullRefresh is just respecting the placement structure of the layout/container it is put into.

Consider this code, no tabs, no pager, just a simple set-up of widgets that is identical to your set-up

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

What it looks like.

enter image description here

The PullRefresh is placed inside a component(Box) that is placed below another component in a Column vertical placement, and since it's below another widget, its initial position will not be hidden like the image sample.

With your set-up, since I noticed that the ViewModel is being shared by the tabs and also the reason why I was confirming if you are decided with your architecture is because the only fix I can think of is moving the PullRefresh up in the sequence of the composable widgets.

First changes I made is in your ChatExampleScreen composable, which ended up like this, all PullRefresh components are removed.

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

and in your Activity I moved all the setContent{…} scope into another function named ChatTabsContent and placed everything inside it including the PullRefresh components.

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

which ended up like this

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

Result:

enter image description here

Structural changes.

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

I haven't explored this API yet, but it looks like it should be used directly in a z-oriented layout/container parent such as Box as the last child.

z.g.y
  • 5,512
  • 4
  • 10
  • 36
  • – Thanks a lot for the very detailed answer. Question; What I was trying to achieve here at first was to have a PullRefreshIndicator on each of the ChatExampleScreen pages. I see that now the ProgressBar shows on top of the ScrollableTabRow is there a way to have the PullRefreshIndicator placed within each of the pages? Sorry, I should have made that clear before. Let me know if it doesn't make sense. – 4gus71n Dec 02 '22 at 14:24
  • I'm going to update the question and add a capture, just to be completely clear. – 4gus71n Dec 02 '22 at 14:25
  • 1
    Done. Let me know if it doesn't makes sense. – 4gus71n Dec 02 '22 at 14:32
1

I just want to share more details about the issue here and what the solution is. I appreciate a lot the solutions shared above and these were definitely key to figuring the problem out.

The bare-minimum solution here is to replace the Box with a ConstraintLayout in the ChatScreenExample composable:

enter image description here

Why? Because as @z.y shared above the PullRefreshIndicator needs to be contained on a "vertically scrollable" composable, and while the Box composable can be set with the vericalScroll() modifier we need to make sure we constraint the height of the content, that's why we had to change to a ConstraintLayout.

Feel free to correct me if I'm missing something.

4gus71n
  • 3,717
  • 3
  • 39
  • 66
0

No need of constraintLayout , just use Scaffold()

ASHIS
  • 1
  • 2
  • 1
    Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community May 25 '23 at 01:40