Pull to refresh
1023.35
OTUS
Цифровые навыки от ведущих экспертов

Ликбез по вложенной прокрутке в Jetpack Compose

Reading time11 min
Views2K
Original author: Levi Albuquerque

В основе большинства приложений для Android лежат списки. За многие годы появилось множество различных решений, реализующих взаимодействие других компонентов пользовательского интерфейса со списками — например, как панель приложения реагирует на прокрутку списка или как вложенные списки взаимодействуют друг с другом. Вы когда-нибудь сталкивались с ситуацией, когда один список находится внутри другого, и, прокручивая внутренний список до конца, вы хотите, чтобы внешний список продолжил движение? Это классический пример вложенной прокрутки!

Вложенная прокрутка (nested scrolling) — это система, в которой компоненты прокрутки, находящиеся друг в друге, могут передавать друг-другу свои дельты прокрутки (scrolling deltas), чтобы организовать согласованную совместную работу. Например, в системе View вложенная прокрутка реализуется на основе NestedScrollingParent и NestedScrollingChild. Эти конструкции используются такими компонентами, как NestedScrollView и RecyclerView, для реализации разных вариантов вложенной прокрутки. Вложенная прокрутка является ключевой функцией во многих фреймворках пользовательского интерфейса. В этой статье мы рассмотрим, как с ней справляется Jetpack Compose.

Давайте рассмотрим пример, в котором нам бы не помешала система вложенной прокрутки. В рамках этого примера мы создадим в нашем приложении эффект разворачивающейся панели (collapsing app bar) в верхней части приложения. Разворачивающаяся панель будет взаимодействовать со списком для создания эффекта свертывания — в любой момент, если панель развернута, прокрутка списка вверх приведет к его свертыванию. Аналогично, если панель свернута, прокрутка списка вниз приведет к его раскрытию. Вот пример того, как это должно выглядеть:

Предположим, что наше приложение состоит из верхней панели и списка (этот дизайн характерен для множества несложных приложений):

Примечание: Аналогичного поведения можно добиться, используя параметр scrollBehavior TopAppBar в Material 3, но мы напишем части этой логики самостоятельно, чтобы проиллюстрировать работу системы вложенной прокрутки.

val AppBarHeight = 56.dp
val Purple40 = Color(0xFF6650a4)

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   Box {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleInitialCode.kt  на GitHub

Этот код отобразит следующее:

По умолчанию между верхней панелью и списком нет никакой связи. Если мы прокручиваем список, панель остается статичной. Одним из вариантов решений было бы сделать панель частью самого списка, но, как мы вскоре увидим, оно не работает. Прокрутив список вниз, мы должны будем прокрутить его обратно на самый верх, чтобы снова увидеть панель:

Столкнувшись с этой проблемой, мы поняли, что нам бы хотелось сохранить иерархию для верхней панели (вне списка). Однако мы также хотим реагировать на изменения прокрутки в списке — то есть заставить компонент реагировать на прокрутку списка. Это прямо указывает на то, что решением для этой проблемы может оказаться система вложенной прокрутки в Compose.

Система вложенной прокрутки — хорошее решение, если вам нужна координация между компонентами, когда один или несколько из них прокручиваются и они иерархически связаны (в приведенном выше случае верхняя панель и список имеют общего родителя). Така система связывает контейнеры прокрутки (scrolling containers) и дает нам возможность взаимодействовать с дельтами прокрутки, которые распространяются/передаются между ними.

Цикл вложенной прокрутки

Давайте вернемся немного назад и обсудим, как выглядит общая схема системы вложенной прокрутки. Цикл вложенной прокрутки — это поток дельт прокрутки (изменений), которые передаются вверх и вниз по иерархическому дереву через все компоненты, которые могут быть частью системы вложенной прокрутки.

В качестве примера возьмем список. При обнаружении события жеста (gesture event), еще до того, как сам список начнет прокручиваться, дельты будут отправлены во вложенную систему прокрутки. Дельты, сгенерированные событием, пройдут через 3 фазы: пре-прокрутка (pre-scroll), потребление узлом (node consumption) и пост-прокрутка (post-scroll).

  • На этапе пре-прокрутки компонент, получивший дельты касания, отправит эти события самому старшему родителю в дереве иерархии. Затем дельта-события будут спускаться вниз, то есть дельты будут распространяться от самого корневого родителя вниз к дочернему компоненту, который инициировал цикл вложенной прокрутки. Это дает родителям вложенной прокрутки на протяжении всего этого пути (composable, использующим модификатор nestedScroll) возможность "сделать что-то" с дельтой, прежде чем сам узел сможет ее потребить.

Если мы вернемся к нашей диаграмме, то дочерний элемент (например, список), прокрученный на 10 пикселей, запустит процесс вложенной прокрутки. Будучи дочерним компонентом, он отправит 10 пикселей вверх по цепочке к самому корневому родителю, где во время фазы пре-прокрутки родителям будет предоставлена возможность использовать эти 10 пикселей самим:

Любой родитель на пути вниз к дочернему узлу, который инициировал процесс, имеет возможность потребить часть из 10 пикселей, а оставшаяся часть будет передана вниз по цепочке. Когда она достигнет дочернего компонента, мы перейдем к фазе потребления узлом. В этом примере родитель 1 решил потреблять 5 пикселей, поэтому для следующей фазы останется 5 пикселей.

  • На фазе потребления узлом уже сам узел использует дельту, которая не была использована его родителями. Именно в этот момент, например, список действительно начнет двигаться.

Во время этой фазы дочерний компонент может совершить всю оставшуюся прокрутку или ее часть. Все, что останется, будет отправлено обратно наверх для прохождения фазы пост-прокрутки. Дочерний компонент на нашей схеме использовал для перемещения только 2 пикселя, оставив 3 пикселя для следующей фазы.

  • Наконец, на этапе пост-прокрутки все, что не было потреблено самим узлом, будет снова отправлено его родителям на случай, если кто-то захочет использовать остатки дельты.

Фаза пост-прокрутки будет работать так же, как и фаза пре-прокрутки, где любой из родителей может совершить потребление.

Во время этой фазы родитель 2 потребляет оставшиеся 3 пикселя и передает оставшиеся 0 пикселей вниз по цепочке.

Аналогично, по завершению жеста перетаскивания намерение пользователя может быть преобразовано в скорость, которая будет использована для смахивания (fling) списка — то есть для его прокрутки с применением fling-анимации. Смахивание также является частью цикла вложенной прокрутки, и скорости, генерируемые событием перетаскивания (drag event), будут проходить через аналогичные фазы: пре-смахивания (pre-fling), потребления узлом и пост-смахивания (post-fling).

Хорошо, но как это связано с нашей первоначальной проблемой? Compose предоставляет набор инструментов, с помощью которых мы можем влиять на работу этих фаз и напрямую взаимодействовать с ними. В нашем случае, если в данный момент отображается верхняя панель, а мы прокручиваем список вверх, мы хотели бы установить приоритет на прокрутку панели. С другой стороны, если мы прокручиваем список вниз, а панель в данный момент не отображена, мы также хотим установить приоритет прокрутки на панель перед прокруткой самого списка. Это еще один намек на то, что система вложенной прокрутки может оказаться хорошим решением: наш сценарий использования заставляет нас делать что-то с дельтами прокрутки еще до прокрутки списка (как раз как в фазе пре-прокрутки выше).

Давайте же рассмотрим доступные нам инструменты.

Модификатор вложенной прокрутки

Если рассматривать цикл вложенной прокрутки как систему, работающую с цепочкой узлов, то модификатор вложенной прокрутки (nested scroll modifier) — это наш способ вклиниться в эту цепочку и влиять на данные (дельты прокрутки), которые по ней распространяются. Этот модификатор можно разместить в любом месте иерархии, и он будет взаимодействовать с экземплярами модификаторов вложенной прокрутки, расположенными выше по дереву, позволяя обмениваться информацией по этому каналу. Для взаимодействия с информацией, передаваемой по этому каналу, вы можете использовать NestedScrollConnection, который будет вызывать определенные колбеки в зависимости от фазы потребления. Давайте более подробно рассмотрим составные части этого модификатора:

  • NestedScrollConnection: Соединение — это способ реагировать на фазы цикла вложенной прокрутки. Это основной способ влияния на систему вложенной прокрутки. Оно состоит из 4 методов-колбек, каждый из которых представляет одну из фаз: pre/post-scroll и pre/post-fling. Каждый колбек также предоставляет информацию о распространяемой дельте:

1. available: Дельта, доступная для данной фазы.

2. consumed: Дельта, потребленная в предыдущих фазах. Например, onPostScroll имеет аргумент "consumed", который указывает на то, сколько было израсходовано во время фазы потребления узлом. Мы можем использовать это значение, например, чтобы узнать, насколько прокрутился исходный список, поскольку эта функция будет вызвана после фазы потребления узлом.

3. nested scroll source: Откуда взялась эта дельта — Drag (если это жест) или Fling (если это fling-анимация).

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

  • NestedScrollDispatcher: Диспетчер — это сущность, которая запускает цикл вложенной прокрутки, то есть использование диспетчера и вызов его методов, по сути, запускает цикл. Например, контейнер с прокруткой имеет встроенный диспетчер, который заботится об отправке дельт, захваченных во время жестов, в систему. По этому в большинстве случаев вместо диспетчера будет использоваться соединение, поскольку мы реагируем на уже существующие дельты, а не отправляем новые.

Теперь давайте подумаем о том, что мы знаем о порядке распространения дельт в системе вложенной прокрутки, и попробуем применить эту информацию к нашему случаю, чтобы увидеть, как мы можем реализовать нужное нам поведение сворачивания для верхней панели. Ранее мы узнали, что после срабатывания события прокрутки, еще до того, как сам список сможет двигаться, нам будет предоставлена возможность принять решение о позиционировании верхней панели. Это говорит о том, что нам нужно что-то сделать во время onPreScroll. Помните, onPreScroll — это фаза, которая происходит непосредственно перед прокруткой списка (фаза NodeConsumption).

Наш исходный код собой комбинацию двух компонентов: одного для верхней панели и другого для списка, обернутого в Box:

val AppBarHeight = 56.dp
val Purple40 = Color(0xFF6650a4)

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   Box {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleInitialCode.kt на GitHub

Высота нашей панели приложения фиксирована, и мы можем просто сместить ее положение, чтобы показать/скрыть ее. Давайте создадим переменную состояния для хранения значения такого смещения:

val appBarOffset by remember { mutableIntStateOf(0) }

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   Box {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           // Для фактического перемещения верхней панели приложения используйте appBarOffset.
           modifier = Modifier.offset { IntOffset(0, appBarOffset) },
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

 ComposeNestedScrollSampleWithAppBarOffset.kt на GitHub

Теперь нам нужно обновить смещение на основе прокрутки списка. Мы установим соединение (connection) вложенной прокрутки в таком месте иерархии, где оно сможет перехватывать дельты, поступающие из списка; в то же время оно должно иметь возможность изменять смещение верхней панели. Подходящим местом является их общий родитель — родитель хорошо расположен иерархически, чтобы 1) получать дельты от одного компонента и 2) влиять на положение другого компонента. Мы будем использовать соединение, чтобы влиять на фазу onPreScroll:

val appBarOffset by remember { mutableIntStateOf(0) }

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   val connection = remember {
       object : NestedScrollConnection {
           override fun onPreScroll(
               available: Offset,
               source: NestedScrollSource
           ): Offset {
               return super.onPreScroll(available, source)
           }
       }
   }

   Box(Modifier.nestedScroll(connection)) {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           modifier = Modifier.offset { IntOffset(0, appBarOffset) },
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleWithConnection.kt на GitHub

Мы получим дельту из списка в параметре available колбека onPreScroll. Возврат этого колбека должен быть тем, что мы использовали из available. Это означает, что если мы вернем Offset.Zero, то мы ничего не использовали, и список сможет использовать все это для своей прокрутки. Если мы вернем available, в списке ничего не останется, и он не будет прокручиваться.

В нашем случае, если значение appBarOffset находится в диапазоне от 0 до максимальной высоты панели, нам нужно будет дать дельту панели (добавить ее к смещению). Мы можем сделать это с помощью вычисления, использующего coerceIn (она ограничивает значения между минимумом и максимумом). После этого нам нужно будет сообщить системе, что было потреблено при смещении верхней панели. В итоге наша реализация onPreScroll будет выглядеть следующим образом:

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
       val delta = available.y.toInt()
       val newOffset = appBarOffset + delta
       val previousOffset = appBarOffset
       appBarOffset = newOffset.coerceIn(-appBarMaxHeight, 0)
       val consumed = appBarOffset - previousOffset
       return Offset(0f, consumed.toFloat())
   }

ComposeNestedScrollSampleOnPreScrollHighlight.kt на GitHub

Давайте немного реорганизуем наш код и абстрагируем смещение состояния и соединение в один класс:

private class CollapsingAppBarNestedScrollConnection(
    val appBarMaxHeight: Int
) : NestedScrollConnection {

   var appBarOffset: Int by mutableIntStateOf(0)
       private set

   override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
       val delta = available.y.toInt()
       val newOffset = appBarOffset + delta
       val previousOffset = appBarOffset
       appBarOffset = newOffset.coerceIn(-appBarMaxHeight, 0)
       val consumed = appBarOffset - previousOffset
       return Offset(0f, consumed.toFloat())
   }
}

ComposeNestedScrollSampleEncapsulatedConnection.kt на GitHub

И теперь мы можем использовать этот класс для смещения нашего appBar:

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   val appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
   val connection = remember(appBarMaxHeightPx) {
       CollapsingAppBarNestedScrollConnection(appBarMaxHeightPx)
   }

   Box(Modifier.nestedScroll(connection)) {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           modifier = Modifier.offset { IntOffset(0, connection.appBarOffset) },
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleUsingConnectionClass.kt на GitHub

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

Но это не совсем то, что нам нужно. Чтобы исправить это, нам нужно использовать appBarOffset для обновления пространства перед списком, чтобы при полном сворачивании верхней панели высота элементов обнулялась. После этого верхняя панель больше не будет ничего потреблять, и список сможет свободно прокручиваться.

Эта логика также применима к выдвижению верхней панели. Пока панель выдвигается, список остается статичным, но наш невидимый элемент увеличивается, поэтому создается иллюзия, что список движется. Когда верхняя панель будет полностью развернута, она больше не будет потреблять дельты, и список сможет продолжить прокрутку.

Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colorScheme.background
) {
    val appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
    val connection = remember(appBarMaxHeightPx) {
        CollapsingAppBarNestedScrollConnection(appBarMaxHeightPx)
    }
    val density = LocalDensity.current
    val spaceHeight by remember(density) {
        derivedStateOf {
            with(density) {
                (appBarMaxHeightPx + connection.appBarOffset).toDp()
            }
        }
    }

    Box(Modifier.nestedScroll(connection)) {
        Column {
            Spacer(
                Modifier
                    .padding(4.dp)
                    .height(spaceHeight)
            )
            LazyColumn {
                items(Contents) {
                    ListItem(item = it)
                }
            }
        }

        TopAppBar(
            modifier = Modifier.offset { IntOffset(0, connection.appBarOffset) },
            title = { Text(text = "Jetpack Compose") },
            colors = TopAppBarDefaults.topAppBarColors(
                containerColor = Purple40,
                titleContentColor = Color.White
            )
        )
    }
}

ComposeNestedScrollSampleCompleteCode.kt на GitHub

В итоге верхняя панель будет сворачиваться/разворачиваться раньше, чем прокручиваться список, как мы и хотели.

Подведём итоги:

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

  • Мы можем использовать NestedScrollConnection, чтобы разрешить изменения распространяемых дельт в пределах цикла вложенной прокрутки.

  • Нам нужно переопределять методы onPreScroll/onPostScroll для изменения дельты прокрутки и onPreFling/onPostFling для изменения скорости смахивания.

  • Всегда помните, что нужно возвращать все, что было потреблено в каждом из переопределенных методов, чтобы вложенный цикл прокрутки мог продолжить распространение.

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

Лицензия на фрагменты кода: Copyright 2024 Google LLC.
SPDX-License-Identifier: Apache-2.0

Статья подготовлена в преддверии старта онлайн-курса "Android Developer. Basic".

Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments1

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS