December 23, 2017
One of the desktop requirements I get asked to implement is to build a shape-driven UI. Sometimes, this takes the form of a flowchart rendering a business rule that would suffer if it were implemented with off-the-shelf controls like a TreeView or a TableView. Although the functional requirements might be met with a non-graphical approach, the shape-driven approach often helps users see the "big picture" and to be more productive by consolidating operations into single drag-and-drop actions.
This article shows how to set up a toolbox and to drag those items onto a work area. The user selects one of three colored rectangles from the left-hand toolbox. The user drags the selected item to the right and drops the item on to the work area. While dragging, the user is aware that they are in the midst of an operation by a tracking rectangle. There are highlights on the toolbox item and the target work area to give additional context to the operation.
This video shows several shapes being dragged on to the work are from the toolbox. A row of red rectangles is formed followed by a row of blue rectangles which are then followed by a row of yellow rectangles. Several overlapping rectangles are then added to the bottom row.
When the mouse hovers over a red, blue, or yellow toolbox item, the item becomes more transparent. As the user drags over the work area, a black border is drawn. The black border is not drawn if an item is not being dragged.
The application is coded in Kotlin using the TornadoFX framework. As with most TornadoFX applications, the program starts with a primary View.
class DraggingView : View("Dragging App") {
private val RECTANGLE_HEIGHT = 50.0
private val RECTANGLE_WIDTH = 50.0
private var draggingColor: Color? = null
private val toolboxItems = mutableListOf<Node>()
private var inflightRect: Rectangle by singleAssign()
private var toolbox: Parent by singleAssign()
private var workArea: Pane by singleAssign()
The primary (and only) View is a subclass called DraggingView. RECTANGLE_HEIGHT and RECTANGLE_WIDTH are two constants. draggingColor is the information that will be transferred between the toolbox and the work area. toolboxItems is a convenient data structure for the Rectangle items. inflightRect is an always-present, though sometimes invisible, Rectangle for tracking the drag cursor movement. toolbox and workArea are references to container Nodes.
When the user selects a toolbox item on the left (MOUSE_PRESSED), the variable draggingColor is set. The operation is completed by a MOUSE_RELEASED over the workArea. If the MOUSE_RELEASED came during the drag operation, a new Rectangle object is formed from the value of the draggingColor. draggingColor is a single piece of data that fully supports the correct creation of the object. Other apps may need to track more information than color and would expand this to multiple fields or a container object.
For this requirement, notice that I'm not moving a Node. This is acceptable for the app because I always want the toolbox item to stay where it is for subsequent drags. Also, I can have multiple instances of similar items on the workArea. Using this technique, I avoid transferring a Node object from one container to another. There are times where you need to move a Node, but this is best done if you can limit it to movement within a single parent container using the relocate() function. A future blog post will build on this app and allow you to move and object on the workArea.
The next block of code we'll look at is the root field of the View. The root is an HBox with a child VBox "toolbox" and a child AnchorPane containing the Pane "workArea".
The VBox "toolbox" contains a generalized function for building the three items. Each toolbox item is using a JavaFX custom property "rectColor" to distinguished between a selection. This property will be used later to set the value of the state variable draggingColor.
The Pane "workArea" is a simple container. It has a permanent object "inflightRect" which is used to track the drag operation. As mentioned earlier, refraining from dragging actual Node objects means that I don't have to worry about managing a Node as it moves from one container to another. The inflightRect object is simply turned on and off based on the mouse movement over the workArea, its color adjusted for the relevant selection.
Finally, there is a set of mouse and drag event handlers. I put these on the outermost HBox container so that I can easily coordinate events in two child containers. Notice that there are no handlers on the Rectangle, toolbox, or workArea. Instead, the higher-level container will consult these objects throughout the drag operation.
The init function adds the toolbox items to a plain List data structure as a convenience.
override val root = hbox {
addClass(DraggingStyles.wrapper)
toolbox = vbox {
fun createToolboxItem(c : Color) : Rectangle {
return rectangle(width=RECTANGLE_WIDTH,height=RECTANGLE_HEIGHT) {
fill = c
properties["rectColor"] = c
addClass(DraggingStyles.toolboxItem)
}
}
add(createToolboxItem(Color.RED))
add(createToolboxItem(Color.BLUE))
add(createToolboxItem(Color.YELLOW))
spacing = 10.0
padding = Insets(10.0)
alignment = Pos.CENTER
hboxConstraints {
hgrow = Priority.NEVER
}
}
anchorpane {
workArea = pane {
addClass(DraggingStyles.workArea)
anchorpaneConstraints {
leftAnchor = 0.0
topAnchor = 0.0
rightAnchor = 0.0
bottomAnchor = 0.0
}
inflightRect = rectangle(0, 0, RECTANGLE_WIDTH, RECTANGLE_HEIGHT) {
isVisible = false
opacity = 0.7
effect = DropShadow()
}
add( inflightRect )
}
hboxConstraints {
hgrow = Priority.ALWAYS
}
}
vboxConstraints {
vgrow = Priority.ALWAYS
}
padding = Insets(10.0)
spacing = 10.0
addEventFilter(MouseEvent.MOUSE_PRESSED, ::startDrag)
addEventFilter(MouseEvent.MOUSE_DRAGGED, ::animateDrag)
addEventFilter(MouseEvent.MOUSE_EXITED, ::stopDrag)
addEventFilter(MouseEvent.MOUSE_RELEASED, ::stopDrag)
addEventFilter(MouseEvent.MOUSE_RELEASED, ::drop)
}
init {
toolboxItems.addAll( toolbox.childrenUnmodifiable )
}
The drag operation begins with a selection in the toolbox. The user presses the mouse and a MOUSE_PRESSED event is registered. Recall that this event is registered by the outermost HBox and by neither the toolbox nor the Rectangle itself.
private fun startDrag(evt : MouseEvent) {
toolboxItems
.filter {
val mousePt : Point2D = it.sceneToLocal( evt.sceneX, evt.sceneY )
it.contains(mousePt)
}
.firstOrNull()
.apply {
if( this != null ) {
draggingColor = this.properties["rectColor"] as Color
}
}
}
Working with the immuable toolboxItems list, the list is filtered to the single item under the cursor. Because the Rectangles don't overlap, there will only ever be one such match. Using the JavaFX property, "rectColor", the state variable draggingColor is set.
After this variable is set, the operation is animated via a MOUSE_DRAGGED event. This activates the previously-hidden inflightRect and highlights the workArea if the MOUSE_DRAGGED is recorded in the workArea. Notice the relocate() method used to move the inflightRect Rectangle around. This is a very performant and convenient way to move a Node around in a container.
private fun animateDrag(evt : MouseEvent) {
val mousePt = workArea.sceneToLocal( evt.sceneX, evt.sceneY )
if( workArea.contains(mousePt) ) {
// highlight the drop target (hover doesn't work)
if( !workArea.hasClass(DraggingStyles.workAreaSelected)) {
workArea.addClass(DraggingStyles.workAreaSelected)
}
// animate a rectangle so that the user can follow
if( !inflightRect.isVisible ) {
inflightRect.isVisible = true
inflightRect.fill = draggingColor
}
inflightRect.relocate( mousePt.x, mousePt.y )
}
}
stopDrag() is a function called in two contexts: after a drag is completed or when a drag is canceled. A drag is canceled when the mouse is moved outside of the workArea while an item is selected. Rather than try to chain these calls together in the terminal drop() function, I register this function as a second filter for the MOUSE_RELEASED event.
private fun stopDrag(evt : MouseEvent) {
if( workArea.hasClass(DraggingStyles.workAreaSelected ) ) {
workArea.removeClass(DraggingStyles.workAreaSelected)
}
if( inflightRect.isVisible ) {
inflightRect.isVisible = false
}
}
The stopDrag() function undoes the animateDrag() function by highing the inflightRect and removing the border (via a CSS style) on the workArea.
The last method to review is drop(). This occurs when a MOUSE_RELEASED is over the workArea and results in a new Rectangle being added to workArea.
private fun drop(evt : MouseEvent) {
val mousePt = workArea.sceneToLocal( evt.sceneX, evt.sceneY )
if( workArea.contains(mousePt) ) {
if( draggingColor != null ) {
val newRect = Rectangle(RECTANGLE_WIDTH, RECTANGLE_HEIGHT, draggingColor)
workArea.add( newRect )
newRect.relocate( mousePt.x, mousePt.y )
inflightRect.toFront() // don't want to move cursor tracking behind added objects
}
}
draggingColor = null
}
The main App subclass and the Stylesheet class can be found in the source download.
This article showed how to drag an object from a toolbox onto a workArea. Although it seemed like a Node was moving from one container to another, some slight-of-hand was used to record a simple state variable and to reconstitute a brand new selection in the target container. Moreover, the operation was tracked by a faux Rectangle that really wasn't part of the transfer at all. This was done to leverage the relocate() method as much as possible and to keep a clean layout structure. It won't work for every requirement, but if you can stick to relocate()-ing within one container, you can avoid having to do a lot of inter-container translation of coordinates.
The source code presented in this article series is a Gradle project found in the zip file below. The DraggingApp source is paired with another example, SelectingApp.
To run the demo, create an Application configuration in your IDE that will run the DraggingApp class. In IntellIJ,
By Carl Walker
President and Principal Consultant of Bekwam, Inc