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.
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
}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:
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() {}
}
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.
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:
- The user presses and drags a pointer within a source View. Its
DragRecognizerreturns aDragOperation, and the operation begins. - As the pointer crosses onto a View with a
DropReceiver, that receiver getsdropEnter. - Further movement within that View fires
dropOver; changing the requested action (holding a modifier key, say) firesdropActionChanged; leaving without dropping firesdropExit. - 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. - Back on the source side, the
DragOperationhears about how things ended:completed(action)if the drop succeeded, orcanceled()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
DragRecognizeron the source, aDropReceiveron the target, and aDataBundlecarrying the data — either end can be external to your app entirely. - Check and read a bundle with
contains/get, both keyed byMimeType. - A
DragOperationlives for the whole operation, so the source can react to whether it completed or was canceled. - Events fire in order:
dropEnter→dropOver/dropActionChanged/dropExit→dropon the receiver, thencompleted/canceledback on the source. - Files drop through the same
DropReceivermechanism, using theFilesmime-type.
Next: on to Popups & Modals for floating content that sits above everything else.