Вложенная прокрутка

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

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

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

Автоматическая вложенная прокрутка

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

Автоматическая вложенная прокрутка поддерживается и предоставляется по умолчанию некоторыми компонентами и модификаторами Compose: verticalScroll , horizontalScroll , scrollable , Lazy APIs и 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)
                    )
                }
            }
        }
    }
}

Два вложенных вертикальных элемента пользовательского интерфейса с прокруткой, реагирующие на жесты внутри и вне внутреннего элемента.
Рисунок 1. Два вложенных вертикальных элемента пользовательского интерфейса с прокруткой, реагирующие на жесты внутри и вне внутреннего элемента.

Использование модификатора nestedScroll

Если вам необходимо создать сложную скоординированную прокрутку между несколькими элементами, модификатор nestedScroll предоставляет вам большую гибкость, определяя иерархию вложенной прокрутки. Как упоминалось в предыдущем разделе, некоторые компоненты имеют встроенную поддержку вложенной прокрутки. Однако для составных элементов, которые не прокручиваются автоматически, таких как Box или Column , изменения прокрутки в таких компонентах не будут распространяться в системе вложенной прокрутки и не достигнут NestedScrollConnection или родительского компонента. Чтобы решить эту проблему, вы можете использовать nestedScroll для предоставления такой поддержки другим компонентам, включая пользовательские компоненты.

Вложенный цикл прокрутки

Вложенный цикл прокрутки — это поток изменений прокрутки, которые передаются вверх и вниз по иерархическому дереву через все компоненты (или узлы), являющиеся частью вложенной системы прокрутки, например, с помощью прокручиваемых компонентов и модификаторов, или nestedScroll .

Фазы вложенного цикла прокрутки

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

Фазы вложенного цикла прокрутки
Рисунок 2. Фазы вложенного цикла прокрутки.

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

Предварительная фаза прокрутки - отправка вверх
Рисунок 3. Фаза перед прокруткой: отправка данных вверх.

Это даёт родительским элементам вложенной прокрутки (композируемым элементам, использующим модификаторы nestedScroll или scrollable) возможность что-то сделать с изменением до того, как сам узел сможет его обработать.

Предварительная фаза прокрутки - образование пузырьков вниз
Рисунок 4. Предварительная фаза прокрутки — образование пузырьков.

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

Потребление узла фаза
Рисунок 5. Фаза потребления узла.

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

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

Фаза после прокрутки - отправка вверх
Рисунок 6. Фаза после прокрутки — отправка данных вверх.

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

Фаза после прокрутки - бурление вниз
Рисунок 7. Фаза после сворачивания — образование пузырьков.

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

Примите участие в цикле вложенной прокрутки.

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

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

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

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 обеспечивает сохранение вертикального центрирования изображения при масштабировании.

Результат

Приведенный выше фрагмент кода приводит к эффекту масштабирования изображения при прокрутке:

Рисунок 8. Эффект масштабирования изображения при прокрутке.

Взаимодействие вложенной прокрутки

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

Эта проблема является результатом заложенных в прокручиваемые составные элементы ожиданий. Прокручиваемые составные элементы имеют правило «вложенной прокрутки по умолчанию», что означает, что любой прокручиваемый контейнер должен участвовать во вложенной цепочке прокрутки как в качестве родителя через NestedScrollConnection , так и в качестве дочернего элемента через NestedScrollDispatcher . Дочерний элемент затем будет управлять вложенной прокруткой для родителя, когда он достигнет границы. Например, это правило позволяет Compose Pager и Compose LazyRow хорошо работать вместе. Однако, когда прокрутка взаимодействия осуществляется с ViewPager2 или RecyclerView , поскольку они не реализуют NestedScrollingParent3 , непрерывная прокрутка от дочернего элемента к родительскому невозможна.

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

Взаимодействующий родительский View , содержащий дочерний ComposeView

Взаимодействующий родительский View — это View, который уже реализует интерфейс NestedScrollingParent3 и, следовательно, может получать изменения прокрутки от взаимодействующего вложенного дочернего компонента. В этом случае ComposeView будет выступать в роли дочернего элемента и должен будет (косвенно) реализовать интерфейс NestedScrollingChild3 . Примером взаимодействующего родительского View является androidx.coordinatorlayout.widget.CoordinatorLayout .

Если вам необходима совместимость вложенной прокрутки между родительскими контейнерами прокручиваемых View и вложенными дочерними компонентами, допускающими прокрутку, вы можете использовать rememberNestedScrollInteropConnection() .

rememberNestedScrollInteropConnection() позволяет запоминать и использовать NestedScrollConnection , обеспечивающее взаимодействие вложенной прокрутки между родительским View , реализующим интерфейс NestedScrollingParent3 и дочерним представлением Compose. Это следует использовать в сочетании с модификатором nestedScroll . Поскольку вложенная прокрутка включена по умолчанию на стороне Compose, вы можете использовать это соединение для включения вложенной прокрутки как на стороне View , так и для добавления необходимой логики связи между Views и компонуемыми объектами.

Часто используемый пример — это CoordinatorLayout , CollapsingToolbarLayout и дочерний составной элемент, как показано в этом примере:

<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 необходимо настроить дочерний компонент и требуемое 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())
                        }
                    }
                }
            }
        }
    }
}

Родительский составной объект, содержащий дочерний AndroidView

В этом сценарии рассматривается реализация API взаимодействия для вложенной прокрутки на стороне Compose — когда у вас есть родительский компонент, содержащий дочерний AndroidView . AndroidView реализует интерфейс NestedScrollDispatcher , поскольку он выступает в качестве дочернего элемента для родительского компонента с прокруткой Compose, а также NestedScrollingParent3 , поскольку он выступает в качестве родительского элемента для дочернего компонента с прокруткой View . Родительский компонент Compose сможет получать вложенные дельты прокрутки от вложенного дочернего компонента View с прокруткой.

В следующем примере показано, как можно реализовать вложенную прокрутку в этом сценарии, а также сворачивающуюся панель инструментов в окне «Составить»:

@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(
            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(android.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 . Это позволяет элементу участвовать во вложенной прокрутке, но не включает автоматическую прокрутку элементов. Для компонентов, которые не прокручиваются автоматически, таких как Box или Column , изменения прокрутки на таких компонентах не будут распространяться в системе вложенной прокрутки и не достигнут NestedScrollConnection предоставляемого методом rememberNestedScrollInteropConnection() , следовательно, эти изменения не достигнут родительского компонента View . Чтобы решить эту проблему, убедитесь, что вы также установили модификаторы прокрутки для этих типов вложенных компонентов. Более подробную информацию можно найти в предыдущем разделе о вложенной прокрутке .

Родительский View не взаимодействующий с дочерним ComposeView

Несовместимое представление — это представление, которое не реализует необходимые интерфейсы NestedScrolling на стороне View . Обратите внимание, что это означает, что взаимодействие вложенной прокрутки с такими Views не работает «из коробки». Несовместимыми Views являются RecyclerView и ViewPager2 .

Дополнительные ресурсы

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}