JavaFX TableViews with ContextMenus

Introduction

Most of the Java work I do involves creating command-line based tools for processing data. However, we have one ongoing project (which may never get finished), to build an in-house Lab Information Management System (LIMS). The front-end for this is in JavaFX, connecting with a JEE backend and database. This gives me an excuse to play with JavaFX, which I think is an excellent toolkit and has some very neat (and not immediately obvious) features.

TableViews with ContextMenus

One question that comes up a lot of various JavaFX forums is about attaching ContextMenus to TableViews. Like all Controls, TableView has a setContextMenu(…) method; the problem arises when the programmer wants a MenuItem to process data for a particular row or cell. For example, an item in the context menu may allow the user to delete the table row on which the context menu appeared (i.e. on the row on which the user clicked).

First, here’s how not to do this:

ContextMenu menu = new ContextMenu();
MenuItem removeMenuItem = new MenuItem("Remove");
menu.getItems().add(removeMenuItem);
table.setContextMenu(menu);
// removeMenuItem will remove the row from the table:
removeMenuItem.setOnAction(new EventHandler() {
  @Override
  public void handle(ActionEvent event) {
    // user must have clicked to make context menu appear
    // clicking on a table selects the row, 
    // so the correct row will be the selected one
    // BROKEN! DON'T DO THIS:
    table.getItems().remove(
      table.getSelectionModel().getSelectedItem()
    );
  }
});

Why is this bad?

  • The selection model, which controls which rows in the table are selected, is configurable. If, at a later date, you decide to change the selection model (for example to disable selecting some items), the code above might break
  • The table often displays empty rows (if there is not enough data to fill the space allocated to the table). If you right-click on an empty row, the selection won’t change, so you may end up deleting the wrong row. In this case, you probably don’t want the context menu to appear at all.

A better way, for the above example, is to set the context menu on the TableRow, rather than the TableView. To do this, you need a custom row factory, and we’ll look at some ways of doing this shortly. The main point, though, is that a ContextMenu depends on its context. When working with a TableView there are three possible contexts to which the menu might apply:

  1. If the actions of the menu item apply to the whole table, i.e. they are independent of any particular row in the table, then the context is the entire TableView. Examples of such actions are adding a new row to the table, or clearing all items from the table.
  2. If the actions of the menu item apply to an entire row in the table, then the context is the TableRow. Examples of such actions are deleting a row, or showing an editor to change values in the row.
  3. If the actions of the menu apply to a single cell in the table, then the context is the TableCell. Accessing a TableCell requires implementing a custom cellFactory. Examples of this are rarer, but might be applicable, say, if one column in your table had a sensible notion of “reset this value to the default” and you wanted that to be an action in the MenuItem.

We’ll work through each of these in turn, then I will show how to combine them when you have MenuItems with different contexts. In another post, I’ll show some techniques for creating reusable factories for doing this.

For an example, I’ll expand on the AddressBook example from Oracle’s JavaFX Tutorial. This example uses a simple Person class as the data model for the table: the Person class just encapsulates the firstName, lastName, and emailAddress for a contact. I’ll modify the Person class slightly, adding in property accessors and making the methods final, which is good practice for classes like this. The code for the Person class is posted at github, along with the final version of the TableViewSample for this post.

When the Context is the TableView

When the actions for all your menu items are actions that apply to the entire table, and are independent of any row, you can just set a context menu on the table itself. This is the simplest case, and is very easy to implement:

final TableView<Person> table = new TableView<>();
table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
// add columns and data...
final ContextMenu menu = new ContextMenu();
final MenuItem addNewPersonItem = new MenuItem("Add...");
addNewPersonItem.setOnAction(new EventHandler<ActionEvent>() {
  @Override
  public void handle(ActionEvent event) {
    // show dialog to enter First Name, 
    // Last Name, and Email
    Person person = ... // get new Person from dialog
    table.getItems().add(person);
  }
});
final MenuItem deleteAllSelectedItem 
  = new MenuItem("Delete all selected rows");
deleteAllSelectedItem.setOnAction(new EventHandler<ActionEvent>() {
  @Override
  public void handle(ActionEvent event) {
    table.getItems().removeAll(
      table.getSelectionModel().getSelectedItems());
  }
});
// disable this menu item if nothing is selected:
deleteAllSelectedItem.disableProperty.bind(
  Bindings.isEmpty(table.getSelectionModel().getSelectedItems()));
menu.getItems().addAll(addNewPersonItem, deleteAllSelectedItem);
table.setContextMenu(menu);

So this is all very well, but to perform an action on a specific row there is no nice way to figure out the correct row on which to perform the action. Actions which are performed on specific rows need to use the TableRow as their context.

When the context is the TableRow

When the action of a menu item is performed on a specific row, the context menu should be set on the TableRow. To get access to the TableRow, we need to specify a custom rowFactory.

A rowFactory is a Callback<TableView<T>, TableRow<T>>, where T is the type of the data in the table (i.e. the type of an item displayed in a row: Person in our example). The JavaFX machinery will invoke this factory’s call(…) method, passing in a reference to the table, and use the returned TableRow to display the contents of the row (which, by default, will be made up of a series of TableCells, one for each column).

It’s important to note that there is not a one-one relationship between the items in the table’s items list and TableRow instances. TableRows can be reused; typically if there is more data than can be shown all at once, TableRow instances will only be made for the visible rows, and may be recycled to show different items as the table is scrolled vertically. Because of this, we need to keep track of the current item that is displayed by the TableRow, and make sure we act on the current item.

Here’s how we can use a TableRow factory to set a context menu on each specific row. We can use the table row’s item property to get at the item being displayed in that particular row.

table.setRowFactory(
    new Callback<TableView<Person>, TableRow<Person>>() {
  @Override
  public TableRow<Person> call(TableView<Person> tableView) {
    final TableRow<Person> row = new TableRow<>();
    final ContextMenu rowMenu = new ContextMenu();
    MenuItem editItem = new MenuItem("Edit");
    editItem.setOnAction(...);
    MenuItem removeItem = new MenuItem("Delete");
    removeItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override
      public void handle(ActionEvent event) {
        table.getItems().remove(row.getItem());
      }
    });
    rowMenu.getItems().addAll(editItem, removeItem);

    // only display context menu for non-null items:
    row.contextMenuProperty().bind(
      Bindings.when(Bindings.isNotNull(row.itemProperty()))
      .then(rowMenu)
      .otherwise((ContextMenu)null));
    return row;
  }
});

Note how we restrict the context menu to only appear on non-null rows by binding the contextMenuProperty of the table row to a conditional value, using the Bindings API.

When the context is the TableCell

A less common scenario is that an action for a menu item corresponds solely to a single cell in the table. As an example, we’ll imagine we want the user to have an option to send an email to one of our contacts, but only if they right-click on the cell in the table displaying the email address. To access the TableCell, we need a custom cellFactory. This is similar to the custom rowFactory we created, but there is one more thing we need to do. The TableCell is responsible for displaying the data in that cell. Creating a TableCell by calling the default TableCell constructor will not display anything, so we need to add that functionality in.

Oracle’s tutorial suggests doing this by subclassing TableCell and overriding the updateItem(…) method. While that works, it seems a little overly complex to me, and in some (other) circumstances it can be a tricky technique, requiring some knowledge of how the TableCell is implemented. I prefer to create a default TableCell and, since this cell is displaying a String, simply bind its textProperty to its itemProperty:

TableCell<Person, String> cell = new TableCell<>();
cell.textProperty().bind(cell.itemProperty());

The TableCell can handle having its text set to null, so we don’t have to worry about empty cells here. The complete code for this, with a ContextMenu, looks like this:

emailCol.setCellFactory(
    new Callback<TableColumn<Person, String>, 
      TableCell<Person, String>>() {  
  @Override
  public TableCell<Person, String> call(
      TableColumn<Person, String> col) {

    final TableCell<Person, String> cell = new TableCell<>();
    cell.textProperty().bind(cell.itemProperty());
    cell.itemProperty().addListener(new ChangeListener<String>() {
      @Override
      public void changed(ObservableValue<? extends String> obs,
          String oldValue, String newValue) {
        if (newValue != null) {
          final ContextMenu cellMenu = new ContextMenu();
          final MenuItem emailMenuItem = new MenuItem("Email");
          emailMenuItem.setOnAction(new EventHandler<ActionEvent>(){
            @Override
            public void handle(ActionEvent event) {
              String emailAdd = cell.getItem();
              Person person = cell.getTableRow().getItem();
              System.out.println("Email " + person + 
                                 " at " + emailAdd);
            }
          });
          cellMenu.getItems().add(emailMenuItem);
          cell.setContextMenu(cellMenu);
        } else {
          cell.setContextMenu(null);
        }
      } 
    });
    return cell;
  }
});

Note how we can get the tableRow with cell.getTableRow() and get the value for the row from it with cell.getTableRow().getItem(). In our example this gives a reference to the Person. We can get the value shown in the cell with cell.getItem(), giving the email address in this example.

Combining Contexts

In most real examples, you’ll have some MenuItems from each of the contexts. You could simply add them all to the most specific context, but it’s slightly nicer from a user perspective to define a context menu for each of the contexts, and “borrow” the menu items from the more general context down to the more specific ones. This means, for example, that if the user right-clicks on an empty row, they’ll see the menu items that are applicable to the table only. If they click on a populated row without cell-specific actions, they’ll see only the table-wide actions and the row-specific actions.

I don’t know of a “pretty” way to do this. I’m simply going to update the code for the rowFactory to check if the table has a context menu attached to it, and if so, add all it’s menu items to the table row’s context menu:

table.setRowFactory(
    new Callback<TableView<Person>, TableRow<Person>>() {
  @Override
  public TableRow<Person> call(TableView<Person> tableView) {
    final TableRow<Person> row = new TableRow<>();
    final ContextMenu rowMenu = new ContextMenu();

    // "Borrow" menu items from table's context menu,
    // if there is one.
    final ContextMenu tableMenu = tableView.getContextMenu();
    if (tableMenu != null) {
      rowMenu.getItems().addAll(tableMenu.getItems());
      rowMenu.getItems().add(new SeparatorMenuItem());
    }
    MenuItem editItem = new MenuItem("Edit");
    editItem.setOnAction(...);
    MenuItem removeItem = new MenuItem("Delete");
    removeItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override
      public void handle(ActionEvent event) {
        table.getItems().remove(row.getItem());
      }
    });
    rowMenu.getItems().addAll(editItem, removeItem);

    // only display context menu for non-null items:
    row.contextMenuProperty().bind(
      Bindings.when(Bindings.isNotNull(row.itemProperty()))
      .then(rowMenu)
      .otherwise((ContextMenu)null));
    return row;
  }
});

The update to the cellFactory is similar, but we first check for the TableRow having a context menu; if it doesn’t, we check for the TableView having a context menu.

The full code for the example is posted as a gist at github.

If you have a lot of tables in your UI, each with context menus, this technique will lead to a lot of repetitive code. In the next post, I’ll show how to make a couple of reusable classes (one for a context menu on a TableRow, and one for a context menu on a TableCell), so the only additional code you need is the generation of the MenuItems.

5 thoughts on “JavaFX TableViews with ContextMenus

  1. Thank you very much for your example and your tests. I’ll then consider using your solution since a 10% effect (at startup) is not that much. I’ll see what are the results and let you know about it if you’d like.

  2. Sam,

    Thanks for the comment. I thought about this a bit. Your solution of course creates fewer ContextMenus (and MenuItems) and in some circumstances might perform better.

    I ran a quick test where I created a large table (100 columns and 1000 rows, of which about 1,200 table cells were visible at startup). This was a pretty naive example which just created all the table data directly in the start() method.

    Under JavaFX 2.2, adding the context menus added about 50% to the startup time, compared to displaying the same table without any context menus (the total startup time increased from 3.8 seconds to 5.7). Under JavaFX 8, the effect of the ContextMenus was less pronounced, showing about a 10% effect (startup increased from 2.8 seconds to 3.1). (Performance of TableCell management in general improved greatly in JavaFX 8.) Scrolling performance was not noticeably different with the addition of the ContextMenus. That seems acceptable, as all the performance hit is at startup, but of course there may be scenarios (very large, frequently updating tables) where that isn’t the case.

    One option I had considered was to have one context menu per column, referencing an observable property of the same type as the column. The context menu references the observable property in order to access cell-specific data. Then register a cell factory which detects the appropriate mouse trigger for displaying the context menu, sets the value of the property from the cell value, and shows the context menu. In the end I decided the additional complexity was not worth the performance saving, but it is an option to consider if performance is an issue in a specific case.

  3. First of all thank you for this very interesting post.

    I just have a few questions. I had the same problem and I found a workaround a bit different and a bit hacky. The main problem to me is the amount of ContextMenu created. Suppose I want different options on each cells of my TableView. And I have a huge one. Suppose I’m currently displaying 500 or 1000 cells on the screen (column’s width small and lots of lines displayed). Then you will instantiate each time, for each updateItem, for each cell a new ContextMenu?

    Not to mention that ContextMenu must be created on the JFX thread so you cannot put that call into another Task.
    So I’m wondering if this solution is viable with large amount of data? What do you think?

    As far as I am concerned, I’ve created one and only one ContextMenu on the table (the “bad” example in the beginning ^^). And I’ve added an InvalidationListener on the “showingProperty” of this contextMenu. Then I just use “setVisible” on each of my MenuItem in order to display them or not in the ContextMenu.
    Of course this solution is not a workaround for the point you mention about changing the selectionModel. But on the other hand, you just instantiate one and only one ContextMenu.

    Let me know about your opinion 🙂

    Sam’

Leave a Reply

Your email address will not be published. Required fields are marked *