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

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

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

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

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

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

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

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

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

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

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

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

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

Фаза до прокрутки – отправка вверх
Рисунок 3. Этап до прокрутки: отправка вверх.

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

Фаза до прокрутки – всплытие вниз
Рисунок 4. Этап до прокрутки – пузырьки опускаются.

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

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

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

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

Этап после прокрутки – отправка вверх
Рисунок 6. Фаза после прокрутки – отправка вверх.

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

Фаза после прокрутки – всплытие вниз
Рисунок 7. Этап после прокрутки – нисходящий.

Подобно прокрутке, когда жест перетаскивания завершается, намерение пользователя может быть преобразовано в скорость, которая используется для прокрутки (прокрутки с использованием анимации) прокручиваемого контейнера. Бросок также является частью вложенного цикла прокрутки, а скорости, генерируемые событием перетаскивания, проходят через аналогичные фазы: предварительный бросок, потребление узла и пост-бросок. Обратите внимание, что анимация броска связана только с жестом касания и не будет запускаться другими событиями, такими как 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 обеспечивает вертикальное центрирование изображения при масштабировании.

Результат

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

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

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

При попытке вложить прокручиваемые элементы 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.

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