Implementing tab functionality for CheckBox cells in TableView - javafx

I've created a TableView where each cell contains a TextField or a CheckBox. In the TableView you're supposed to be able to navigate left and right between cells using TAB and SHIFT+TAB, and up and down between cells using the UP and DOWN keys.
This works perfectly when a text field cell is focused. But when a check box cell is focused, the tab funcationality behaves strange. You can tab in the opposite direction of the cell you tabbed from, but you can't switch tab direction.
So for instance if you tabbed to the check box cell using only the TAB key, then SHIFT+TAB wont work. But if you tab to the next cell using the TAB key, and then TAB back using SHIFT+TAB (assuming the next cell is a text field cell), then TAB wont work.
I've tried running any code handling focus on the UI thread using Platform.runLater(), without any noteable difference. All I know is that the TAB KeyEvent is properly catched, but the check box cell and the check box never loses focus in the first place anyway. I've tried for instance removing its focus manually by doing e.g. getParent().requestFocus() but that just results in the parent being focused instead of the next cell. What makes it strange is that the same code is executed and working properly when you tab in the opposite direction of the cell you came from.
Here's a MCVE on the issue. Sadly it does not really live up to the "M" of the abbreviation:
import java.util.List;
import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.CheckBox;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;
public class AlwaysEditableTable extends Application {
public void start(Stage stage) {
TableView<ObservableList<StringProperty>> table = new TableView<>();
table.setEditable(true);
table.getSelectionModel().setCellSelectionEnabled(true);
table.setPrefWidth(510);
// Dummy columns
ObservableList<String> columns = FXCollections.observableArrayList("Column1", "Column2", "Column3", "Column4",
"Column5");
// Dummy data
ObservableList<StringProperty> row1 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
new SimpleStringProperty("Cell2"), new SimpleStringProperty("0"), new SimpleStringProperty("Cell4"),
new SimpleStringProperty("0"));
ObservableList<StringProperty> row2 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
new SimpleStringProperty("Cell2"), new SimpleStringProperty("1"), new SimpleStringProperty("Cell4"),
new SimpleStringProperty("0"));
ObservableList<StringProperty> row3 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
new SimpleStringProperty("Cell2"), new SimpleStringProperty("1"), new SimpleStringProperty("Cell4"),
new SimpleStringProperty("0"));
ObservableList<ObservableList<StringProperty>> data = FXCollections.observableArrayList(row1, row2, row3);
for (int i = 0; i < columns.size(); i++) {
final int j = i;
TableColumn<ObservableList<StringProperty>, String> col = new TableColumn<>(columns.get(i));
col.setCellValueFactory(param -> param.getValue().get(j));
col.setPrefWidth(100);
if (i == 2 || i == 4) {
col.setCellFactory(e -> new CheckBoxCell(j));
} else {
col.setCellFactory(e -> new AlwaysEditingCell(j));
}
table.getColumns().add(col);
}
table.setItems(data);
Scene scene = new Scene(table);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
/**
* A cell that contains a text field that is always shown.
*/
public static class AlwaysEditingCell extends TableCell<ObservableList<StringProperty>, String> {
private final TextField textField;
public AlwaysEditingCell(int columnIndex) {
textField = new TextField();
this.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) -> {
if (isNowEmpty) {
setGraphic(null);
} else {
setGraphic(textField);
}
});
// The index is not changed until tableData is instantiated, so this
// ensure the we wont get a NullPointerException when we do the
// binding.
this.indexProperty().addListener((obs, oldValue, newValue) -> {
ObservableList<ObservableList<StringProperty>> tableData = getTableView().getItems();
int oldIndex = oldValue.intValue();
if (oldIndex >= 0 && oldIndex < tableData.size()) {
textField.textProperty().unbindBidirectional(tableData.get(oldIndex).get(columnIndex));
}
int newIndex = newValue.intValue();
if (newIndex >= 0 && newIndex < tableData.size()) {
textField.textProperty().bindBidirectional(tableData.get(newIndex).get(columnIndex));
setGraphic(textField);
} else {
setGraphic(null);
}
});
// Every time the cell is focused, the focused is passed down to the
// text field and all of the text in the textfield is selected.
this.focusedProperty().addListener((obs, oldValue, newValue) -> {
if (newValue) {
textField.requestFocus();
textField.selectAll();
System.out.println("Cell focused!");
}
});
// Switches focus to the cell below if ENTER or the DOWN arrow key
// is pressed, and to the cell above if the UP arrow key is pressed.
// Works like a charm. We don't have to add any functionality to the
// TAB key in these cells because the default tab behavior in
// JavaFX works here.
this.addEventFilter(KeyEvent.KEY_RELEASED, e -> {
if (e.getCode().equals(KeyCode.UP)) {
getTableView().getFocusModel().focus(getIndex() - 1, this.getTableColumn());
e.consume();
} else if (e.getCode().equals(KeyCode.DOWN)) {
getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
e.consume();
} else if (e.getCode().equals(KeyCode.ENTER)) {
getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
e.consume();
}
});
}
}
/**
* A cell containing a checkbox. The checkbox represent the underlying value
* in the cell. If the cell value is 0, the checkbox is unchecked. Checking
* or unchecking the checkbox will change the underlying value.
*/
public static class CheckBoxCell extends TableCell<ObservableList<StringProperty>, String> {
private final CheckBox box;
public CheckBoxCell(int columnIndex) {
this.box = new CheckBox();
this.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) -> {
if (isNowEmpty) {
setGraphic(null);
} else {
setGraphic(box);
}
});
this.indexProperty().addListener((obs, oldValue, newValue) -> {
// System.out.println("Row: " + getIndex() + ", Column: " +
// columnIndex + ". Old index: " + oldValue
// + ". New Index: " + newValue);
ObservableList<ObservableList<StringProperty>> tableData = getTableView().getItems();
int newIndex = newValue.intValue();
if (newIndex >= 0 && newIndex < tableData.size()) {
// If active value is "1", the check box will be set to
// selected.
box.setSelected(tableData.get(getIndex()).get(columnIndex).equals("1"));
// We add a listener to the selected property. This will
// allow us to execute code every time the check box is
// selected or deselected.
box.selectedProperty().addListener((observable, oldVal, newVal) -> {
if (newVal) {
// If newValue is true the checkBox is selected, and
// we set the corresponding cell value to "1".
tableData.get(getIndex()).get(columnIndex).set("1");
} else {
// Otherwise we set it to "0".
tableData.get(getIndex()).get(columnIndex).set("0");
}
});
setGraphic(box);
} else {
setGraphic(null);
}
});
// If I listen to KEY_RELEASED instead, pressing tab next to a
// checkbox will make the focus jump past the checkbox cell. This is
// probably because the default TAB functionality is invoked on key
// pressed, which switches the focus to the check box cell, and then
// upon release this EventFilter catches it and switches focus
// again.
this.addEventFilter(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode().equals(KeyCode.UP)) {
System.out.println("UP key pressed in checkbox");
getTableView().getFocusModel().focus(getIndex() - 1, this.getTableColumn());
e.consume();
} else if (e.getCode().equals(KeyCode.DOWN)) {
System.out.println("DOWN key pressed in checkbox");
getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
e.consume();
} else if (e.getCode().equals(KeyCode.TAB)) {
System.out.println("Checkbox TAB pressed!");
TableColumn<ObservableList<StringProperty>, ?> nextColumn = getNextColumn(!e.isShiftDown());
if (nextColumn != null) {
getTableView().getFocusModel().focus(getIndex(), nextColumn);
}
e.consume();
// ENTER key will set the check box to selected if it is
// unselected and vice versa.
} else if (e.getCode().equals(KeyCode.ENTER)) {
box.setSelected(!box.isSelected());
e.consume();
}
});
// Tracking the focused property of the check box for debug
// purposes.
box.focusedProperty().addListener((obs, oldValue, newValue) ->
{
if (newValue) {
System.out.println("Box focused on index " + getIndex());
} else {
System.out.println("Box unfocused on index " + getIndex());
}
});
// Tracking the focused property of the check box for debug
// purposes.
this.focusedProperty().addListener((obs, oldValue, newValue) ->
{
if (newValue) {
System.out.println("Box cell focused on index " + getIndex());
box.requestFocus();
} else {
System.out.println("Box cell unfocused on index " + getIndex());
}
});
}
/**
* Gets the column to the right or to the left of the current column
* depending no the value of forward.
*
* #param forward
* If true, the column to the right of the current column
* will be returned. If false, the column to the left of the
* current column will be returned.
*/
private TableColumn<ObservableList<StringProperty>, ?> getNextColumn(boolean forward) {
List<TableColumn<ObservableList<StringProperty>, ?>> columns = getTableView().getColumns();
// If there's less than two columns in the table view we return null
// since there can be no column to the right or left of this
// column.
if (columns.size() < 2) {
return null;
}
// We get the index of the current column and then we get the next
// or previous index depending on forward.
int currentIndex = columns.indexOf(getTableColumn());
int nextIndex = currentIndex;
if (forward) {
nextIndex++;
if (nextIndex > columns.size() - 1) {
nextIndex = 0;
}
} else {
nextIndex--;
if (nextIndex < 0) {
nextIndex = columns.size() - 1;
}
}
// We return the column on the next index.
return columns.get(nextIndex);
}
}
}

After some digging in the TableView source code I found the issue. Here's the source code for the focus(int row, TableColumn<S, ?> column) method:
#Override public void focus(int row, TableColumn<S,?> column) {
if (row < 0 || row >= getItemCount()) {
setFocusedCell(EMPTY_CELL);
} else {
TablePosition<S,?> oldFocusCell = getFocusedCell();
TablePosition<S,?> newFocusCell = new TablePosition<>(tableView, row, column);
setFocusedCell(newFocusCell);
if (newFocusCell.equals(oldFocusCell)) {
// manually update the focus properties to ensure consistency
setFocusedIndex(row);
setFocusedItem(getModelItem(row));
}
}
}
The issue arises when newFocusCell is compared to oldFocusCell. When tabbing to a checkbox cell the cell would for some reason not get set as the focused cell. Hence the focusedCell property returned by getFocusedCell() will be the cell we focused before the check box cell. So when we then try to focus that previous cell again, newFocusCell.equals(oldFocusCell) will return true, and the focus will be set to the currently focused cell again by doing:
setFocusedIndex(row);
setFocusedItem(getModelItem(row));`
So what I had to do was make sure that the cell isn't be the value of the focusedCell property when we want to focus it. I solved this by setting the focus manually to the whole table before trying to switch the focus from the check box cell:
table.requestFocus();

Related

JavaFX 11 TableView Cell navigation by TAB key pressed without custom editableCell class

The problem:
I want to navigate through a TableView from one cell to the next right neighbor cell in JavaFX by using the TAB key.
Notice: The TableView is set to editable. And CellSelection is enabled too.
tableReceipt.getSelectionModel().setCellSelectionEnabled(true);
The handling of the KeyPressedEvent seemingly is not my problem, but to request the focus of the single cell on the right of the current cell.
I can focus one cell but when i press the TAB key the focus goes out of the table on other form elements.
The TableView contains some editable TextFieldTableCells and one editable ComboBoxTableCell.
I don't use a custom class for the editable Cells but Code like this:
Callback<TableColumn<Receipt, int>, TableCell<Receipt, int>> tfCallBack = TextFieldTableCell.forTableColumn();
columnAmount.setCellFactory(tfCallBack);
for a TableCell with editable TextField nature.
My question:
How can I implement a solution to solve my problem? A theoretical solution would help too. I allready searched for this topic but only found an example that's using a custom EditableCell class. I think there must be a solution using the callback method like I do.
Solution approach:
tableReceipt.setOnKeyPressed(new EventHandler<KeyEvent>() {
#Override
public void handle(KeyEvent t) {
if (t.getCode() == KeyCode.TAB) {
tableReceipt.getFocusModel().focusRightCell();
Robot r = new Robot();
r.keyPress(KeyCode.ENTER);
}
}
});
With this code I can get focus of the right cell next to the current one. And I need the ENTER KeyPress to enable the editable mode of the Cell. But when I press TAB on keyboard the new value is not committed. For example I press '2' the default value is '0' and after pressing TAB the value is again '0'.
Question No.2:
How can I combine the code above with a changeListener/onEditCommitListener, that the new value is stored in the cell after pressing TAB?
Thank you.
This may be helpful. You have to manually call it after you complete a cell edit.
import javafx.scene.control.TableColumn;
import javafx.scene.control.TablePosition;
import javafx.scene.control.TableView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import java.util.ArrayList;
import java.util.List;
/**
* Created by Brian Boutin on 5/14/2019
*/
class CellNav<T> {
private final TableView<T> tableView;
private KeyEvent lastKeyEvent;
private TablePosition editingPosition;
CellNav(TableView<T> tableView) {
this.tableView = tableView;
this.editingPosition = null;
tableView.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
if (event.getCode() == KeyCode.TAB || event.getCode() == KeyCode.ENTER) {
editingPosition = tableView.getEditingCell();
lastKeyEvent = event;
}
});
tableView.setOnMouseReleased(e -> editingPosition = null);
for (TableColumn col : tableView.getColumns()) {
col.setOnEditCancel(event -> editingPosition = null);
}
}
void doCellNav() {
if (editingPosition == null) {
return;
}
if (lastKeyEvent.getCode() == KeyCode.ENTER) {
int selectIdx;
if (lastKeyEvent.isShiftDown()) {
selectIdx = editingPosition.getRow() - 1;
} else {
selectIdx = editingPosition.getRow() + 1;
}
if (selectIdx < 0 ) {
selectIdx = tableView.getItems().size() - 1;
} else if (selectIdx > tableView.getItems().size() - 1) {
selectIdx = 0;
}
tableView.layout();
tableView.scrollTo(selectIdx == 0 ? selectIdx : selectIdx - 1);
tableView.getSelectionModel().clearAndSelect(selectIdx);
tableView.edit(selectIdx, editingPosition.getTableColumn());
} else if (lastKeyEvent.getCode() == KeyCode.TAB) {
TableColumn colToEdit = getNextColumn(!lastKeyEvent.isShiftDown(), editingPosition.getTableColumn());
if (colToEdit != null) {
tableView.layout();
tableView.scrollToColumn(colToEdit);
tableView.edit(editingPosition.getRow(), colToEdit);
}
}
editingPosition = null;
}
boolean isNavigating() {
return editingPosition != null;
}
private TableColumn getNextColumn(boolean forward, TableColumn currentCol) {
List<TableColumn> columns = new ArrayList<>();
for (TableColumn col : tableView.getColumns()) {
if (col.isEditable() && col.isVisible() && (col.getStyleClass().contains("editable-col") || col.getStyleClass().contains("result-col"))) {
columns.add(col);
}
}
if (columns.size() < 2) {
return null;
}
int currentIndex = -1;
for (int i = 0; i < columns.size(); i++) {
if (columns.get(i) == currentCol) {
currentIndex = i;
break;
}
}
int nextIndex = currentIndex;
if (forward) {
nextIndex++;
if (nextIndex > columns.size() - 1) {
nextIndex = 0;
}
} else {
nextIndex--;
if (nextIndex < 0) {
nextIndex = columns.size() - 1;
}
}
return columns.get(nextIndex);
}
}
public class ExampleClass{
CellNav cellNav;
public ExampleClass() {
TableView yourTableView = new TableView();
cellNav = new CellNav(yourTableView);
TableColumn someCol = new TableColumn();
someCol.setOnEditCommit(e -> {
//perform a save of your table data here
//pick up cell nav again
if (cellNav.isNavigating()) {
cellNav.doCellNav();
}
});
}
}

JavaFX TableView custom row style on double click

I have a TableView in my JavaFX application.
I would like to style differently row when it is double-clicked on it, and differently when it is single-clicked.
Here is what I achieve:
final PseudoClass doubleClickPseudoClass = PseudoClass.getPseudoClass("new");
setRowFactory(tableView -> {
final TableRow<Bean> row = new TableRow<Bean>();
row.setOnMouseClicked(event -> {
if (event.getClickCount() == 2 && (! row.isEmpty())) {
row.pseudoClassStateChanged(doubleClickPseudoClass, true);
});
return row;
});
However, when the user doubles click on every new row, I want all previously double-clicked rows to be styled without applying "new" class:
row.pseudoClassStateChanged(doubleClickPseudoClass, false);
How can I do that?
Now I have cumulative styled all rows as they are double-clicked.
You shouldn't use TableRows to store the state themselves since new items may be assigned to a TableRow instance. Instead use a property to store the item double-clicked item and use a listener for styling the rows:
final ObjectProperty<Bean> doubleClickedObject = new SimpleObjectProperty<>();
setRowFactory(tableView -> new TableRow<Bean>() {
private void updateStyle() {
pseudoClassStateChanged(doubleClickPseudoClass, !isEmpty() && doubleClickedObject.get() == getItem());
}
private final InvalidationListener listener;
{
listener = o -> updateStyle();
doubleClickedObject.addListener(new WeakInvalidationListener(listener));
setOnMouseClicked(event -> {
if (!isEmpty() && event.getClickCount() == 2) {
doubleClickedObject.set(getItem());
}
});
}
#Override
protected void updateItem(Bean item, boolean empty) {
super.updateItem(item, empty);
updateStyle();
}
});

JavaFX Spinner disable buttons on empty editor

Ok, heres my situation. I need to disable both spinner buttons when the Editor is empty, which is the final piece i need to complete this "custom" component. Heres my SSCCE.
When the focus is lost: Default value sets to zero and text is updated.
It only accepts decimal values with 2 decimal places, it is meant to only accept money or percentages values.
Nothing else to add.
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class test extends Application{
#Override
public void start(Stage primaryStage) throws Exception {
VBox v = new VBox();
v.setPadding(new Insets(20));
Spinner<Double> spinner = new Spinner<>();
spinner.setEditable(true);
Button dummy = new Button("dummy focus");
v.getChildren().addAll(spinner,dummy);
//----------------------------------HERE IS EVERYTHING RELATED TO THE SPINNER---------------------------------------------
spinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0, 100));
spinner.getValueFactory().setValue(0.0);
spinner.getEditor().textProperty().addListener((obs,old,gnu)->{
if(gnu.isEmpty()) {
System.out.println("empty, buttons should be disabled here, they will be disabled after this ");
spinner.getValueFactory().setValue(0.0);
return;
}
System.out.println("enabling buttons");
if(!gnu.matches("^\\d*\\.?\\d*$")) {
try {
spinner.getEditor().setText(old);
spinner.getValueFactory().setValue(Double.parseDouble(old));
}catch (NumberFormatException e) {
System.out.println("invalid string, previous value was empty, no biggie you are safe: Current value : "+spinner.getValueFactory().getValue());
}
} else {
if((Double.parseDouble(gnu)*100)%1!=0) {
spinner.getEditor().setText(old);
spinner.getValueFactory().setValue(Double.parseDouble(old));
}
/*
* You can use this to validate inside a range, for example. PERCENTAGES : 0 ~ 100
*
double val = Double.parseDouble(gnu)*100;
if(val%1!=0 || val>10000 || val<0) {
spinner.getEditor().setText(old);
spinner.getValueFactory().setValue(Double.parseDouble(old));
}
*/
}
});
spinner.getEditor().setOnKeyPressed(e->{
switch (e.getCode()) {
case UP:
spinner.increment(1);
break;
case DOWN:
spinner.decrement(1);
break;
default:
break;
}
});
spinner.setOnScroll(e->{
if(e.getDeltaY()>0)
spinner.increment(1);
else
spinner.decrement(1);
});
spinner.getEditor().focusedProperty().addListener((obs,old,niu)->{
if(!niu && spinner.getEditor().getText().isEmpty()) {
spinner.getEditor().setText("0");
spinner.getValueFactory().setValue(0.0);
}
});
//-----------------------------------------------------------------------------------------------------------------------------------------
Scene sc = new Scene(v);
primaryStage.setScene(sc);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
EDIT:
It also happens with keypress and scroll events.
I was also searching for a way to disable only the buttons. I needed to disable changing the Spinner's value while still allowing copy/paste, so I did some digging in the source code.
What I found was that, while the buttons are not actually part of the Spinner object itself, they're part of the Spinner's SpinnerSkin (they're also not Buttons, but StackPanes), so I managed to disable only the buttons with the following (in Kotlin):
val editing = SimpleBooleanProperty(false)
val spinner = Spinner<Int>()
spinner.skinProperty().addListener { observable, oldValue, newValue ->
// only bind if the skin is an instance of `SpinnerSkin`
if (newValue != null && newValue is SpinnerSkin<*>) {
(skin as SpinnerSkin<*>).children
// only select the children that are `StackPane`s (the buttons)
.filter { it is StackPane }
// bind the `disableProperty` of the buttons to our property for whether we're editing
.forEach { disableProperty().bind(editing.not()) }
}
}
I had to listen to the property change because the the skinProperty is not set on initialization, but only after the CSS gets processed. If you are absolutely sure that your spinner has already displayed and the skin is set, you can just call getSkin instead.
I'm afraid you can't disable only the spinner's buttons. But what about setting the value just after the Editor (which is actually the TextField) is empty? By using such a solution you don't get any exceptions after clicking buttons - value is just incremented from 0. I modified your gnu.isEmpty() code a little.
if(gnu.isEmpty()) {
System.out.println("empty, buttons should be disabled here, they will be disabled after this ");
double valueToSet = 0.0;
spinner.getValueFactory().setValue(valueToSet);
Platform.runLater(() -> spinner.getEditor().setText(Double.toString(valueToSet)));
return;
}
Another thing is, that your code allows to put '0' as a first number, even if there are another numbers after. Check that code, should fix the problem (swap it with the whole if/else statement starting with if(!gnu.matches("^\\d*\\.?\\d*$"))):
if (!isDouble(gnu)) {
gnu = old;
}
spinner.getEditor().setText(gnu);
Where isDouble is a method:
private boolean isDouble(String string) {
boolean startsWithZero =
string.startsWith("0") &&
(string.length() > 1) &&
(!string.startsWith("0."));
boolean minusZeroCondition =
string.startsWith("-0") &&
(string.length() > 2) &&
(!string.startsWith("-0."));
boolean containsTypeSpecificLetters =
Pattern.matches(".*[a-zA-Z].*", string);
boolean isEmpty = string.equals("");
boolean isMinus = string.equals("-");
try {
Double.parseDouble(string);
return !(startsWithZero || minusZeroCondition || containsTypeSpecificLetters);
} catch (IllegalArgumentException exception) {
return isEmpty || isMinus;
}
}

javafx TableView with dynamic ContextMenu on rows

I'm trying to make a java media player with DLNA Control Point.
There is a table with media files.
With JavaFX TableView, what I have learned, within the setRowFactory callback, we can add listeners on events, generated by table elements properties. All event types of TableView are fired only on internal table data changes.
I can't find a way to get to the table rows in case of some external event or logic, and to modify, for example, the ContextMenu for each row.
Each row in a table represents a media file. The ContextMenu initially has only "Play" (locally) and "Delete" menu items.
For instance, a DLNA renderer device has appeared on the network. DLNA discovery thread has fired an event and I want to add a "Play to this device" menu item to the context menu of each table row. Respectively, I will need to remove this item, as soon as the corresponding device will go off.
How to hook to the ContextMenu of each row from outside of the rowFactory stuff?
Here's the code of the table and row factory
public FileManager(GuiController guiController) {
gCtrl = guiController;
gCtrl.fileName.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Name"));
gCtrl.fileType.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Type"));
gCtrl.fileSize.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Size"));
gCtrl.fileTime.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("modifiedTime"));
gCtrl.filesTable.setRowFactory(tv -> {
TableRow<FileTableItem> row = new TableRow<>();
row.emptyProperty().addListener((obs, wasEmpty, isEmpty) -> {
if (!isEmpty) {
FileTableItem file = row.getItem();
ContextMenu contextMenu = new ContextMenu();
if (file.isPlayable()) {
row.setOnMouseClicked(event -> {
if (event.getClickCount() == 2) {
gCtrl.playMedia(file.getAbsolutePath());
}
});
MenuItem playMenuItem = new MenuItem("Play");
playMenuItem.setOnAction(event -> {
gCtrl.playMedia(file.getAbsolutePath());
});
contextMenu.getItems().add(playMenuItem);
}
if (file.canWrite()) {
MenuItem deleteMenuItem = new MenuItem("Delete");
deleteMenuItem.setOnAction(event -> {
row.getItem().delete();
});
contextMenu.getItems().add(deleteMenuItem);
}
row.setContextMenu(contextMenu);
}
});
return row;
});
gCtrl.filesTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
}
...
public class FileTableItem extends File {
...
}
Thanks in advance
JavaFX generally follows MVC/MVP type patterns. In a table view, the TableRow is part of the view: therefore to change the appearance of the table row (including the content of the context menu associated with it in this case), you should let it observe some kind of model, and to change what is displayed in the context menu you change that model.
I'm not entirely sure if I've understood your use case correctly, but I think I understand that each item in the table may have a different set of devices associated with it. So you would have an entity class looking something like this:
public class FileTableItem extends File {
private final ObservableList<Device> devices = FXCollections.observableArrayList();
public ObservableList<Device> getDevices() {
return devices ;
}
}
When you create the table row, you need it to observe the list of devices associated with its current item; you can do this with a ListChangeListener. Of course, the item being displayed at any given time by a row can change at arbitrary times beyond your control (when the user scrolls the table, for example), so you need to observe the row's item property and make sure the ListChangeListener is observing the correct list of items. Here is some code that achieves this:
TableView<FileTableItem> filesTable = new TableView<>();
filesTable.setRowFactory(tv -> {
TableRow<FileTableItem> row = new TableRow<>();
ContextMenu menu = new ContextMenu();
ListChangeListener<FileTableItem> changeListener = (ListChangeListener.Change<? extends FileTableItem> c) ->
updateMenu(menu, row.getItem().getDevices());
row.itemProperty().addListener((obs, oldItem, newItem) -> {
if (oldItem != null) {
oldItem.getDevices().removeListener(changeListener);
}
if (newItem == null) {
contextMenu.getItems().clear();
} else {
newItem.getDevices().addListener(changeListener);
updateMenu(menu, newItem.getDevices());
}
});
row.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) ->
row.setContextMenu(isNowEmpty ? null : menu));
return row ;
});
// ...
private void updateMenu(ContextMenu menu, List<Device> devices) {
menu.getItems().clear();
for (Device device : devices) {
MenuItem item = new MenuItem(device.toString());
item.setOnAction(e -> { /* ... */ });
menu.getItems().add(item);
}
}
This will now automatically update the context menu if the list of devices changes.
In the comments below your question you said you wanted there to be a getRows() method in the table. There isn't such a method, partly because the design is using a MVC approach as described. Even if there were, it wouldn't really help: suppose the list of devices for an item scrolled out of view changed - in that case there would not be a TableRow corresponding to that item, so you would not be able to get a reference to a row to change its context menu. Instead, with the setup described, you would simply update the model at the point in the code where you intend to update the table row.
You might need to modify this if you have menu items that are not dependent on the list, etc, but this should be enough to show the idea.
Here is a SSCCE. In this example, there are initially 20 items in the table, with no devices attached. The context menu for each shows just a "Delete" option which deletes the item. Instead of a background task which updates the items, I mimicked this with some controls. You can select an item in the table and add devices to it by pressing the "Add device" button: you will subsequently see "Play on device...." appearing in its context menu. Similarly "Remove device" will remove the last device in the list. The "Delay" check box will delay the addition or removal of a device by two seconds: this allows you to press the button and then (quickly) open the context menu; you will see the context menu update while it is being shown.
import javafx.animation.PauseTransition;
import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;
import javafx.util.Duration;
public class DynamicContextMenuInTable extends Application {
private int deviceCount = 0 ;
private void addDeviceToItem(Item item) {
Device newDevice = new Device("Device "+(++deviceCount));
item.getDevices().add(newDevice);
}
private void removeDeviceFromItem(Item item) {
if (! item.getDevices().isEmpty()) {
item.getDevices().remove(item.getDevices().size() - 1);
}
}
#Override
public void start(Stage primaryStage) {
TableView<Item> table = new TableView<>();
TableColumn<Item, String> itemCol = new TableColumn<>("Item");
itemCol.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().getName()));
table.getColumns().add(itemCol);
table.setRowFactory(tv -> {
TableRow<Item> row = new TableRow<>();
ContextMenu menu = new ContextMenu();
MenuItem delete = new MenuItem("Delete");
delete.setOnAction(e -> table.getItems().remove(row.getItem()));
menu.getItems().add(delete);
ListChangeListener<Device> deviceListListener = c ->
updateContextMenu(row.getItem(), menu);
row.itemProperty().addListener((obs, oldItem, newItem) -> {
if (oldItem != null) {
oldItem.getDevices().removeListener(deviceListListener);
}
if (newItem != null) {
newItem.getDevices().addListener(deviceListListener);
updateContextMenu(row.getItem(), menu);
}
});
row.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) ->
row.setContextMenu(isNowEmpty ? null : menu));
return row ;
});
CheckBox delay = new CheckBox("Delay");
Button addDeviceButton = new Button("Add device");
addDeviceButton.disableProperty().bind(table.getSelectionModel().selectedItemProperty().isNull());
addDeviceButton.setOnAction(e -> {
Item selectedItem = table.getSelectionModel().getSelectedItem();
if (delay.isSelected()) {
PauseTransition pause = new PauseTransition(Duration.seconds(2));
pause.setOnFinished(evt -> {
addDeviceToItem(selectedItem);
});
pause.play();
} else {
addDeviceToItem(selectedItem);
}
});
Button removeDeviceButton = new Button("Remove device");
removeDeviceButton.disableProperty().bind(table.getSelectionModel().selectedItemProperty().isNull());
removeDeviceButton.setOnAction(e -> {
Item selectedItem = table.getSelectionModel().getSelectedItem() ;
if (delay.isSelected()) {
PauseTransition pause = new PauseTransition(Duration.seconds(2));
pause.setOnFinished(evt -> removeDeviceFromItem(selectedItem));
pause.play();
} else {
removeDeviceFromItem(selectedItem);
}
});
HBox buttons = new HBox(5, addDeviceButton, removeDeviceButton, delay);
BorderPane.setMargin(buttons, new Insets(5));
BorderPane root = new BorderPane(table, buttons, null, null, null);
for (int i = 1 ; i <= 20; i++) {
table.getItems().add(new Item("Item "+i));
}
Scene scene = new Scene(root);
primaryStage.setScene(scene);
primaryStage.show();
}
private void updateContextMenu(Item item, ContextMenu menu) {
if (menu.getItems().size() > 1) {
menu.getItems().subList(1, menu.getItems().size()).clear();
}
for (Device device : item.getDevices()) {
MenuItem menuItem = new MenuItem("Play on "+device.getName());
menuItem.setOnAction(e -> System.out.println("Play "+item.getName()+" on "+device.getName()));
menu.getItems().add(menuItem);
}
}
public static class Device {
private final String name ;
public Device(String name) {
this.name = name ;
}
public String getName() {
return name ;
}
#Override
public String toString() {
return getName();
}
}
public static class Item {
private final ObservableList<Device> devices = FXCollections.observableArrayList() ;
private final String name ;
public Item(String name) {
this.name = name ;
}
public ObservableList<Device> getDevices() {
return devices ;
}
public String getName() {
return name ;
}
}
public static void main(String[] args) {
launch(args);
}
}
With an advice from sillyfly I got the working solution, however it may potentially have performance drawbacks. So it would be interesting to find a better one.
class FileManager {
private GuiController gCtrl;
protected Menu playToSub = new Menu("Play to...");
Map<String, MenuItem> playToItems = new HashMap<String, MenuItem>();
public FileManager(GuiController guiController) {
gCtrl = guiController;
gCtrl.fileName.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Name"));
gCtrl.fileType.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Type"));
gCtrl.fileSize.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Size"));
gCtrl.fileTime.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("modifiedTime"));
gCtrl.filesTable.setRowFactory(tv -> {
TableRow<FileTableItem> row = new TableRow<>();
row.emptyProperty().addListener((obs, wasEmpty, isEmpty) -> {
if (!isEmpty) {
FileTableItem file = row.getItem();
ContextMenu contextMenu = new ContextMenu();
if (file.isPlayable()) {
row.setOnMouseClicked(event -> {
if (event.getClickCount() == 2) {
gCtrl.mainApp.playFile = file.getName();
gCtrl.playMedia(file.getAbsolutePath());
}
});
MenuItem playMenuItem = new MenuItem("Play");
playMenuItem.setOnAction(event -> {
gCtrl.mainApp.playFile = file.getName();
gCtrl.playMedia(file.getAbsolutePath());
});
contextMenu.getItems().add(playMenuItem);
}
if (file.canWrite()) {
MenuItem deleteMenuItem = new MenuItem("Delete");
deleteMenuItem.setOnAction(event -> {
row.getItem().delete();
});
contextMenu.getItems().add(deleteMenuItem);
}
row.setContextMenu(contextMenu);
}
});
row.setOnContextMenuRequested((event) -> {
/// Here, just before showing the context menu we can decide what to show in it
/// In this particular case it's OK, but it may be time expensive in general
if(! row.isEmpty()) {
if(gCtrl.mainApp.playDevices.size() > 0) {
if(! row.getContextMenu().getItems().contains(playToSub)) {
row.getContextMenu().getItems().add(1, playToSub);
}
}
else {
if(row.getContextMenu().getItems().contains(playToSub)) {
row.getContextMenu().getItems().remove(playToSub);
}
}
}
});
return row;
});
gCtrl.filesTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
}
/// addPlayToMenuItem and removePlayToMenuItem are run from Gui Controller
/// which in turn is notified by events in UPNP module
/// The playTo sub menu items are managed here
public void addPlayToMenuItem(String uuid, String name, URL iconUrl) {
MenuItem playToItem = new PlayToMenuItem(uuid, name, iconUrl);
playToItems.put(uuid, playToItem);
playToSub.getItems().add(playToItem);
}
public void removePlayToMenuItem(String uuid) {
if(playToItems.containsKey(uuid)) {
playToSub.getItems().remove(playToItems.get(uuid));
playToItems.remove(uuid);
}
}
public class PlayToMenuItem extends MenuItem {
PlayToMenuItem(String uuid, String name, URL iconUrl) {
super();
if (iconUrl != null) {
Image icon = new Image(iconUrl.toString());
ImageView imgView = new ImageView(icon);
imgView.setFitWidth(12);
imgView.setPreserveRatio(true);
imgView.setSmooth(true);
imgView.setCache(true);
setGraphic(imgView);
}
setText(name);
setOnAction(event -> {
gCtrl.mainApp.playFile = gCtrl.filesTable.getSelectionModel().getSelectedItem().getName();
gCtrl.mainApp.startRemotePlay(uuid);
});
}
}
/// Other class methods and members
}

javafx tableview how to get the row I clicked?

here is my code
// this event is attached to TableCell
public EventHandler dblclickDescription = new EventHandler<MouseEvent>(){
#Override
public void handle(MouseEvent event) {
if(event.getButton().equals(MouseButton.PRIMARY)){
if(event.getClickCount() == 2){
printRow(event.getTarget());
}
}
event.consume();
}
};
// print row
public void printRow(Object o){
Text text = (Text) o;
// ??? don't know what to write here
System.out.println(row.toString());
}
1) how do I get from the cell I clicked to the row?
2) can I attach the event to the whole row instead of each column?
EDIT:
3) I thought that I attached the event on TableCell
TableCell cell = TableColumn.DEFAULT_CELL_FACTORY.call(p);
cell.setOnMouseClicked(dblclickDescription);
but when I tested,
event.getSource();// is a TableColumn
event.getTarget();// is a Text if clicked on text
event.getTarget();// is a TableColumn if clicked on empty space, even if that cell has text
is there a way to get TableCell from MouseEvent?
To answer your specific questions:
how do I get from the cell I clicked to the row?
TableCell defines a getTableRow() method, returning the TableRow. So you can do
Object item = cell.getTableRow().getItem();
which will give you the row item from the table (i.e. the correct element of table.getItems()). You can also get this from table.getItems().get(cell.getIndex()) if you prefer.
can I attach the event to the whole row instead of each column?
Yes. Define a rowFactory:
TableView<MyDataType> table = new TableView<>();
table.setRowFactory(tv -> {
TableRow<MyDataType> row = new TableRow<>();
row.setOnMouseClicked(event -> {
if (! row.isEmpty() && event.getButton()==MouseButton.PRIMARY
&& event.getClickCount() == 2) {
MyDataType clickedRow = row.getItem();
printRow(clickedRow);
}
});
return row ;
});
// ...
private void printRow(MyDataType item) {
// ...
}
To get back the index of the row or the selected item, use this code :
Object object = table.getSelectionModel().selectedItemProperty().get();
int index = table.getSelectionModel().selectedIndexProperty().get();
Then you can add a listener when the index / object change like this :
table.getSelectionModel().selectedIndexProperty().addListener((num) -> function());

Resources