I played with this a while. The bottom line is that the default commitEdit
method in TableCell
, which is invoked when you commit the change in the text field in the TextFieldTableCell
, calls updateItem(...)
on the table cell with the new value. That's why the value in the cell is changing even though you don't change it in the model.
To prevent this, you need to implement the table cell yourself, which isn't too hard. The easiest implementation should would probably just use a text field table cell and override updateItem(...)
to check the validity of the value. Something like
tcAantalDagen.setCellFactory(col -> new TextFieldTableCell<T, Integer>(new IntegerStringConverter()) {
@Override
public void updateItem(Integer item, boolean empty) {
if (empty) {
super.updateItem(item, empty) ;
} else {
// if out of range, revert to previous value:
if (item.intValue() < 1 || item.intValue() > 31) {
item = getItem();
}
super.updateItem(item, empty);
}
}
});
should work (though I haven't tested it). Obviously replace T
with whatever the type of the items in the table are. Note that I used Integer
as the column type here, which you can do if you provide an appropriate converter to the TextFieldTableCell
. You would modify the cell value factory as
tcAantalDagen.setCellValueFactory(cellData -> cellData.getValue().aantalDagenProperty().asObject());
However... once you're going to all the trouble of implementing a table cell, you may as well provide one that simply doesn't allow the user to enter an invalid value, which is a much nicer user experience imho. You can do this by creating a text field for the cell that uses a TextFormatter
that just vetoes invalid values. You have to be a little careful with these, as you want to allow values that would be partially-edited (so in general, it's not enough to allow only values that are valid, as they are checked every time the text changes, not just on commits). In this case, the only thing this means is that you should allow empty strings in the text field (else the user won't be able to delete the current value while editing, which would be awkward). You can use the converter to return the current value if the user tries to commit with an empty string.
So an implementation of this might look like
public class IntegerEditingCell extends TableCell<Item, Integer> {
private TextField textField ;
private TextFormatter<Integer> textFormatter ;
public IntegerEditingCell(int min, int max) {
textField = new TextField();
UnaryOperator<TextFormatter.Change> filter = c -> {
String newText = c.getControlNewText() ;
// always allow deleting all characters:
if (newText.isEmpty()) {
return c ;
}
// otherwise, must have all digits:
if (! newText.matches("\d+")) {
return null ;
}
// check range:
int value = Integer.parseInt(newText) ;
if (value < min || value > max) {
return null ;
} else {
return c ;
}
};
StringConverter<Integer> converter = new StringConverter<Integer>() {
@Override
public String toString(Integer value) {
return value == null ? "" : value.toString() ;
}
@Override
public Integer fromString(String string) {
// if it's an int, return the parsed value
if (string.matches("\d+")) {
return Integer.valueOf(string);
} else {
// otherwise, just return the current value of the cell:
return getItem() ;
}
}
};
textFormatter = new TextFormatter<Integer>(converter, 0, filter) ;
textField.setTextFormatter(textFormatter);
textField.addEventFilter(KeyEvent.KEY_RELEASED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
cancelEdit();
}
});
textField.setOnAction(e -> commitEdit(converter.fromString(textField.getText())));
textProperty().bind(Bindings
.when(emptyProperty())
.then((String)null)
.otherwise(itemProperty().asString()));
setGraphic(textField);
setContentDisplay(ContentDisplay.TEXT_ONLY);
}
@Override
protected void updateItem(Integer value, boolean empty) {
super.updateItem(value, empty);
if (isEditing()) {
textField.requestFocus();
textField.selectAll();
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
} else {
setContentDisplay(ContentDisplay.TEXT_ONLY);
}
}
@Override
public void startEdit() {
super.startEdit();
textFormatter.setValue(getItem());
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
textField.requestFocus();
textField.selectAll();
}
@Override
public void commitEdit(Integer newValue) {
super.commitEdit(newValue);
setContentDisplay(ContentDisplay.TEXT_ONLY);
}
@Override
public void cancelEdit() {
super.cancelEdit();
setContentDisplay(ContentDisplay.TEXT_ONLY);
}
}
That looks like a lot of code, but most of it is just setting up the text field and the formatter, then there's some pretty standard cell implementation that just makes sure the text field is displayed in editing mode and the plain text is displayed in non-editing mode.
Now you simply don't need to worry about checking the validity of the input, because the user cannot enter an invalid value.
Here's a simple usage example:
import java.util.Random;
import java.util.function.UnaryOperator;
import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.TextFormatter;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.util.StringConverter;
public class ValidatingTableColumn extends Application {
@Override
public void start(Stage primaryStage) {
TableView<Item> table = new TableView<>();
table.setEditable(true);
TableColumn<Item, String> nameColumn = new TableColumn<>("Item");
nameColumn.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
TableColumn<Item, Integer> valueColumn = new TableColumn<>("Value");
valueColumn.setCellValueFactory(cellData -> cellData.getValue().valueProperty().asObject());
table.getColumns().add(nameColumn);
table.getColumns().add(valueColumn);
valueColumn.setCellFactory(col -> new IntegerEditingCell(1, 31));
valueColumn.setOnEditCommit(e -> {
table.getItems().get(e.getTablePosition().getRow()).setValue(e.getNewValue());
});
Random rng = new Random();
for (int i = 1; i <= 20; i++) {
table.getItems().add(new Item("Item "+i, rng.nextInt(31)+1));
}
Button debug = new Button("Show all values");
debug.setOnAction(e -> table.getItems().forEach(item -> System.out.println(item.getName()+" ("+item.getValue()+")")));
BorderPane.setAlignment(debug, Pos.CENTER);
BorderPane.setMargin(debug, new Insets(5));
BorderPane root = new BorderPane(table, null, null, debug, null);
primaryStage.setScene(new Scene(root, 600, 600));
primaryStage.show();
}
public static class Item {
private final IntegerProperty value = new SimpleIntegerProperty() ;
private final StringProperty name = new SimpleStringProperty();
public Item(String name, int value) {
setName(name);
setValue(value);
}
public final IntegerProperty valueProperty() {
return this.value;
}
public final int getValue() {
return this.valueProperty().get();
}
public final void setValue(final int value) {
this.valueProperty().set(value);
}
public final StringProperty nameProperty() {
return this.name;
}
public final String getName() {
return this.nameProperty().get();
}
public final void setName(final String name) {
this.nameProperty().set(name);
}
}
public static void main(String[] args) {
launch(args);
}
}