博客 / 詳情

返回

CMP 如何優雅的實現跨軟件的拖拽功能

在現代桌面應用中,拖拽(Drag-and-Drop)操作是一種極其直觀且提升用户體驗的交互方式。它允許用户通過直接操作界面元素來移動數據、重新組織內容,從而極大地簡化了複雜任務。如果你正在使用 Kotlin Compose Multiplatform 開發桌面應用,並希望為你的應用增添這一強大的交互功能,那麼你來對地方了!

本文將深入探討如何在 Compose Multiplatform Desktop 應用中優雅地實現拖拽功能,我們將從核心概念開始,逐步講解如何創建可拖拽的源和可接收拖拽的目標,並結合你的實際項目源碼,展示高級用法和最佳實踐。

Compose Multiplatform 拖拽概述

Compose Multiplatform 提供了一套直觀的修飾符(modifiers)來支持拖拽操作。核心是兩個修飾符:dragAndDropSourcedragAndDropTarget

通過這兩個修飾符,你可以讓你的 Compose Multiplatform 應用能夠:

  • 接收用户從其他應用程序拖入的數據。
  • 允許用户將數據從你的應用中拖出。

接下來,讓我們分別看看如何創建拖拽源和拖拽目標。

創建拖拽源(Drag Source)

fun Modifier.dragAndDropSource(
    drawDragDecoration: DrawScope.() -> Unit,
    transferData: (Offset) -> DragAndDropTransferData?
): Modifier =
    this then
        DragAndDropSourceElement(
            drawDragDecoration = drawDragDecoration,
            // TODO: Expose this as public argument
            detectDragStart = DragAndDropSourceDefaults.DefaultStartDetector,
            transferData = transferData
        )

在你的 Compose 組件中,你可以使用 dragAndDropSource 修飾符來定義一個拖拽源。這個修飾符需要兩個參數:

  • drawDragDecoration: 一個繪製函數,用於在拖拽時顯示拖拽組件。
  • transferData: 一個數據傳輸函數,用於在拖拽開始時提供要傳輸的數據。

接下來以我開源項目 CrossPaste 為例,展示如何實現優雅的拖拽源:

SidePastePreviewItemView.kt 完整實現點這裏

// 我們使用 graphicsLayer 記錄拖拽源渲染的內容
val graphicsLayer = rememberGraphicsLayer()

Row(
    modifier =
        Modifier
            .dragAndDropSource(
                drawDragDecoration = {
                    // 使用 graphicsLayer 繪製拖拽裝飾
                    runBlocking {
                        runCatching {
                            graphicsLayer.toImageBitmap()
                        }.getOrNull()
                    }?.let { bitmap ->
                        drawImage(
                            image = bitmap,
                            topLeft = Offset.Zero,
                            alpha = 0.9f, // 設置拖拽部分的透明度
                        )
                    }
                },
            ) { offset ->
                DragAndDropTransferData(
                    transferable =
                        DragAndDropTransferable(
                            pasteProducer
                                .produce(
                                    pasteData = pasteData,
                                    localOnly = true,
                                    primary = configManager.getCurrentConfig().pastePrimaryTypeOnly,
                                )?.let {
                                    it as DesktopWriteTransferable
                                } ?: DesktopWriteTransferable(LinkedHashMap()),
                        ),
                    supportedActions =
                        listOf(
                            DragAndDropTransferAction.Copy,
                        ),
                    dragDecorationOffset = offset,
                    onTransferCompleted = { action ->
                    },
                )
            }
            ...
) {
    Box(
        modifier =
            Modifier
                .fillMaxSize()
                .drawWithContent {
                    graphicsLayer.record {
                        this@drawWithContent.drawContent()
                    }
                    drawLayer(graphicsLayer)
                },
    ) {
        // 這裏是你的拖拽源內容
        ...
    }
}

我使用 rememberGraphicsLayer() 來記錄拖拽源渲染的內容,並在 dragAndDropSource 中導出為 bitmap 來繪製。這樣,當用户開始拖拽時,可以看到一個半透明的拖拽內容,並且這個拖拽內容與拖拽原是一模一樣即時渲染的,這在交互時非常重要,可以幫助用户確認拖拽內容正確,沒有拖錯。

數據傳輸函數 transferData 需要用户提供一個 DragAndDropTransferData` 對象

@OptIn(ExperimentalComposeUiApi::class)
actual class DragAndDropTransferData(
    /**
     * The object being transferred during a drag-and-drop gesture.
     */
    @property:ExperimentalComposeUiApi
    val transferable: DragAndDropTransferable,

    /**
     * The transfer actions supported by the source of the drag-and-drop session.
     */
    @property:ExperimentalComposeUiApi
    val supportedActions: Iterable<DragAndDropTransferAction>,

    /**
     * The offset of the pointer relative to the drag decoration.
     */
    @property:ExperimentalComposeUiApi
    val dragDecorationOffset: Offset = Offset.Zero,

    /**
     * Invoked when the drag-and-drop gesture completes.
     *
     * The argument to the callback specifies the transfer action with which the gesture completed,
     * or `null` if the gesture did not complete successfully.
     */
    @property:ExperimentalComposeUiApi
    val onTransferCompleted: ((userAction: DragAndDropTransferAction?) -> Unit)? = null,
)
  • DragAndDropTransferable 在 Desktop 環境實際上就是 awtTransferable,它包含了要傳輸的數據。(對於不熟悉 Transferable 的同學,可以簡單理解為它是一個數據容器,它返回其支持的所有 DataFlavor,每個 DataFlavor 可以理解為一種 mime 類型,使用 getTransferData 方法傳入 DataFlavor 可以獲取對應類型的數據。)
  • supportedActions 是一個可迭代的 DragAndDropTransferAction 列表,表示支持的拖拽操作類型(CopyMoveLink)。
  • dragDecorationOffset 是拖拽裝飾的偏移量,通常用於調整拖拽時顯示的位置。
  • onTransferCompleted 是一個回調函數,當拖拽操作完成時調用,可以用於處理拖拽結果。

output.gif

創建拖拽目標(Drop Target)

在你的 Compose 組件中,你可以使用 dragAndDropTarget 修飾符來定義一個拖拽目標。這個修飾符需要一個 DragAndDropTarget 接口的實現,它包含了處理拖拽事件的方法。

interface DragAndDropTarget {

    fun onDrop(event: DragAndDropEvent): Boolean

    fun onStarted(event: DragAndDropEvent) = Unit

    fun onEntered(event: DragAndDropEvent) = Unit

    fun onMoved(event: DragAndDropEvent) = Unit

    fun onExited(event: DragAndDropEvent) = Unit

    fun onChanged(event: DragAndDropEvent) = Unit

    fun onEnded(event: DragAndDropEvent) = Unit
}

在一般情況下我們只需要關注三個方法:

  • onStarted(event: DragAndDropEvent): 當拖拽操作開始時調用,可以用於更新 UI 狀態或準備拖拽目標。
  • onDrop(event: DragAndDropEvent): 當拖拽操作完成並釋放時調用,處理實際的數據傳輸。
  • onEnded(event: DragAndDropEvent): 當拖拽操作結束時調用,可以用於清理狀態或重置 UI。

下面是 CrossPaste 中接受用户拖拽數據記錄在粘貼板的實現:

DragTargetContentView.kt 完整實現點這裏

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DragTargetContentView() {
    val appWindowManager = koinInject<DesktopAppWindowManager>()
    val copywriter = koinInject<GlobalCopywriter>()
    val pasteConsumer = koinInject<TransferableConsumer>()
    var isDragging by remember { mutableStateOf(false) }
    val animatedAlpha by animateFloatAsState(
        targetValue = if (isDragging) 0.8f else 0f,
        animationSpec = tween(300),
        label = "drag_target_alpha",
    )

    val dragAndDropTarget =
        remember {
            object : DragAndDropTarget {
                override fun onStarted(event: DragAndDropEvent) {
                    isDragging = true
                }

                override fun onEnded(event: DragAndDropEvent) {
                    isDragging = false
                }

                override fun onDrop(event: DragAndDropEvent): Boolean {
                    val transferable = event.awtTransferable

                    val source: String? = appWindowManager.getCurrentActiveAppName()
                    val pasteTransferable = DesktopReadTransferable(transferable)
                    return runBlocking {
                        pasteConsumer.consume(pasteTransferable, source, false)
                    }.isSuccess
                }
            }
        }

    Box(
        modifier =
            Modifier
                .fillMaxSize()
                .dragAndDropTarget(
                    shouldStartDragAndDrop = { true },
                    target = dragAndDropTarget,
                ),
    ) {
        ... // 這裏實現具體的 UI 與動畫效果
    }
}
  • 在這個實現中,通過 onStartedonEnded 方法來控制拖拽狀態的變化。這方便我們在自己的 UI 組件上層創建蒙版層,基於 isDragging 是否顯示來控制拖拽時的視覺效果。
  • onDrop 在桌面環境中我們可以從 event 中提取 awtTransferable,這與拖拽源中提供的數據結構一樣,基於你應用關注的數據類型,你可以在這裏進行相應的處理,在 CrossPaste 中我將嘗試遍歷所有支持的粘貼板類型,將説有數據記錄下來,以便用户後續使用。

drop.gif

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.