bekwam courses

TableView Context Menu

September 2, 2018

This article shows how to outfit a TableView with a ContextMenu. The ContextMenu provides the user with a way to choose from a set of actions that are relevant for the action's argument. In this case, the argument is a selected TableRow and a TableView. This diverges from the example in the TornadoFX Guide in that the action will not be displayed for empty TableRows since there is no argument.

The following video shows a TableView based on a Commit data structure. When the user right-clicks, a ContextMenu is displayed with a single action "Revert". Selecting "Revert" echoes a message to the console. In the video, I'm also right-clicking on the empty TableRows and the ContextMenu is not displayed.

Out-of-the-box, TornadoFX provides a convenient way to add a ContextMenu to a TableView. That works perfectly for TableView-oriented actions. In the action handler, you can get the selectedItem and execute on the action. However, for empty rows, there may not be a selectedItem and the user executes a no-op. Worse, the selectedItem may not be the TableRow on which the ContextMenu is executed. Usually, it's clear which item is selected, but if it's not as visually discernible, the user may right-click on an empty row and execute an action on a different row.

The app employs some slight-of-hand to show the progress. The ListView containing the messages obscures the ProgressIndicator panel. When the user drags, they start an animation that translates the ListView downward. The screen keeps this translation until after the fetch. Post-fetch, a second animation that reverses the translation is played.

Screen Shot of App
Revert Actions Displayed In Console

The data structure used in the program is a JavaFX-oriented class of Commit properties. A Controller holds a list of these items and also a String containing the console messages.

	
class Commit(commitDate : LocalDateTime, committer : String, logEntry : String) {

    val commitDateProperty = SimpleObjectProperty(commitDate)
    val committerProperty = SimpleStringProperty(committer)
    val logEntryProperty = SimpleStringProperty(logEntry)
}

class TableContextMenuController : Controller() {

    val data = mutableListOf(
        Commit(
            LocalDateTime.of(2018, Month.JANUARY, 3, 11, 15),
                "walkerca",
                "[#146] expose new user entity as a web service"),
        Commit(
            LocalDateTime.of(2018, Month.JANUARY, 2, 14, 45),
                "walkerro",
                "[#87] fixes table spacing glitch"),
        Commit(
            LocalDateTime.of(2018, Month.JANUARY, 1, 12, 0),
                "walkerca",
                "[#145] changes to support adding a user")
    ).observable()

    val consoleMessages = SimpleStringProperty("")
}

The View is a TableView on top of a TitledPane containing a TextArea. The TableView is bound to the Controller's data field and the TextArea is bound to the Controller's consoleMessages field. The TableViews contains three columns: date, committer, log entry.

	
class TableContextMenuView : View("Context Menu") {

    val c : TableContextMenuController by inject()

    override val root = vbox {

        tableview(c.data) {

            column("Date", Commit::commitDateProperty) {
                converter(LocalDateTimeStringConverter())
            }
            column("Committer", Commit::committerProperty)
            column("Log", Commit::logEntryProperty)

            rowFactory = Callback {
                val tr = object : TableRow<Commit>() {
                    override fun updateItem(item: Commit?, empty: Boolean) {
                        super.updateItem(item, empty)
                        if( item != null && !empty ) {
                            val revertMenuItem = MenuItem("Revert")
                            revertMenuItem.action {
                                c.consoleMessages += "Reverting commit from ${item.commitDateProperty.value} by ${item
                                        .committerProperty.value} - ${item.logEntryProperty.value}\n"
                            }
                            this.contextMenu = ContextMenu(revertMenuItem)
                        } else {
                            this.contextMenu = null
                        }
                    }
                }
                tr
            }

            vgrow = Priority.ALWAYS
        }

        titledpane("Console") {
            textarea(c.consoleMessages)
        }

        spacing = 4.0
        padding = Insets(4.0)
        prefHeight = 414.0
        prefWidth = 736.0
    }
}	

The implementation uses an anonymous class in the rowFactory. This class overrides the updateItem() function and adds a ContextMenu if the TableRow contains data. Note that because the TableRow will be recycled, you must clear out any unused contextMenus.

There is a trivial App and main provided in the source link

For table-wide actions, use the ContextMenu example found in the TornadoFX Guide. However, if you need to be more specific in your context, set the rowFactory to a TableRow that considers the argument underneath the mouse selection.

The complete TableContextMenuApp.kt file can be downloaded here.


Headshot of Carl Walker

By Carl Walker

President and Principal Consultant of Bekwam, Inc