Skip to main content

Drag and drop

Drag-and-drop is a form of data transfer between two Views or a View and an external component. The Views involved can be within a single app, or separate apps; and the external component may be within a 3rd-party app entirely.

The key components involved in drag-and-drop sequence are: a DragRecognizer attached to a source View, DropReceiver linked to a receiver View, and a DataBundle. Both the source and/or target can be external to the app, meaning there might not be a recognizer or receiver at play.

The operation occurs when the user presses and drags a pointer within a drag-and-drop source, then drags that pointer onto a target and releases. Data will be captured from the source and provided to the target, where it can decide whether and how to accept it.

Module Required

You must include the DragDropModule (Web, Desktop) in your application in order 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 }

Doodle uses opt-in modules like this to improve bundle size.

tip

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

Creating a source

You can create a drag source by attaching a DragRecognizer to any View. The recognizer is responsible for initiating the operation in response to a drag PointerEvent. You can create a custom recognizer that implements the interface, or you can use the dragRecognized DSL.

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 whenever a receiver returns a non-null DragOperation from dragRecognized. The DragOperation contains the transfer data, user action (copy, move, link), and visual used to provide feedback during the operation. This object represents the entire lifecycle of the operation and is notified when dragging begins, completes, or is canceled. This allows the source to update based on the outcome of the operation.

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 size = 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() {} }

This is an example of a simple recognizer that allows the text from a button to be copied or moved to a target.

tip

Having the PointerEvent that triggered the drag lets a recognizer decide which subregion in a View a drag can happen from. It can also produce different data from different regions in a single View.

Receiving drops

You receive drops by attaching a DropReceiver to any View. Dragging a pointer over a View with a receiver during a drag-and-rop triggers the dropEnter event. Subsequent pointer movement results in dropOver or dropExit events; and releasing the pointer sends the drop event. Each of these events provides a DropEvent with information about the data being transferred and the user's intended action.

The DropReceiver indicates whether the current drop operation is allowed by returning true on any of these events. Or, it can return false to signal the drop is not allowed.

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 size = 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 is a simple receiver that accepts PlainText data and assigns it to the button's text. Try dragging some text onto the button. You can even drag from the previous app's button.

tip

A View can be a source and target for drag-and-drop simultaneously.

Data bundles

The DataBundle class manages the underlying data that is transferred between the source and target. This interface has two key methods: contains and get: both take a MimeType.

interface DataBundle { operator fun <T> get (type: MimeType<T>): T? operator fun <T> contains(type: MimeType<T>): Boolean //... }

The contains method checks whether the bundle has data that matches the given mime-type, and get returns it.

Event Sequence

The full sequence of events for an operation goes something like this:

Handling files

Drag-and-drop also supports file transfer. This is handled via a DropReceiver--just like any other data type, with the Files mime-type indicating which file types are allowed.

Try dragging files into the table below. The app will only allow files whose types are selected in the list. The Files mime-type fetches a collection of files from the bundle, which allows a receiver to handle multiple files in a single drop.

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.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.controls.toString 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.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.center import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.layout.constraints.fill 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.centerY eq parent.centerY } val fileTypes = listOf( PlainText, TextType("csv"), Image("jpg"), Image("jpeg") ) val fileTypesList = List(fileTypes, toString(textVisualizer), MultiSelectionModel(), fitContent = emptySet()).apply { this.font = font cellAlignment = { it.left eq 4 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) }, toString(textVisualizer)) { minWidth = 50.0; width = 75.0; cellAlignment = { it.right eq parent.right - 4; it.centerY eq parent.centerY } } column(Label("Type"), { type }, textVisualizer) { minWidth = 50.0; width = 150.0; cellAlignment = center } }.apply { var allowedFileTypes: Files? = null fileTypesList.selectionChanged += { _, _, _ -> allowedFileTypes = Files(*fileTypesList.selection.map { fileTypes[it] }.toTypedArray()) } this.font = font bounds = Rectangle(200, 0, 400, 200) //sampleStart dropReceiver = object: DropReceiver { override val active = true private fun allowed (event: DropEvent) = (allowedFileTypes?.let { it in event.bundle } ?: false).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 } ?: false } //sampleEnd Resizer(this).apply { movable = false directions = setOf(Direction.East) } } display += container { this += ScrollPanel(fileTypesList).apply { size = Size(140, 200) contentWidthConstraints = { it eq max(parent.width, it) } contentHeightConstraints = { it eq max(parent.height, it) } } this += fileTable layout = constrain(children[0], children[1]) { a, b -> val spacing = 10 a.left eq (parent.width - (a.width.readOnly + b.width.readOnly + spacing)) / 2 a.centerY eq parent.centerY b.left eq a.right + spacing b.top eq a.top } } display.layout = constrain(display.first(), fill) display.fill(White.paint) } } override fun shutdown() {} }