PullRefreshIndicator перекрывается с ScrollableTabRow

Я начинаю изучать Jetpack Compose. Я собрал это приложение, где я исследую различные повседневные варианты использования, каждый из функциональных модулей в рамках этого проекта должен решать разные сценарии.

Один из этих функциональных модулей — функциональный модуль chatexample, пытается реализовать простую ViewPager, где каждая из страниц представляет собой Fragment, а первая страница «Сообщения» должна отображать разбитый на страницы RecyclerView, обернутый вокруг SwipeRefreshLayout. Теперь цель — реализовать все это с помощью Jetpack Compose. Это проблема, которая у меня сейчас:

PullRefreshIndicator, который я использую для реализации действия Pull-To-Refresh, работает, как и ожидалось, и пока все кажется довольно простым, но я не могу понять, почему ProgresBar остается сверху.

До сих пор я пытался; Продолжая Modifier от родителя Scaffold до конца. Убедившись, что я явно установил размеры, соответствующие максимальной высоте и ширине. Добавьте пустой Box в оператор , когда - но пока ничего не сработало, я предполагаю, что могу просто удалить PullRefreshIndicator, если увижу, что ViewModel не должен обновляться, но я не думаю, что это правильный поступок.

Чтобы быстро объяснить Composables, которые я использую здесь, у меня есть:

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

Честно говоря, я не понимаю, как PullRefreshIndicator, который находится в совершенно другом Composable (ChatExampleScreen), может перекрываться с ScrollableTabRow, который снаружи.

Надеюсь, это немного облегчит восприятие пользовательского интерфейса. Любой совет, совет или рекомендация приветствуются. Спасибо! 🙇

Редактировать: Чтобы быть полностью ясным, я пытаюсь добиться того, чтобы на каждой странице был PullRefreshIndicator. Что-то вроде этого:

На каждой странице вы опускаете вниз, видите, как появляется ProgressBar, и когда это делается, он исчезает на той же странице. Не пересекается с вкладками выше.

я столкнулся с той же проблемой пару дней назад. Используя Zindex, я столкнулся с этим одним установленным tabindicator Zindex как положительное целое число, а Zindex of RefreshIndicator - с любым отрицательным целым числом. Надеюсь это поможет

Syed Ibrahim 29.11.2022 05:02

добавьте также свой код, чтобы мы его изменили и протестировали

Syed Ibrahim 30.11.2022 05:06

@SyedIbrahim - Там есть ссылка на репозиторий GH. Позвольте мне поделиться им здесь: github.com/4gus71n/TheOneApp

4gus71n 30.11.2022 14:53

@ 4gus71n, просто уточнение, вы уверены («уверен» может быть неправильным словом) или вы решили в своей архитектуре? что вашим ViewModel делятся Tabs?

z.g.y 01.12.2022 15:04

@z.y Эй, нет, моя идея заключалась в том, чтобы ViewPager с TabLayout и каждая страница была полностью независимым экраном. Я повторно использовал ChatExampleScreen, потому что мне было лень создавать еще один Composable, ха-ха.

4gus71n 02.12.2022 15:19

хммм, я думала, что все уже позади, хотя и не ожидала, что будет больнее ^_^, jk! , дайте мне попробовать, но поскольку мы уже установили хорошую отправную точку, вы даже можете решить ее самостоятельно!

z.g.y 02.12.2022 15:31

@z.y — Не заморачивайтесь, ха-ха — единственное, что я пытаюсь понять, — это как заставить PullRefreshIndicator работать должным образом на каждой странице. Использование одних и тех же или разных ViewModel — это одно и то же. Дайте мне знать, если снимок экрана, который я там разместил, не имеет смысла.

4gus71n 02.12.2022 15:37

@ 4gus71n ага! это имеет смысл, позвольте мне попытаться сжать немного больше до последней капли, ^_^, худший сценарий, последнее, что я могу утверждать, это иметь общую ViewModel, и просто иметь эти разные вкладки, наблюдающие определенное состояние, используя структуру в мой ответ и вернуть флаг/перечисление в область PullRefresh, какая вкладка в данный момент выбрана/активна

z.g.y 02.12.2022 15:41

О, да! прежде чем я попытаюсь стать сухим, вы были бы удовлетворены, если бы мы могли сохранить структуру, как в моем ответе, и сообщить PullRefresh прицелу, который Tab на самом деле выполняет притяжение? поскольку вы сказали: «Единственное, что я пытаюсь понять, это как заставить PullRefreshIndicator работать должным образом на каждой странице». Я думаю о том, чтобы иметь перечисление, которое будет представлять каждую вкладку, и предоставить его для обновления обновления и просто сделать оператор when, чтобы вызвать соответствующую функцию viewModel.

z.g.y 02.12.2022 15:43

@z.y Да, полностью. О, так вы говорите, что из-за того, что эти две ChatExampleScreen страницы используют одну и ту же ViewModel, поэтому PullRefreshIndicator не исчезает и не отображается должным образом? Мм! Не знал об этом!

4gus71n 02.12.2022 15:46
11
10
1 414
6
Перейти к ответу Данный вопрос помечен как решенный

Ответы 6

Я думаю, что нет ничего плохого в том, что 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,
            )
        }
    }

На что это похоже.

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 02.12.2022 15:24

Я собираюсь обновить вопрос и добавить захват, чтобы быть полностью ясным.

4gus71n 02.12.2022 15:25

Сделанный. Дайте мне знать, если это не имеет смысла.

4gus71n 02.12.2022 15:32

Я хочу оставить свой первый ответ, так как я чувствую, что он все еще будет полезен будущим читателям, так что вот еще один, который вы могли бы рассмотреть.

Одна из Box на вкладках имеет модификатор прокрутки, потому что согласно Документам аккомпаниатора и фактической функциональности.

… Контент должен быть «прокручиваемым по вертикали» для SwipeRefresh() чтобы иметь возможность реагировать на жесты смахивания. Такие макеты, как LazyColumn, автоматически прокручивается по вертикали, но другие, такие как Column или LazyRow нет. В таких случаях вы можете предоставить Модификатор Modifier.verticalScroll…

Это из документации аккомпаниатора о переносе API, но по-прежнему применимо к текущему фреймворку компоновки.

Насколько я понимаю, должно присутствовать событие прокрутки, чтобы 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 = ""
)

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

Компоненты экрана вкладок:

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

Наконец, компонуемый, который содержит компоненты PullRefresh и Pager/Tab, и все они являются прямыми потомками ConstraintLayout. Таким образом, чтобы получить PullRefresh позади Tabs, но все же поверх HorizontalPager, сначала мне пришлось поместить 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"
                    )
                }
            )
        }
    }
}

выход:

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

Итак, нет никакого способа фактически переместить PullRefreshIndicator на каждую страницу, как на скриншотах, которые я разместил?

4gus71n 02.12.2022 22:44

Я имею в виду, что я вижу на этой гифке, что ProgressBar все еще перекрывается с TabLayouts.

4gus71n 02.12.2022 22:45

@ 4gus71n, пожалуйста, посмотрите мой обновленный ответ, используя ConstraintLayout

z.g.y 03.12.2022 12:44

Спасибо за помощь в решении этой проблемы — и последнее, из любопытства, что вы думаете о Jetpack Compose? Я имею в виду, что все это было бы намного проще с XML. Я не вижу, что Jetpack Compose дает нам решение, которое требует меньше кода или является более понятным/

4gus71n 03.12.2022 23:28

TBH, даже с моим ограниченным набором навыков в составлении, я уже сталкиваюсь с множеством его нюансов и проблем, вы можете посмотреть мои недавние встречи, анимацию первого элемента LazyColumn, Toast, вызывающий нежелательные рекомпозиции, просто чтобы назвать два, но учитывая, что JC был рядом для всего пару лет по сравнению с XML, который является древним, JC пройдёт долгий путь.., IMO, это просто сначала сложно, но как только вы освоитесь, как только вы получите реактивный ранец, вы никогда не вернетесь назад

z.g.y 04.12.2022 02:39

что касается количества кода, мой взгляд на это не только с композицией, но и с декларативностью в целом, я думаю, что даже с реакцией или флаттером ожидается то же самое, поскольку все вещи декалируются/кодируются в одном месте.

z.g.y 04.12.2022 13:30

Я просто хочу поделиться более подробной информацией о проблеме здесь и о том, каково ее решение. Я очень ценю решения, представленные выше, и они определенно были ключом к выяснению проблемы.

Минимальное решение здесь — заменить Box на ConstraintLayout в составном ChatScreenExample:

Почему? Поскольку, как @z.y поделился выше, PullRefreshIndicator должен содержаться в компоновке с «вертикальной прокруткой», и хотя компоновка Box может быть установлена ​​с помощью модификатора vericalScroll(), нам нужно убедиться, что мы ограничиваем высоту содержимого, поэтому нам пришлось изменить на ConstraintLayout.

Не стесняйтесь исправлять меня, если я что-то упустил.

Сравнительно более простым решением в моем случае было просто присвоить Box, содержащему мой Composable с вертикальной прокруткой, и мой PullRefreshIndicator zIndex, равный -1f:

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

И это уже помогло мне. У меня очень похожая установка на OP, Scaffold, содержащий ScrollableTabRow и HorizontalPager с обновляемыми списками на отдельных вкладках.

Определенно, это самое аккуратное решение на данный момент. У вас есть причина использовать zIndex? Как вы поняли это решение?

4gus71n 19.12.2022 23:19

Z-индексы по умолчанию определяются порядком, в котором объявляются Composables (объявленный последним имеет самый высокий zIndex и рисуется сверху). Принятое решение, предложенное здесь, направлено на то, чтобы сделать zIndex PullRefreshIndicator ниже, чем ScrollableTabRow, объявив его первым, но используя ConstraintLayout вместо Column, чтобы по-прежнему правильно позиционировать два. Поэтому я решил, что установка zIndex вручную может привести к тому же результату без реструктуризации моего макета, и это сработало.

Dominik G. 20.12.2022 09:07

@ 4gus71n отличное решение и объяснение, код работает весело, но когда я опускаю и отпускаю, индикатор остается и не исчезает, как ожидалось. Есть зацепки?

Tonnie 29.01.2023 19:07
Ответ принят как подходящий

Есть еще одно решение этой проблемы — использование модификатора .clipToBounds() над контейнером содержимого вкладки.

Я отмечаю это как принятый ответ, предыдущее решение привело к ошибке Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed.

4gus71n 25.03.2023 18:41

Нет необходимости в limitedLayout , просто используйте Scaffold()

Другие вопросы по теме