Skip to main content

Drag & Drop

Fresh off Animation, let's pick up a different kind of interactivity: moving data around by dragging it.

Drag-and-drop transfers data between two Views, or between a View and something outside your app entirely. The Views involved can live in the same app, in separate apps, or the whole operation can start or end in a third-party app you have no control over at all. Three pieces make this work: a DragRecognizer on the source View, a DropReceiver on the target, and a DataBundle carrying the payload between them. Either end can be external, so it's entirely possible for a recognizer or receiver to be missing from the picture.

Here's the operation in a nutshell: someone presses and drags within a source, drags that pointer onto a target, and releases. Data gets captured from the source and handed to the target, which decides whether — and how — to accept it.

Module Required

Include the DragDropModule (Web, Desktop) in your application to use these features.

package dragdrop import io.nacular.doodle.application.Modules.Companion.DragDropModule import io.nacular.doodle.application.application import org.kodein.di.instance import rendering.MyApp fun main() { //sampleStart application(modules = listOf(DragDropModule)) { MyApp(instance(), instance()) } //sampleEnd }
tip

The DragDropModule imports the PointerModule (Web, Desktop) as well, so you do not need to do so.

Data bundles​

Before we get to sources and receivers, it's worth meeting the thing that actually moves between them. A DataBundle manages the data being transferred, and it's a small interface — really just two methods, both keyed by MimeType:

  • contains — does the bundle have data of this type?
  • get — hand it over.

Keep this in your back pocket — you'll see event.bundle checked and read this exact way throughout the examples below.

Creating a source​

Attach a DragRecognizer to any View to make it a drag source. The recognizer's job is deciding when a drag PointerEvent should kick off an operation — implement the interface yourself, or reach for the dragRecognized DSL for the common case:

package dragdrop import io.nacular.doodle.datatransport.dragdrop.DragOperation import io.nacular.doodle.datatransport.dragdrop.DragRecognizer import io.nacular.doodle.datatransport.dragdrop.dragRecognized import io.nacular.doodle.event.PointerEvent //sampleStart // Interface approach class Custom: DragRecognizer { override fun dragRecognized(event: PointerEvent): DragOperation? { TODO("Not yet implemented") } } // DSL approach val recognizer = dragRecognized { _: PointerEvent -> TODO("Not yet implemented") } //sampleEnd

The operation begins the moment a recognizer hands back a non-null DragOperation. That object carries the transfer data, the user's intended action (copy, move, link), and the visual shown as feedback during the drag. It sticks around for the operation's whole lifecycle, getting notified when dragging starts, completes, or gets canceled, so the source can react to how things turned out.

Here's a button whose text can be copied or moved to a target:

package io.nacular.doodle.docs.apps import io.nacular.doodle.application.Application import io.nacular.doodle.controls.buttons.PushButton import io.nacular.doodle.core.Display import io.nacular.doodle.datatransport.dragdrop.DragOperation import io.nacular.doodle.datatransport.dragdrop.DragOperation.Action import io.nacular.doodle.datatransport.dragdrop.DragOperation.Action.Copy import io.nacular.doodle.datatransport.dragdrop.DragOperation.Action.Move import io.nacular.doodle.datatransport.dragdrop.dragRecognized import io.nacular.doodle.datatransport.textBundle import io.nacular.doodle.docs.utils.DEFAULT_FONT_FAMILIES import io.nacular.doodle.docs.utils.DEFAULT_FONT_SIZE import io.nacular.doodle.drawing.Canvas import io.nacular.doodle.drawing.Color.Companion.Red import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.drawing.FontLoader import io.nacular.doodle.drawing.Renderable import io.nacular.doodle.drawing.paint import io.nacular.doodle.drawing.text import io.nacular.doodle.geometry.Point import io.nacular.doodle.geometry.Size import io.nacular.doodle.layout.constraints.center import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.theme.Theme import io.nacular.doodle.theme.ThemeManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class ButtonDragApp( display : Display, fonts : FontLoader, themeManager: ThemeManager, theme : Theme ): Application { init { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { val font = fonts { families = DEFAULT_FONT_FAMILIES size = DEFAULT_FONT_SIZE } themeManager.selected = theme //sampleStart val button = PushButton("Drag Me").apply { this.font = font suggestSize(Size(80, 40)) dragRecognizer = dragRecognized { object: DragOperation { override val bundle = textBundle(text) override val allowedActions = setOf(Copy, Move) override val visualOffset = Point(0, 14) override val visual = object: Renderable { override val size = this@apply.size override fun render(canvas: Canvas) { canvas.text(text, font = font, color = Red) } } override fun completed(action: Action) { if (action == Move) text = "" } } } } //sampleEnd display += button display.layout = constrain(display.first(), center) display.fill(White.paint) } } override fun shutdown() {} }
tip

Since the recognizer gets the triggering PointerEvent, it can decide which sub-region of a View a drag is even allowed to start from — and hand back different data depending on where within the View it began.

Receiving drops​

Attach a DropReceiver to any View to let it catch drops. Drag a pointer over a View with a receiver, and it fires dropEnter; keep moving, and you'll get dropOver or dropExit; release, and drop fires. Every one of these hands you a DropEvent describing the data in transit and the user's intended action.

The DropReceiver signals whether the current operation is welcome by returning true from any of these — or false to turn it away.

package io.nacular.doodle.docs.apps import io.nacular.doodle.application.Application import io.nacular.doodle.controls.buttons.PushButton import io.nacular.doodle.core.Display import io.nacular.doodle.datatransport.PlainText import io.nacular.doodle.datatransport.dragdrop.DropEvent import io.nacular.doodle.datatransport.dragdrop.DropReceiver import io.nacular.doodle.docs.utils.DEFAULT_FONT_FAMILIES import io.nacular.doodle.docs.utils.DEFAULT_FONT_SIZE import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.drawing.FontLoader import io.nacular.doodle.drawing.paint import io.nacular.doodle.geometry.Size import io.nacular.doodle.layout.constraints.center import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.theme.Theme import io.nacular.doodle.theme.ThemeManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class ButtonDropApp( display : Display, fonts : FontLoader, themeManager: ThemeManager, theme : Theme ): Application { init { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { val font = fonts { families = DEFAULT_FONT_FAMILIES size = DEFAULT_FONT_SIZE } themeManager.selected = theme //sampleStart val button = PushButton("_____").apply { this.font = font suggestSize(Size(80, 40)) dropReceiver = object: DropReceiver { override val active = true private fun allowed (event: DropEvent) = PlainText in event.bundle override fun dropEnter (event: DropEvent) = allowed(event) override fun dropOver (event: DropEvent) = allowed(event) override fun dropActionChanged(event: DropEvent) = allowed(event) override fun drop (event: DropEvent) = event.bundle[PlainText]?.let { this@apply.text = it; true } ?: false } } //sampleEnd display += button display.layout = constrain(display.first(), center) display.fill(White.paint) } } override fun shutdown() {} }

This one accepts PlainText and assigns it straight to the button's own text — drag some text onto it, or even drag straight from the button in the previous example.

tip

A single View can be a source and a target at the same time — nothing stops it from wearing both hats.

The full sequence​

Put a source and a receiver together, and here's the order things actually happen in, end to end:

  1. The user presses and drags a pointer within a source View. Its DragRecognizer returns a DragOperation, and the operation begins.
  2. As the pointer crosses onto a View with a DropReceiver, that receiver gets dropEnter.
  3. Further movement within that View fires dropOver; changing the requested action (holding a modifier key, say) fires dropActionChanged; leaving without dropping fires dropExit.
  4. Releasing the pointer over a receiver that's still willing fires drop — this is where the receiver actually reads the bundle and acts on it.
  5. Back on the source side, the DragOperation hears about how things ended: completed(action) if the drop succeeded, or canceled() if it didn't.

That last step is exactly what lets ButtonDragApp above clear its own text only when the action was Move — the source finds out the outcome and reacts accordingly.

Handling files​

Drag-and-drop covers file transfer too, through the very same DropReceiver mechanism — just with the Files mime-type marking which file types are welcome. Since Files hands back a whole collection at once, a single receiver can happily deal with several dropped files in one go.

Try dragging files onto the table below — it only accepts the types currently selected in the list beside it.

package io.nacular.doodle.docs.apps import io.nacular.doodle.application.Application import io.nacular.doodle.controls.IndexedItem import io.nacular.doodle.controls.ItemVisualizer import io.nacular.doodle.controls.MultiSelectionModel import io.nacular.doodle.controls.invoke import io.nacular.doodle.controls.list.List import io.nacular.doodle.controls.mutableListModelOf import io.nacular.doodle.controls.panels.ScrollPanel import io.nacular.doodle.controls.table.DynamicTable import io.nacular.doodle.controls.text.Label import io.nacular.doodle.core.Display import io.nacular.doodle.core.container import io.nacular.doodle.datatransport.Files import io.nacular.doodle.datatransport.Image import io.nacular.doodle.datatransport.LocalFile import io.nacular.doodle.datatransport.PlainText import io.nacular.doodle.datatransport.TextType import io.nacular.doodle.datatransport.dragdrop.DropEvent import io.nacular.doodle.datatransport.dragdrop.DropReceiver import io.nacular.doodle.docs.utils.DEFAULT_FONT_FAMILIES import io.nacular.doodle.docs.utils.DEFAULT_FONT_SIZE import io.nacular.doodle.docs.utils.sizeVisualizer import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.drawing.FontLoader import io.nacular.doodle.drawing.paint import io.nacular.doodle.geometry.Rectangle import io.nacular.doodle.geometry.Size import io.nacular.doodle.layout.constraints.Bounds import io.nacular.doodle.layout.constraints.ConstraintDslContext import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.layout.constraints.fill import io.nacular.doodle.layout.constraints.ideal import io.nacular.doodle.theme.Theme import io.nacular.doodle.theme.ThemeManager import io.nacular.doodle.utils.Direction import io.nacular.doodle.utils.Resizer import io.nacular.measured.units.BinarySize.Companion.kilobytes import io.nacular.measured.units.times import io.nacular.measured.units.toNearest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class FileDragApp( display : Display, fonts : FontLoader, themeManager : ThemeManager, theme : Theme, textVisualizer: ItemVisualizer<String, IndexedItem> ): Application { private fun fileSize(file: LocalFile) = file.size toNearest 1 * kilobytes init { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { val font = fonts { families = DEFAULT_FONT_FAMILIES size = DEFAULT_FONT_SIZE } themeManager.selected = theme val leftAligned: ConstraintDslContext.(Bounds) -> Unit = { it.left eq 4; it.size prefers ideal; it.centerY eq parent.centerY } val fileTypes = listOf( PlainText, TextType("csv" ), Image ("jpg" ), Image ("jpeg"), Image ("png" ) ) val fileTypesList = List(fileTypes, textVisualizer(), MultiSelectionModel()).apply { this.font = font cellAlignment = { it.left eq 4 it.size prefers ideal it.centerY eq parent.centerY } } val tableData = mutableListModelOf<LocalFile>() val fileTable = DynamicTable(tableData, MultiSelectionModel()) { column(Label("Name"), { name }, textVisualizer) { minWidth = 50.0; width = 200.0; headerAlignment = leftAligned; cellAlignment = leftAligned } column(Label("Size"), { fileSize(this) }, sizeVisualizer) { minWidth = 75.0; width = 100.0; cellAlignment = fill } column(Label("Type"), { type }, textVisualizer) { minWidth = 50.0; width = 150.0; cellAlignment = { it.size prefers ideal; it.center eq parent.center } } }.apply { var allowedFileTypes: Files? = null fileTypesList.selectionChanged += { _, _, _ -> allowedFileTypes = Files(*fileTypesList.selection.map { fileTypes[it] }.toTypedArray()) } this.font = font suggestBounds(Rectangle(200, 0, 400, 200)) //sampleStart dropReceiver = object: DropReceiver { override val active = true private fun allowed (event: DropEvent) = (allowedFileTypes?.let { it in event.bundle } == true).also { println("allowedFileTypes: ${allowedFileTypes?.types}") } override fun dropEnter (event: DropEvent) = allowed(event) override fun dropOver (event: DropEvent) = allowed(event) override fun dropActionChanged(event: DropEvent) = allowed(event) override fun drop (event: DropEvent) = allowedFileTypes?.let { event.bundle[it] }?.let { files -> files.forEach { tableData.add(it) } true } == true } //sampleEnd Resizer(this, movable = false).apply { directions = setOf(Direction.East) } } display += container { + ScrollPanel(fileTypesList).apply { suggestSize(Size(140, 200)) contentConstraints = { it.width eq parent.width - verticalScrollBarWidth } } + fileTable layout = constrain(children[0], children[1]) { panel, table -> val spacing = 10 panel.size prefers panel.size.value panel.left eq (parent.width - readonly(panel.width + table.width + spacing)) / 2 panel.centerY eq parent.centerY table.top eq panel.top table.size prefers table.size.value table.left eq panel.left + readonly(panel.width) + spacing } } display.layout = constrain(display.first(), fill) display.fill(White.paint) } } override fun shutdown() {} }

Recap​

  • Three pieces make up an operation: a DragRecognizer on the source, a DropReceiver on the target, and a DataBundle carrying the data — either end can be external to your app entirely.
  • Check and read a bundle with contains/get, both keyed by MimeType.
  • A DragOperation lives for the whole operation, so the source can react to whether it completed or was canceled.
  • Events fire in order: dropEnter → dropOver/dropActionChanged/dropExit → drop on the receiver, then completed/canceled back on the source.
  • Files drop through the same DropReceiver mechanism, using the Files mime-type.

Next: on to Popups & Modals for floating content that sits above everything else.