Двумерная прокрутка: 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-пространстве

В этом примере показано, как получить необработанные данные о движении в 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. Фиолетовый прямоугольник внутри списка с вертикальной прокруткой, допускающий внутреннее перемещение в 2D-пространстве, но передающий управление вертикальной прокруткой родительскому списку, как только внутреннее смещение прямоугольника по оси Y достигает своего предела в 300 пикселей.

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

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

Реализуйте Modifier.draggable2D

Для перемещения отдельных элементов пользовательского интерфейса используйте модификатор draggable2D .

Перетащите составной элемент

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

@Composable
private fun DraggableComposableElement() {
    // 1. Track the position of the floating window
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Box(
            modifier = Modifier
                // 2. Apply the offset to the box's position
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                // ...
                // 3. Attach the 2D drag logic
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Update the position based on the movement delta
                        offset += delta
                    }
                ),
            contentAlignment = Alignment.Center
        ) {
            Text("Video Preview", color = Color.White, fontSize = 12.sp)
        }
    }
}

Рисунок 6. Небольшой фиолетовый прямоугольник, перемещаемый на сером фоне, демонстрирует прямое перетаскивание в 2D-пространстве, при котором элемент останавливается в тот момент, когда пользователь убирает палец.

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

  • Отслеживает положение ящика, используя состояние offset .
  • Использует модификатор offset для изменения положения компонента в зависимости от величины перетаскивания.
  • Поскольку функция запуска не предусмотрена, коробка останавливается в тот момент, когда пользователь убирает палец.

Перетащите дочерний составной элемент на основе области перетаскивания родительского элемента.

В этом примере показано, как использовать draggable2D для создания двумерной области ввода, где ползунок выбора ограничен определенной поверхностью. В отличие от примера с перетаскиваемым элементом, который перемещает сам компонент, в этой реализации используются двумерные дельты для перемещения дочернего составного «селектора» по палитре цветов:

@Composable
private fun ExampleColorSelector(
    // ...
)  {
    // 1. Maintain the 2D position of the selector in state.
    var selectorOffset by remember { mutableStateOf(Offset.Zero) }

    // 2. Track the size of the background container.
    var containerSize by remember { mutableStateOf(IntSize.Zero) }

    Box(
        modifier = Modifier
            .size(300.dp, 200.dp)
            // Capture the actual pixel dimensions of the container when it's laid out.
            .onSizeChanged { containerSize = it }
            .clip(RoundedCornerShape(12.dp))
            .background(
                brush = remember(hue) {
                    // Create a simple gradient representing Saturation and Value for the given Hue.
                    Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))
                }
            )
    ) {
        Box(
            modifier = Modifier
                .size(24.dp)
                .graphicsLayer {
                    // Center the selector on the finger by subtracting half its size.
                    translationX = selectorOffset.x - (24.dp.toPx() / 2)
                    translationY = selectorOffset.y - (24.dp.toPx() / 2)
                }
                // ...
                // 3. Configure 2D touch dragging.
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Calculate the new position and clamp it to the container bounds
                        val newX = (selectorOffset.x + delta.x)
                            .coerceIn(0f, containerSize.width.toFloat())
                        val newY = (selectorOffset.y + delta.y)
                            .coerceIn(0f, containerSize.height.toFloat())

                        selectorOffset = Offset(newX, newY)
                    }
                )
        )
    }
}

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

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

  • Для определения фактических размеров контейнера с градиентом используется модификатор onSizeChanged . Селектор точно знает, где находятся края.
  • Внутри graphicsLayer он регулирует параметры translationX и translationY , чтобы селектор оставался центрированным во время перетаскивания.