Двумерная прокрутка: scrollable2D, draggable2D

В Jetpack Compose scrollable2D и draggable2D – это низкоуровневые модификаторы, предназначенные для обработки ввода указателя в двух измерениях. Стандартные одномерные модификаторы scrollable и draggable ограничены одной ориентацией, а двумерные варианты отслеживают движение по осям X и Y одновременно.

Например, существующий модификатор scrollable используется для прокрутки и перелистывания в одной ориентации, а scrollable2d – для прокрутки и перелистывания в 2D. Это позволяет создавать более сложные макеты, которые перемещаются во всех направлениях, например электронные таблицы или средства просмотра изображений. Модификатор scrollable2d также поддерживает вложенную прокрутку в 2D-сценариях.

Рисунок 1. Панорамирование карты в двух направлениях.

Выберите scrollable2D или draggable2D.

Выбор правильного API зависит от элементов пользовательского интерфейса, которые вы хотите переместить, и предпочтительного физического поведения этих элементов.

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

Modifier.draggable2D – переместить компонент. Это легкий модификатор, поэтому движение останавливается точно в тот момент, когда останавливается палец пользователя. Поддержка жеста пролистывания не предусмотрена.

Если вы хотите сделать компонент перетаскиваемым, но вам не нужна поддержка прокрутки или вложенной прокрутки, используйте draggable2D.

Как реализовать модификаторы 2D

В следующих разделах приведены примеры использования модификаторов 2D.

Как внедрять правило Modifier.scrollable2D

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

Запись данных о движении в 2D

В этом примере показано,как получить необработанные данные о перемещении в двух измерениях и показать смещение по осям X и Y.

@Composable
private fun Scrollable2DSample() {
    // 1. Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            // ...
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 2. Attach the 2D scroll logic to capture XY movement deltas
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        // 3. Update the cumulative offset state with the new movement delta
                        offset += delta

                        // Return the delta to indicate the entire movement was handled by this box
                        delta
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // 4. Display the current X and Y values from the offset state in real-time
                Text(
                    text = "X: ${offset.x.roundToInt()}",
                    // ...
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    // ...
                )
            }
        }
    }
}

Рисунок 2. Фиолетовое поле, которое отслеживает и показывает текущие смещения координат X и Y, когда пользователь перетаскивает указатель по его поверхности.

Приведенный выше фрагмент кода выполняет следующие действия:

  • Использует offset в качестве состояния, которое содержит общее расстояние, на которое пользователь прокрутил страницу.
  • Внутри rememberScrollable2DState лямбда-функция определена для обработки каждого дельта-значения, сгенерированного пальцем пользователя. Код offset.value += delta обновляет состояние, заданное вручную, с учетом новой позиции.
  • Компоненты Text отображают текущие значения X и Y состояния offset, которые обновляются в реальном времени, когда пользователь перетаскивает элемент.

Панорамирование большой области просмотра

В этом примере показано, как использовать полученные данные о прокрутке в двух измерениях и применить translationX и translationY к контенту, который больше родительского контейнера:

@Composable
private fun Panning2DImage() {

    // Manually track the total distance the user has moved in both X and Y directions
    val offset = remember { mutableStateOf(Offset.Zero) }

    // Define how gestures are captured. The lambda is called for every finger movement
    val scrollState = rememberScrollable2DState { delta ->
        offset.value += delta
        delta
    }

    // The Viewport (Container): A fixed-size box that acts as a window into the larger content
    Box(
        modifier = Modifier
            .size(600.dp, 400.dp) // The visible area dimensions
            // ...
            // Hide any parts of the large content that sit outside this container's boundaries
            .clipToBounds()
            // Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
            .scrollable2D(state = scrollState),
        contentAlignment = Alignment.Center,
    ) {
        // The Content: An image given a much larger size than the container viewport
        Image(
            painter = painterResource(R.drawable.cheese_5),
            contentDescription = null,
            modifier = Modifier
                .requiredSize(1200.dp, 800.dp)
                // Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
                // we use graphicsLayer to shift the drawing position based on the tracked offset.
                .graphicsLayer {
                    translationX = offset.value.x
                    translationY = offset.value.y
                },
            contentScale = ContentScale.FillBounds
        )
    }
}

Рисунок 3. Область просмотра изображения с панорамированием в двух направлениях, созданная с помощью Modifier.scrollable2D.
Рисунок 4. Область просмотра текста с панорамированием в двух направлениях, созданная с помощью Modifier.scrollable2D.

В приведенном выше фрагменте кода есть следующие элементы:

  • Контейнеру задан фиксированный размер (600x400dp), а контенту – гораздо больший (1200x800dp), чтобы он не подстраивался под размер родительского элемента.
  • Модификатор clipToBounds() в контейнере гарантирует, что любая часть большого контента, которая находится за пределами поля 600x400, будет скрыта от просмотра.
  • В отличие от компонентов высокого уровня, таких как LazyColumn, scrollable2D не перемещает контент автоматически. Вместо этого необходимо применить отслеживаемый offset к контенту, используя преобразования graphicsLayer или смещения макета.
  • Внутри блока graphicsLayer блоки translationX = offset.value.x и translationY = offset.value.y сдвигают позицию рисования изображения или текста в зависимости от движения пальца, создавая визуальный эффект прокрутки.

Как реализовать вложенную прокрутку с помощью scrollable2D

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

При реализации вложенной прокрутки учитывайте следующие моменты:

  • Лямбда-выражение для rememberScrollable2DState должно возвращать только использованную дельту, чтобы родительский список мог естественным образом взять на себя управление, когда дочерний список достигнет своего предела.
  • Когда пользователь выполняет диагональное пролистывание, передается двумерная скорость. Если дочерний элемент достигает границы во время анимации, оставшийся импульс передается родительскому элементу, чтобы продолжить прокрутку естественным образом.

@Composable
private fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val maxScrollDp = 250.dp
    val maxScrollPx = with(LocalDensity.current) { maxScrollDp.toPx() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(Color(0xFFF5F5F5)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            "Scroll down to find the 2D Box",
            modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
            style = TextStyle(fontSize = 18.sp, color = Color.Gray)
        )

        // The Child: A 2D scrollable box with nested scroll coordination
        Box(
            modifier = Modifier
                .size(250.dp)
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        val oldOffset = offset

                        // Calculate new potential offset and clamp it to our boundaries
                        val newX = (oldOffset.x + delta.x).coerceIn(-maxScrollPx, maxScrollPx)
                        val newY = (oldOffset.y + delta.y).coerceIn(-maxScrollPx, maxScrollPx)

                        val newOffset = Offset(newX, newY)

                        // Calculate exactly how much was consumed by the child
                        val consumed = newOffset - oldOffset

                        offset = newOffset

                        // IMPORTANT: Return ONLY the consumed delta.
                        // The remaining (unconsumed) delta propagates to the parent Column.
                        consumed
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                val density = LocalDensity.current
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${with(density) { offset.x.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${with(density) { offset.y.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: 250 or -250,\nthis parent list will take over the vertical scroll.",
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
            style = TextStyle(fontSize = 14.sp, color = Color.Gray)
        )
    }
}

Рисунок 5. Фиолетовое поле в вертикальном списке прокрутки, которое позволяет перемещаться в двух измерениях, но передает управление вертикальной прокруткой родительскому списку, как только внутреннее смещение поля по оси Y достигает предела в 300 пикселей.

В приведенном выше фрагменте:

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

Как внедрить правило Modifier.draggable2D

Подробнее о том, как использовать Modifier.draggable2D, читайте в статье Перетаскивание в двух измерениях с помощью Modifier.draggable2D.