В Jetpack Compose модификаторы scrollable2D и draggable2D являются низкоуровневыми модификаторами, предназначенными для обработки ввода указателя в двух измерениях. В то время как стандартные одномерные модификаторы scrollable и draggable ограничены одной ориентацией, двухмерные варианты отслеживают движение одновременно по осям X и Y.
Например, существующий модификатор scrollable используется для прокрутки и перемещения в одном направлении, а scrollable2d — для прокрутки и перемещения в 2D. Это позволяет создавать более сложные макеты, перемещающиеся во всех направлениях, такие как электронные таблицы или программы просмотра изображений. Модификатор scrollable2d также поддерживает вложенную прокрутку в 2D-сценариях.
Выберите 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()}", // ... ) } } } }
Приведенный выше фрагмент кода выполняет следующие действия:
- Использует
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 ) } }
Modifier.scrollable2D . Modifier.scrollable2D .Приведённый выше фрагмент содержит следующее:
- Контейнер имеет фиксированный размер (
600x400dp), в то время как содержимому присваивается гораздо больший размер (1200x800dp), чтобы избежать его изменения размера в соответствии с размером родительского элемента. - Модификатор
clipToBounds()для контейнера гарантирует, что любая часть крупного контента, находящаяся за пределами прямоугольника600x400будет скрыта от просмотра. - В отличие от высокоуровневых компонентов, таких как
LazyColumn,scrollable2Dне перемещает контент автоматически. Вместо этого вам необходимо применить к контенту отслеживаемоеoffset, используя либо преобразованияgraphicsLayer, либо смещения компоновки. - Внутри блока
graphicsLayertranslationX = 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) ) } }
В приведенном выше фрагменте:
- Двумерный компонент может использовать движение по оси 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) } } }
Приведённый выше фрагмент кода содержит следующее:
- Отслеживает положение ящика, используя состояние
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) } ) ) } }
Приведённый выше фрагмент содержит следующее:
- Для определения фактических размеров контейнера с градиентом используется модификатор
onSizeChanged. Селектор точно знает, где находятся края. - Внутри
graphicsLayerон регулирует параметрыtranslationXиtranslationY, чтобы селектор оставался центрированным во время перетаскивания.