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