CMP 如何优雅的实现跨软件的拖拽功能

在现代桌面应用中,拖拽(Drag-and-Drop)操作是一种极其直观且提升用户体验的交互方式。它允许用户通过直接操作界面元素来移动数据、重新组织内容,从而极大地简化了复杂任务。如果你正在使用 Kotlin Compose Multiplatform 开发桌面应用,并希望为你的应用增添这一强大的交互功能,那么你来对地方了!

本文将深入探讨如何在 Compose Multiplatform Desktop 应用中优雅地实现拖拽功能,我们将从核心概念开始,逐步讲解如何创建可拖拽的源和可接收拖拽的目标,并结合你的实际项目源码,展示高级用法和最佳实践。

Compose Multiplatform 拖拽概述

Compose Multiplatform 提供了一套直观的修饰符(modifiers)来支持拖拽操作。核心是两个修饰符:dragAndDropSourcedragAndDropTarget

通过这两个修饰符,你可以让你的 Compose Multiplatform 应用能够:

  • 接收用户从其他应用程序拖入的数据。
  • 允许用户将数据从你的应用中拖出。

接下来,让我们分别看看如何创建拖拽源和拖拽目标。

创建拖拽源(Drag Source)

kotlin 复制代码
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 完整实现点这里

kotlin 复制代码
// 我们使用 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` 对象

kotlin 复制代码
@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 是一个回调函数,当拖拽操作完成时调用,可以用于处理拖拽结果。

创建拖拽目标(Drop Target)

在你的 Compose 组件中,你可以使用 dragAndDropTarget 修饰符来定义一个拖拽目标。这个修饰符需要一个 DragAndDropTarget 接口的实现,它包含了处理拖拽事件的方法。

kotlin 复制代码
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 完整实现点这里

kotlin 复制代码
@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 中我将尝试遍历所有支持的粘贴板类型,将说有数据记录下来,以便用户后续使用。

了解详情

CrossPaste 是一款 跨设备的通用粘贴板,在任意设备间复制粘贴,就像在同一台设备上操作一样自然流畅

相关推荐
十盒半价9 分钟前
TypeScript + React:大型项目开发的黄金搭档
前端·typescript·trae
楚轩努力变强1 小时前
前端工程化常见问题总结
开发语言·前端·javascript·vue.js·visual studio code
鱼樱前端1 小时前
rust基础二(闭包)
前端·rust
菜鸟学Python1 小时前
Python web框架王者 Django 5.0发布:20周年了!
前端·数据库·python·django·sqlite
前端开发爱好者1 小时前
只有 7 KB!前端圈疯传的 Vue3 转场动效神库!效果炸裂!
前端·javascript·vue.js
pe7er2 小时前
RESTful API 的规范性和接口安全性如何取舍
前端·后端
Fly-ping2 小时前
【前端】JavaScript文件压缩指南
开发语言·前端·javascript
qianmoQ2 小时前
GitHub 趋势日报 (2025年07月25日)
github
未来之窗软件服务3 小时前
免费版酒店押金原路退回系统之【房费押金计算器】实践——仙盟创梦IDE
前端·javascript·css·仙盟创梦ide·东方仙盟·酒店押金系统
拾光拾趣录3 小时前
常见 HTTP 请求头:从“为什么接口返回乱码”说起
前端·http