在現代桌面應用中,拖拽(Drag-and-Drop)操作是一種極其直觀且提升用户體驗的交互方式。它允許用户通過直接操作界面元素來移動數據、重新組織內容,從而極大地簡化了複雜任務。如果你正在使用 Kotlin Compose Multiplatform 開發桌面應用,並希望為你的應用增添這一強大的交互功能,那麼你來對地方了!
本文將深入探討如何在 Compose Multiplatform Desktop 應用中優雅地實現拖拽功能,我們將從核心概念開始,逐步講解如何創建可拖拽的源和可接收拖拽的目標,並結合你的實際項目源碼,展示高級用法和最佳實踐。
Compose Multiplatform 拖拽概述
Compose Multiplatform 提供了一套直觀的修飾符(modifiers)來支持拖拽操作。核心是兩個修飾符:dragAndDropSource 和 dragAndDropTarget。
通過這兩個修飾符,你可以讓你的 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 環境實際上就是awt的Transferable,它包含了要傳輸的數據。(對於不熟悉Transferable的同學,可以簡單理解為它是一個數據容器,它返回其支持的所有DataFlavor,每個DataFlavor可以理解為一種 mime 類型,使用getTransferData方法傳入DataFlavor可以獲取對應類型的數據。)supportedActions是一個可迭代的DragAndDropTransferAction列表,表示支持的拖拽操作類型(Copy、Move、Link)。dragDecorationOffset是拖拽裝飾的偏移量,通常用於調整拖拽時顯示的位置。onTransferCompleted是一個回調函數,當拖拽操作完成時調用,可以用於處理拖拽結果。
創建拖拽目標(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 與動畫效果
}
}
- 在這個實現中,通過
onStarted和onEnded方法來控制拖拽狀態的變化。這方便我們在自己的 UI 組件上層創建蒙版層,基於isDragging是否顯示來控制拖拽時的視覺效果。 onDrop在桌面環境中我們可以從event中提取awtTransferable,這與拖拽源中提供的數據結構一樣,基於你應用關注的數據類型,你可以在這裏進行相應的處理,在 CrossPaste 中我將嘗試遍歷所有支持的粘貼板類型,將説有數據記錄下來,以便用户後續使用。