二維捲動:scrollable2D、draggable2D

在 Jetpack Compose 中,scrollable2Ddraggable2D 是低階修飾符,用於處理二維指標輸入。標準的 1D 修飾符 scrollabledraggable 只能用於單一方向,但 2D 變體可同時追蹤 X 軸和 Y 軸的移動。

舉例來說,現有的 scrollable 修飾符用於單一方向的捲動和甩動,而 scrollable2d 則用於 2D 的捲動和甩動。這可讓您建立更複雜的版面配置,在所有方向上移動,例如試算表或圖片檢視器。scrollable2d 修飾符也支援 2D 情境中的巢狀捲動。

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

選擇 scrollable2Ddraggable2D

選擇合適的 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 可捲動資料,並將 translationXtranslationY 套用至大於父項容器的內容:

@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.xtranslationY = 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

使用 draggable2D 修飾符移動個別 UI 元素。

拖曳可組合函式元素

這個範例顯示 draggable2D 最常見的用途,也就是讓使用者拿起 UI 元素,並在父項容器內的任何位置重新放置。

@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 建立 2D 輸入區域,將選取器旋鈕限制在特定表面內。與移動元件本身的 可拖曳元素範例不同,這項實作會使用 2D 增量,在顏色挑選器中移動子項可組合項「選取器」:

@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. 顏色漸層,以及可朝任何方向拖曳的白色圓形選取器旋鈕,示範如何將 2D 差異值限制在容器的邊界內,以更新所選色彩值。

上述程式碼片段包含下列項目:

  • 它會使用 onSizeChanged 修飾符擷取漸層容器的實際尺寸。選取器會確切知道邊緣位置。
  • graphicsLayer 內,它會調整 translationXtranslationY,確保選取器在拖曳時保持置中。