在 Jetpack Compose 中,scrollable2D 和 draggable2D 是
旨在处理二维指针输入的底层修饰符。虽然
标准的一维修饰符 scrollable 和 draggable 仅限于单个方向,但二维变体可以同时跟踪
X 轴和 Y 轴上的移动。
例如,现有的 scrollable 修饰符用于单向
滚动和轻拂,而 scrollable2d 用于二维滚动和轻拂
。这样,您就可以创建在所有方向上移动的更复杂的布局,例如电子表格或图片查看器。scrollable2d 修饰符还支持二维场景中的嵌套滚动。
选择 scrollable2D 或 draggable2D
选择哪个 API 取决于您要移动的界面元素以及这些元素的首选物理行为。
Modifier.scrollable2D:在容器上使用此修饰符可移动其中的内容。例如,将其与地图、电子表格或照片查看器搭配使用,其中容器的内容需要在水平和垂直方向上滚动。它包含内置的快速滑动支持,因此内容在滑动后会继续移动,并且会与页面上的其他滚动组件协调。
Modifier.draggable2D:使用此修饰符可移动组件本身。它是一个轻量级修饰符,因此移动会在用户的手指停止时立即停止。它不包含快速滑动支持。
如果您想让组件可拖动,但不需要快速滑动或嵌套滚动支持,请使用 draggable2D。
实现二维修饰符
以下部分提供了示例,展示了如何使用二维修饰符。
实现 Modifier.scrollable2D
对于用户需要向各个方向移动内容的容器,请使用此修饰符。
捕获二维移动数据
此示例展示了如何捕获原始二维移动数据并显示 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内,定义了一个 lambda 函数来处理用户手指生成的每个增量。代码offset.value += delta会使用新位置更新手动状态。 Text组件会显示offset状态的当前 X 和 Y 值,这些值会在用户拖动时实时更新。
平移大型视口
此示例展示了如何使用捕获的二维可滚动数据,并将 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不会自动移动内容。相反,您必须使用graphicsLayer转换或布局偏移量将跟踪的offset应用于内容。 - 在
graphicsLayer区块内,translationX = offset.value.x和translationY = offset.value.y会根据手指的移动来移动图片或文本的绘制位置,从而产生滚动的视觉效果。
使用 scrollable2D 实现嵌套滚动
此示例演示了如何将双向组件集成到标准的一维父级组件(例如垂直新闻信息流)中。
实现嵌套滚动时,请注意以下几点:
rememberScrollable2DState的 lambda 应仅返回 已使用的 增量 ,以便在子级达到 其限制时,父级列表自然接管 。- 当用户执行对角轻拂时,系统会共享二维速度。如果子级在动画期间触及边界,则剩余的动量会传播 到父级,以自然地继续滚动。
@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,以确保选择器在拖动时保持居中。