Вложенная прокрутка – это система, в которой несколько компонентов прокрутки, содержащихся друг в друге, работают вместе, реагируя на один жест прокрутки и передавая свои дельты (изменения) прокрутки.
Система вложенной прокрутки позволяет координировать действия между компонентами, которые можно прокручивать и которые иерархически связаны (чаще всего за счет общего родителя). Эта система связывает контейнеры прокрутки и позволяет взаимодействовать с дельтами прокрутки, которые распространяются и передаются между ними.
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-функций действует правило nested-scroll-by-default. Это значит, что любой прокручиваемый контейнер должен участвовать в цепочке вложенной прокрутки как родительский элемент через 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 и композициями.
Часто используются 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 или фрагменте вам нужно настроить дочернюю 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
Несовместимый объект View – это объект, который не реализует необходимые интерфейсы NestedScrolling на стороне View. Это означает, что совместимость вложенной прокрутки с этими Views не работает по умолчанию. Не сотрудничают Views RecyclerView и ViewPager2.
Дополнительные ресурсы
Рекомендуем
- Примечание. Текст ссылки показывается, когда JavaScript отключен.
- Жесты
- Переход с
CoordinatorLayoutна Compose - Как использовать представления в режиме создания