二维滚动:scrollable2D、draggable2D

在 Jetpack Compose 中,scrollable2D 和 draggable2D 是 旨在处理二维指针输入的底层修饰符。虽然 标准的一维修饰符 scrollable 和 draggable 仅限于单个方向,但二维变体可以同时跟踪 X 轴和 Y 轴上的移动。

例如,现有的 scrollable 修饰符用于单向 滚动和轻拂,而 scrollable2d 用于二维滚动和轻拂 。这样,您就可以创建更复杂的布局,这些布局可以向各个方向移动,例如电子表格或图片查看器。scrollable2d 修饰符还支持二维场景中的嵌套滚动。

图 1. 地图上的双向平移。

选择 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()}",
                    // ...
                )
            }
        }
    }
}

图 2. 一个紫色框,用于跟踪和显示当前 X 和 Y 坐标偏移量,用户可以在其表面上拖动指针。

上述代码段会执行以下操作:

  • 使用 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
        )
    }
}

图 3. 使用 Modifier.scrollable2D 创建的双向平移图片视口。
图 4. 使用 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)
        )
    }
}

图 5. 垂直滚动列表中的一个紫色框,允许内部二维移动,但一旦框的内部 Y 偏移量达到 300 像素的限制,就会将垂直滚动控制权传递给父级列表。

在上述代码段中:

  • 二维组件可以消耗 X 轴移动以在内部平移,同时在达到子级自身的垂直边界后,将 Y 轴移动分派给父级列表。
  • 系统不会将用户困在二维表面内,而是计算已使用的增量,并将剩余部分传递到层次结构中。这样可确保用户无需抬起手指即可继续滚动浏览页面的其余部分。

实现 Modifier.draggable2D

如需了解如何使用 Modifier.draggable2D,请参阅 使用 Modifier.draggable2D 在二维中拖动。