拖曳可組合項

讓使用者在畫面上實際拖曳元件,是現代 Android 應用程式的基本功能。無論是要建構自訂滑桿、滑動關閉元件,還是互動式畫布,Jetpack Compose 都提供一組完整的 API,可輕鬆處理拖曳事件。

Compose 拖曳 API 可簡化原始觸控事件的複雜度。您可以使用內建修飾符,直接連結至 Compose 的狀態管理和動畫系統,而不必手動計算觸控座標,並使用 pointerInput 管理手勢生命週期。

本頁面說明各個拖曳 API 的使用方式和時機。

選擇正確的拖曳 API

您可以使用多個 API 實作可拖曳內容。

使用 draggable 沿單一軸拖曳

draggable 修飾符是高階進入點,可讓您循單一方向拖曳手勢,並回報拖曳距離 (以像素為單位)。

請注意,這個修飾符和 scrollable 很類似,也只會偵測手勢。您必須保留該狀態並在畫面上呈現,例如透過 offset 修飾符移動元素:

@Composable
private fun DraggableText() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            ),
        text = "Drag me!"
    )
}

如要控管整個拖曳手勢,請考慮透過 pointerInput 修飾符改用拖曳手勢偵測工具。

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableFloatStateOf(0f) }
        var offsetY by remember { mutableFloatStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
        )
    }
}

在這裡插入替代文字
圖 1. 用手指拖曳 UI 元素

使用 anchoredDraggable 在錨點之間拖曳元件

anchoredDraggable 修飾符可讓您在一組預先定義的位置 (稱為錨點) 之間拖曳元件。使用者放開拖曳手勢時,元件會停在最靠近的錨點。anchoredDraggable 適合用來建立自訂底部功能表、複雜滑桿或導覽匣面板等元件。

API 介面

AnchoredDraggable API 主要包含下列部分:

  • 錨定標記 (DraggableAnchors):定義自訂狀態 (例如 Start, Center, End 的列舉類別) 與對應像素偏移值之間對應關係的建構工具。
  • 狀態 (AnchoredDraggableState<T>):狀態容器,可管理元件的目前位置 (位移)、停駐的錨點,以及拖曳進度。請使用 rememberSaveable 和 AnchoredDraggableState.Saver<T> 建立,以便在設定變更時保留狀態。
  • 手勢修飾符 (Modifier.anchoredDraggable):修飾符,可偵測沿著 Orientation 的拖曳手勢、使用拖曳增量更新狀態,以及處理滑動手勢,在放開時將元件動畫化至最近的錨點。

範例:建構自訂 SwipeToDismiss 元件

這個範例示範如何建構自訂 SwipeToDismiss 元件 (白色橫幅),使用者可以向左滑動關閉,或向右拖曳查看過度捲動延展效果。

  1. 定義錨定值。定義列舉,代表可拖曳內容可進入的離散位置:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. 例項化並儲存 AnchoredDraggableState。搭配使用 rememberSaveable 和 AnchoredDraggableState.Saver(),在活動重建和設定變更 (例如螢幕旋轉或主題變更) 時,保留元件目前的位置:

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. 套用 layout、offset 和 draggable 修飾符。如要讓元件可拖曳,並確保錨點會配合元件大小調整,請將下列修飾符套用至應在視覺上移動的內容:

    • Modifier.layout:錨點位置通常取決於元件大小,而這在初始組合期間是未知的。使用 layout 修飾符測量元件、根據寬度動態計算 DraggableAnchors 對應項,並使用 state.updateAnchors() 更新狀態。

    • Modifier.anchoredDraggable:連結至狀態、以指定方向調度拖曳差異,並處理快速滑過手勢,在放開時將元件動畫至最近的錨點。請將這個修飾符新增至應回應拖曳手勢的元件,並傳遞 state 和拖曳 orientation (以及選用的 overscrollEffect)。
    • Modifier.offset:從偏移 lambda 內的 state.requireOffset() 讀取偏移。在這個 lambda 中讀取偏移量,可確保系統只會在放置階段讀取值,避免在拖曳期間進行不必要的重組。請將這個修飾符套用至應以視覺方式移動的內容。

    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }

  4. [選用] 整合過度捲動。overscrollEffect API 會實作視覺效果,在捲動容器的邊緣達到捲動或快速滑動時顯示。當使用者拖曳超過定義的錨定邊界時,您可以透過過度捲動效果 (通常是拉伸或發光) 提供視覺回饋。

    1. 調度:將 overscrollEffect 例項傳遞至 anchoredDraggable 修飾符。這會根據手勢是否在錨定範圍內,將拖曳差異調度至 AnchoredDraggable 狀態或過度捲動效果。
    2. 算繪:將 Modifier.overscroll 套用至應以視覺方式算繪效果的元件,並使用相同的 OverscrollEffect 執行個體。

下列程式碼片段說明如何新增標準延展效果:

val overscrollEffect = rememberOverscrollEffect()
Box(
    modifier.fillMaxWidth()
) {
    Box(Modifier.fillMaxWidth()) {
        background()
    }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }
}

結果

上述程式碼片段會產生白色橫幅,並顯示「Drag me to dismiss!」(向左滑動即可關閉!) 文字,使用者可以向左滑動來關閉橫幅 (或向右拖曳來查看過度捲動延展效果):

影片 2. 自訂「滑動即可關閉」元件實作項目。

範例:建立自訂手勢導覽導覽匣

這個範例說明如何建構自訂的手勢驅動導覽匣。當使用者水平拖曳時,前景畫面 (ScreenContents) 會向右滑動,從 1.0f 縮小至 0.8f,並圓角化,顯示底層的導覽匣。

  1. 定義錨定狀態和位置。在這個範例中,錨點是導覽匣的開啟和關閉狀態。定義 Open 和 Closed 狀態的列舉,並使用 DraggableAnchors 將其對應至像素偏移:

    enum class DrawerState {
        Open,
        Closed
    }
    
        val anchors = DraggableAnchors {
            DrawerState.Open at drawerWidth
            DrawerState.Closed at 0f
        }
        val state = remember {
            AnchoredDraggableState(
                initialValue = DrawerState.Closed,
                anchors = anchors,
            )
        }

  2. 在點按項目時加入動畫 anchoredDraggableState。將 HomeScreenDrawer 放在底部,Box 放在中間,ScreenContents 放在頂部。抽屜會保持不動,而頂層則會在其上平移和縮放。

    使用者選取抽屜式選單項目時,請更新有效畫面狀態,並使用 state.animateTo(DrawerState.Closed) 自動關閉抽屜式選單:

    @Composable
    private fun AnchoredDraggableDrawerOnClickSample(
        modifier: Modifier = Modifier,
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        var screenState by remember { mutableStateOf(ScreenState.Home) }
    
        Box(modifier = modifier.fillMaxSize()) {
            HomeScreenDrawer(
                selectedScreen = screenState,
                onScreenSelected = { screen ->
                    screenState = screen
                    // Dismiss drawer when a destination is selected
                    coroutineScope.launch { state.animateTo(DrawerState.Closed) }
                }
            )
    
            // ScreenContents(...)
        }
    }

  3. 使用 state.requireOffset() 變更多個視覺屬性。 除了偏移內容,您也可以使用 Modifier.graphicsLayer 變更多個屬性,根據拖曳進度同時為平移、縮放和圓角半徑加上動畫效果:

    • 使用 state.requireOffset() 取得目前的拖曳偏移。
    • 使用 lerp() (線性插補) 計算相對於位移和抽屜寬度的比例縮放 (1f 至 0.8f) 和圓角半徑 (0.dp 至 32.dp)。
    • Modifier.graphicsLayer 會在繪圖階段執行變更,確保動畫高效能,不會在拖曳期間觸發重組。

    @Composable
    private fun AnchoredDraggableDrawerGraphicsLayerSample(
        state: AnchoredDraggableState<DrawerState>,
        drawerWidth: Float,
        selectedScreen: ScreenState,
        onDrawerClicked: () -> Unit
    ) {
        ScreenContents(
            selectedScreen = selectedScreen,
            onDrawerClicked = onDrawerClicked,
            modifier = Modifier
                .graphicsLayer {
                    this.translationX = state.requireOffset()
                    val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth)
                    this.scaleX = scale
                    this.scaleY = scale
                    val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth)
                    this.shape = RoundedCornerShape(cornerRadius.dp)
                    this.clip = true
                }
                .anchoredDraggable(state, Orientation.Horizontal)
        )
    }

  4. 使用 animateTo 觸發程式輔助動畫。透過程式輔助方式控制抽屜 (例如輕觸選單圖示或選取抽屜項目時),方法是在協同程式範圍內啟動 state.animateTo():

    fun toggleDrawerState(
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        coroutineScope.launch {
            if (state.currentValue == DrawerState.Open) {
                state.animateTo(DrawerState.Closed)
            } else {
                state.animateTo(DrawerState.Open)
            }
        }
    }

結果

影片 3. 前景畫面 (睡眠) 向右拖曳,顯示底層的導覽匣。

使用 Modifier.draggable2D 拖曳兩個維度

使用 draggable2D 修飾符,同時在 X 軸和 Y 軸上移動個別 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)
        }
    }
}

影片 4. 灰色背景上有一個紫色小方塊,使用者直接拖曳這個 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)
                    }
                )
        )
    }
}

影片 5. 顏色漸層,其中有白色圓形選取器旋鈕,可拖曳至任何方向,示範如何將 2D 差異值限制在容器邊界內,以更新所選顏色值。

上述程式碼片段包含下列內容:

  • 它會使用 onSizeChanged 修飾符擷取漸層容器的實際尺寸。選取器能準確辨識邊緣位置。
  • 在 graphicsLayer 內,它會調整 translationX 和 translationY,確保選取器在拖曳時保持置中。