二維捲動:scrollable2D、draggable2D

在 Jetpack Compose 中,scrollable2D 和 draggable2D 是低階修飾符,用於處理二維指標輸入。標準 1D 修飾符 scrollable 和 draggable 僅限單一方向,而 2D 變體則可同時追蹤 X 軸和 Y 軸的移動。

舉例來說,現有的 scrollable 修飾符用於單一方向捲動和快速滑動,而 scrollable2d 則用於 2D 捲動和快速滑動。這樣一來,您就能建立更複雜的版面配置,並朝各個方向移動,例如試算表或圖片檢視器。scrollable2d 修飾符也支援 2D 情境中的巢狀捲動。

圖 1. 在地圖上雙向平移。

選擇「scrollable2D」或「draggable2D」

選擇合適的 API 取決於您要移動的 UI 元素,以及這些元素偏好的物理行為。

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 中,系統會定義 lambda 函式,處理使用者手指產生的每個差異。程式碼 offset.value += delta 會以新位置更新手動狀態。
  • Text 元件會顯示 offset 狀態目前的 X 和 Y 值,並在使用者拖曳時即時更新。

平移大型可視區域

以下範例說明如何使用擷取的 2D 可捲動資料,並將 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 應只傳回「已消耗的差異」,讓「父項清單在子項達到上限時自然接管」。
  • 當使用者執行對角線快速滑動時,系統會分享 2D 速度。如果子項在動畫期間到達邊界,剩餘的動量會傳播到父項,以自然地繼續捲動。

@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 像素限制,就會將垂直捲動控制權傳遞給父項清單。

在上述程式碼片段中:

  • 2D 元件可使用 X 軸移動在內部平移,同時在達到子項本身的垂直邊界時,將 Y 軸移動調度至父項清單。
  • 系統不會將使用者限制在 2D 介面中,而是會計算消耗的差異,並將剩餘部分傳遞至階層。這樣一來,使用者就能繼續捲動頁面的其餘部分,不必抬起手指。

實作 Modifier.draggable2D

如要瞭解如何使用 Modifier.draggable2D,請參閱「使用 Modifier.draggable2D 拖曳兩個維度」。