Вложенная прокрутка – это система, в которой несколько компонентов прокрутки, содержащихся друг в друге, работают вместе, реагируя на один жест прокрутки и передавая свои дельты (изменения) прокрутки.
Система вложенной прокрутки позволяет координировать действия между компонентами, которые можно прокручивать и которые иерархически связаны (чаще всего за счет общего родителя). Эта система связывает контейнеры прокрутки и позволяет взаимодействовать с дельтами прокрутки, которые распространяются и передаются между ними.
Compose предоставляет несколько способов обработки вложенной прокрутки между составными объектами. Типичный пример вложенной прокрутки – список внутри другого списка, а более сложный случай – сворачиваемая панель инструментов.
Автоматическая вложенная прокрутка
Простая вложенная прокрутка не требует никаких действий с вашей стороны. Жесты, которые инициируют прокрутку, автоматически передаются от дочерних элементов к родительским. Таким образом, когда дочерний элемент больше не может прокручиваться, жест обрабатывается его родительским элементом.
Автоматическая вложенная прокрутка поддерживается и предоставляется по умолчанию некоторыми компонентами и модификаторами Compose: verticalScroll, horizontalScroll, scrollable, API Lazy и TextField. Это означает, что когда пользователь прокручивает внутренний дочерний элемент вложенных компонентов, предыдущие модификаторы распространяют дельты прокрутки на родительские элементы, которые поддерживают вложенную прокрутку.
В примере ниже показаны элементы с модификатором verticalScroll внутри контейнера, к которому также применен модификатор verticalScroll.
@Composable private fun AutomaticNestedScroll() { val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White) Box( modifier = Modifier .background(Color.LightGray) .verticalScroll(rememberScrollState()) .padding(32.dp) ) { Column { repeat(6) { Box( modifier = Modifier .height(128.dp) .verticalScroll(rememberScrollState()) ) { Text( "Scroll here", modifier = Modifier .border(12.dp, Color.DarkGray) .background(brush = gradient) .padding(24.dp) .height(150.dp) ) } } } } }
Использование модификатора nestedScroll
Если вам нужно создать расширенную скоординированную прокрутку между несколькими элементами, модификатор nestedScroll обеспечивает большую гибкость за счет определения иерархии вложенной прокрутки. Как упоминалось в предыдущем разделе, некоторые компоненты имеют встроенную поддержку вложенной прокрутки. Однако для composable-функций, которые не поддерживают прокрутку автоматически, например Box или Column, дельты прокрутки не будут распространяться в системе вложенной прокрутки и не достигнут ни NestedScrollConnection, ни родительского компонента. Чтобы решить эту проблему, вы можете использовать nestedScroll, чтобы предоставить такую поддержку другим компонентам, включая пользовательские.
Цикл вложенной прокрутки
Цикл вложенной прокрутки – это поток дельт прокрутки, которые отправляются вверх и вниз по дереву иерархии через все компоненты (или узлы), являющиеся частью системы вложенной прокрутки, например с помощью прокручиваемых компонентов и модификаторов или nestedScroll.
Фазы цикла вложенной прокрутки
Когда событие триггера (например, жест) обнаруживается прокручиваемым компонентом, еще до того, как запускается фактическое действие прокрутки, сгенерированные дельты отправляются в систему вложенной прокрутки и проходят три фазы: предварительная прокрутка, потребление узла и пост-прокрутка.
На первом этапе, до прокрутки, компонент, получивший дельты событий триггера, отправляет эти события вверх по дереву иерархии к самому верхнему родительскому элементу. Затем события дельты будут передаваться вниз, то есть дельты будут распространяться от самого корневого родительского элемента вниз к дочернему элементу, который запустил вложенный цикл прокрутки.
Это дает родительским элементам вложенной прокрутки (составным элементам, использующим модификаторы nestedScroll или прокрутки) возможность что-то сделать с дельтой до того, как сам узел сможет ее использовать.
На этапе потребления узла сам узел будет использовать любую дельту, которая не использовалась его родителями. Это момент, когда прокрутка фактически выполняется и видна.
На этом этапе ребенок может выбрать, использовать ли весь оставшийся свиток или его часть. Остальные объявления будут отправлены на этап после прокрутки.
Наконец, на этапе после прокрутки все, что не было использовано самим узлом, будет снова отправлено его предкам для использования.
Эта фаза работает так же, как и фаза до прокрутки: любой из родительских элементов может выбрать, использовать ли событие.
Подобно прокрутке, когда жест перетаскивания завершается, намерение пользователя может быть преобразовано в скорость, которая используется для прокрутки (прокрутки с использованием анимации) прокручиваемого контейнера. Бросок также является частью вложенного цикла прокрутки, а скорости, генерируемые событием перетаскивания, проходят через аналогичные фазы: предварительный бросок, потребление узла и пост-бросок. Обратите внимание, что анимация броска связана только с жестом касания и не будет запускаться другими событиями, такими как a11y или аппаратная прокрутка.
Участвуйте в цикле вложенной прокрутки
Участие в цикле означает перехват, использование и отчетность об использовании дельт по иерархии. Compose предоставляет набор инструментов, позволяющих влиять на работу системы вложенной прокрутки и взаимодействовать с ней напрямую, например когда вам нужно что-то сделать с дельтами прокрутки до того, как прокручиваемый компонент начнет прокручиваться.
Если цикл вложенной прокрутки – это система, воздействующая на цепочку узлов, то модификатор nestedScroll позволяет перехватывать эти изменения, вставлять в них свои и влиять на данные (дельты прокрутки), которые распространяются по цепочке. Этот модификатор можно разместить в любом месте иерархии, и он взаимодействует с вложенными экземплярами модификатора прокрутки вверх по дереву, чтобы он мог обмениваться информацией по этому каналу. Его составные части – NestedScrollConnection и NestedScrollDispatcher.
NestedScrollConnection позволяет реагировать на фазы цикла вложенной прокрутки и влиять на систему вложенной прокрутки. Он состоит из четырех методов обратного вызова, каждый из которых представляет одну из фаз потребления: до/после прокрутки и до/после пролистывания:
val nestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { println("Received onPreScroll callback.") return Offset.Zero } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { println("Received onPostScroll callback.") return Offset.Zero } }
Каждый обратный вызов также предоставляет информацию о распространяемой дельте: available дельта для этой конкретной фазы и consumed дельта, использованная на предыдущих фазах. Если в какой-то момент вы захотите прекратить распространение дельт вверх по иерархии, вы можете использовать для этого вложенное соединение прокрутки:
val disabledNestedScrollConnection = remember { object : NestedScrollConnection { override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { return if (source == NestedScrollSource.SideEffect) { available } else { Offset.Zero } } } }
Все обратные вызовы предоставляют информацию о типе NestedScrollSource.
NestedScrollDispatcher инициализирует цикл вложенной прокрутки. Использование диспетчера и вызов его методов запускает цикл. Прокручиваемые контейнеры имеют встроенный диспетчер, который отправляет дельты, зафиксированные во время жестов, в систему. По этой причине в большинстве случаев
настройки вложенной прокрутки используется NestedScrollConnection вместо
диспетчера, чтобы реагировать на уже существующие дельты, а не отправлять новые.
Дополнительные примеры использования приведены в статье NestedScrollDispatcherSample.
Изменение размера изображения при прокрутке
При прокрутке страницы размер изображения будет меняться в зависимости от положения прокрутки.
Как изменить размер изображения в зависимости от положения прокрутки
Этот фрагмент кода демонстрирует изменение размера изображения в LazyColumn на основе позиции вертикальной прокрутки. Изображение уменьшается, когда пользователь прокручивает страницу вниз, и увеличивается, когда он прокручивает ее вверх, оставаясь в пределах заданных минимального и максимального размеров:
@Composable fun ImageResizeOnScrollExample( modifier: Modifier = Modifier, maxImageSize: Dp = 300.dp, minImageSize: Dp = 100.dp ) { var currentImageSize by remember { mutableStateOf(maxImageSize) } var imageScale by remember { mutableFloatStateOf(1f) } val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { // Calculate the change in image size based on scroll delta val delta = available.y val newImageSize = currentImageSize + delta.dp val previousImageSize = currentImageSize // Constrain the image size within the allowed bounds currentImageSize = newImageSize.coerceIn(minImageSize, maxImageSize) val consumed = currentImageSize - previousImageSize // Calculate the scale for the image imageScale = currentImageSize / maxImageSize // Return the consumed scroll amount return Offset(0f, consumed.value) } } } Box(Modifier.nestedScroll(nestedScrollConnection)) { LazyColumn( Modifier .fillMaxWidth() .padding(15.dp) .offset { IntOffset(0, currentImageSize.roundToPx()) } ) { // Placeholder list items items(100, key = { it }) { Text( text = "Item: $it", style = MaterialTheme.typography.bodyLarge ) } } Image( painter = ColorPainter(Color.Red), contentDescription = "Red color image", Modifier .size(maxImageSize) .align(Alignment.TopCenter) .graphicsLayer { scaleX = imageScale scaleY = imageScale // Center the image vertically as it scales translationY = -(maxImageSize.toPx() - currentImageSize.toPx()) / 2f } ) } }
Важные сведения о коде
- В этом коде используется
NestedScrollConnectionдля перехвата событий прокрутки. onPreScrollрассчитывает изменение размера изображения на основе дельты прокрутки.- Переменная состояния
currentImageSizeхранит текущий размер изображения, ограниченный значениямиminImageSizeиmaxImageSize. imageScale, полученными изcurrentImageSize. LazyColumnсмещается на основеcurrentImageSize.Imageиспользует модификаторgraphicsLayer, чтобы применить рассчитанный масштаб.- Класс
translationYв элементеgraphicsLayerобеспечивает вертикальное центрирование изображения при масштабировании.
Результат
Приведенный выше фрагмент кода позволяет масштабировать изображение при прокрутке:
Взаимодействие вложенной прокрутки
Если вы попытаетесь вложить прокручиваемые элементы View в прокручиваемые composable-функции или наоборот, могут возникнуть проблемы. Наиболее заметные из них будут происходить, когда вы прокручиваете дочерний элемент и достигаете его начальной или конечной границы и ожидаете, что родительский элемент возьмет на себя прокрутку. Однако это ожидаемое поведение может не произойти или может работать не так, как ожидалось.
Эта проблема возникает из-за ожиданий, заложенных в прокручиваемые composable-функции.
Для прокручиваемых composable-функций действует правило "вложенная прокрутка по умолчанию". Это значит, что любой прокручиваемый контейнер должен участвовать в цепочке вложенной прокрутки как родительский элемент через NestedScrollConnection и как дочерний элемент через NestedScrollDispatcher.
Затем дочерний элемент будет управлять вложенной прокруткой для родительского элемента, когда дочерний элемент находится на границе. Например, это правило позволяет Compose Pager и Compose LazyRow хорошо работать вместе. Однако при использовании ViewPager2 или RecyclerView непрерывная прокрутка от дочернего элемента к родительскому невозможна, поскольку в них не реализован интерфейс NestedScrollingParent3.
Чтобы включить API взаимодействия вложенной прокрутки между прокручиваемыми элементами View и прокручиваемыми composable-функциями, вложенными в обоих направлениях, вы можете использовать API взаимодействия вложенной прокрутки для устранения этих проблем в следующих сценариях.
Родительский элемент View, содержащий дочерний элемент ComposeView
Совместный родительский элемент View, который уже реализует NestedScrollingParent3, может получать дельты прокрутки от вложенной дочерней composable-функции. В этом случае ComposeView будет дочерним классом и должен (косвенно) реализовывать NestedScrollingChild3.
Пример родительского элемента, поддерживающего совместную работу: androidx.coordinatorlayout.widget.CoordinatorLayout.
Если вам нужна совместимость вложенной прокрутки между прокручиваемыми родительскими контейнерами View и вложенными дочерними composable-функциями, используйте rememberNestedScrollInteropConnection().
rememberNestedScrollInteropConnection() разрешает и запоминает NestedScrollConnection, который обеспечивает совместимость вложенной прокрутки между родительским элементом View, реализующим NestedScrollingParent3, и дочерним элементом Compose. Его следует использовать в сочетании с модификатором nestedScroll. Поскольку вложенная прокрутка включена по умолчанию на стороне Compose, вы можете использовать это подключение, чтобы включить вложенную прокрутку на стороне View и добавить необходимую логику связывания между Views и composable-функциями.
Часто используются CoordinatorLayout, CollapsingToolbarLayout и дочерняя composable-функция, как показано в этом примере:
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.appbar.AppBarLayout android:id="@+id/app_bar" android:layout_width="match_parent" android:layout_height="100dp" android:fitsSystemWindows="true"> <com.google.android.material.appbar.CollapsingToolbarLayout android:id="@+id/collapsing_toolbar_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" app:layout_scrollFlags="scroll|exitUntilCollapsed"> <!--...--> </com.google.android.material.appbar.CollapsingToolbarLayout> </com.google.android.material.appbar.AppBarLayout> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" app:layout_behavior="@string/appbar_scrolling_view_behavior" android:layout_width="match_parent" android:layout_height="match_parent"/> </androidx.coordinatorlayout.widget.CoordinatorLayout>
В Activity или Fragment необходимо настроить дочернюю composable-функцию и обязательный параметр
NestedScrollConnection:
open class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewById<ComposeView>(R.id.compose_view).apply { setContent { val nestedScrollInterop = rememberNestedScrollInteropConnection() // Add the nested scroll connection to your top level @Composable element // using the nestedScroll modifier. LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) { items(20) { item -> Box( modifier = Modifier .padding(16.dp) .height(56.dp) .fillMaxWidth() .background(Color.Gray), contentAlignment = Alignment.Center ) { Text(item.toString()) } } } } } } }
Родительская composable-функция, содержащая дочернюю AndroidView
В этом сценарии рассматривается реализация API взаимодействия вложенной прокрутки на стороне Compose, когда у вас есть родительская composable-функция, содержащая дочерний элемент AndroidView. AndroidView реализует NestedScrollDispatcher, поскольку выступает в качестве дочернего элемента для родительского элемента прокрутки Compose, а также NestedScrollingParent3, поскольку выступает в качестве родительского элемента для дочернего элемента прокрутки View. Родительский элемент Compose сможет получать дельты вложенной прокрутки от дочернего элемента с возможностью вложенной прокрутки.
View
В примере ниже показано, как реализовать совместимость вложенной прокрутки в этом сценарии, а также сворачиваемую панель инструментов Compose.
@Composable private fun NestedScrollInteropComposeParentWithAndroidChildExample() { val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() } val toolbarOffsetHeightPx = remember { mutableStateOf(0f) } // Sets up the nested scroll connection between the Box composable parent // and the child AndroidView containing the RecyclerView val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { // Updates the toolbar offset based on the scroll to enable // collapsible behaviour val delta = available.y val newOffset = toolbarOffsetHeightPx.value + delta toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f) return Offset.Zero } } } Box( Modifier .fillMaxSize() .nestedScroll(nestedScrollConnection) ) { TopAppBar( title = {}, modifier = Modifier .height(ToolbarHeight) .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) } ) AndroidView( { context -> LayoutInflater.from(context) .inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply { with(findViewById<RecyclerView>(R.id.main_list)) { layoutManager = LinearLayoutManager(context, VERTICAL, false) adapter = NestedScrollInteropAdapter() } }.also { // Nested scrolling interop is enabled when // nested scroll is enabled for the root View ViewCompat.setNestedScrollingEnabled(it, true) } }, // ... ) } } private class NestedScrollInteropAdapter : Adapter<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() { val items = (1..10).map { it.toString() } override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): NestedScrollInteropViewHolder { return NestedScrollInteropViewHolder( LayoutInflater.from(parent.context) .inflate(R.layout.list_item, parent, false) ) } override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) { // ... } class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) { fun bind(item: String) { // ... } } // ... }
В примере ниже показано, как использовать API с модификатором scrollable:
@Composable fun ViewInComposeNestedScrollInteropExample() { Box( Modifier .fillMaxSize() .scrollable( rememberScrollableState { // View component deltas should be reflected in Compose // components that participate in nested scrolling it }, Orientation.Vertical ) ) { AndroidView( { context -> LayoutInflater.from(context) .inflate(R.layout.list_item, null) .apply { // Nested scrolling interop is enabled when // nested scroll is enabled for the root View ViewCompat.setNestedScrollingEnabled(this, true) } } ) } }
Наконец, в этом примере показано, как API взаимодействия вложенной прокрутки используется с BottomSheetDialogFragment для успешного перетаскивания и закрытия:
class BottomSheetFragment : BottomSheetDialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false) rootView.findViewById<ComposeView>(R.id.compose_view).apply { setContent { val nestedScrollInterop = rememberNestedScrollInteropConnection() LazyColumn( Modifier .nestedScroll(nestedScrollInterop) .fillMaxSize() ) { item { Text(text = "Bottom sheet title") } items(10) { Text( text = "List item number $it", modifier = Modifier.fillMaxWidth() ) } } } return rootView } } }
Обратите внимание, что rememberNestedScrollInteropConnection() устанавливает NestedScrollConnection в элементе, к которому он прикреплен. NestedScrollConnection отвечает за передачу дельт с уровня Compose на уровень View. Это позволяет элементу участвовать во вложенной прокрутке, но не включает прокрутку элементов автоматически. Для composable-функций, которые не поддерживают прокрутку автоматически, например Box или Column, дельты прокрутки в таких компонентах не будут распространяться в системе вложенной прокрутки и не достигнут NestedScrollConnection, предоставляемого rememberNestedScrollInteropConnection(). Поэтому эти дельты не достигнут родительского компонента View. Чтобы решить эту проблему, убедитесь, что вы также установили модификаторы прокрутки для этих типов вложенных компонуемых объектов. Дополнительную информацию вы найдете в разделе Вложенная прокрутка.
Несовместимый родительский элемент View, содержащий дочерний элемент ComposeView
Несовместимым считается представление, в котором не реализованы необходимые интерфейсы NestedScrolling на стороне View. Обратите внимание, что это означает, что совместимость вложенной прокрутки с этими Views не работает по умолчанию. Не сотрудничают Views RecyclerView и ViewPager2.
Дополнительные ресурсы
Рекомендуем
- Примечание. Текст ссылки показывается, когда JavaScript отключен.
- Жесты
- Переход с
CoordinatorLayoutна Compose - Использование представлений в Compose