MVP Buffered Binding with JavaFX Table


Published: 2017-03-29
Updated: 2017-04-02
Web: https://fritzthecat-blog.blogspot.com/2017/03/mvp-buffered-binding-with-javafx-table.html


This is an MVP example showing

The concept of buffered binding is needed when the application features "Save" and "Cancel" buttons that would either commit or dismiss changes on an input-form. That means, not every field-change is written immediately to the according model property (like it is normally done in JavaFX), instead they get buffered until either saved or dismissed by the user.

Read how to drive JavaFX with JDK 1.8 in this Blog.

Package Structure


It is important is to separate the windowing-system-specific classes into their own viewimpl package. In this case JavaFX is the windowing-system, and no JavaFX imports must occur in the parent table package.

Demo Application

Here is how the application builds together the MVP and runs the JavaFX user interface.

package jfx.examples.mvp.table.viewimpl;

import java.time.LocalDate;
import javafx.application.Application;
import javafx.geometry.*;
import javafx.scene.*;
import javafx.scene.control.Button;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import jfx.examples.mvp.table.*;
import jfx.examples.mvp.table.PersonsModel.Person;

/**
* JavaFX application starter, featuring the MVP.
*/
public class Demo extends Application
{
public static void main(String[] args) {
launch(args);
}

@Override
public void start(Stage primaryStage) throws Exception {
// build MVP
final PersonsModel model = createModel();
final PersonsView<Node> view = new JfxPersonsView();
final PersonsPresenter presenter = new PersonsPresenter(view);
presenter.setModel(model);

// build UI
final VBox ground = new VBox(8);
ground.setAlignment(Pos.CENTER);
ground.setPadding(new Insets(8.0));
ground.getChildren().add(view.getAddableComponent());

// add "Reset" button to demonstrate how to set a new model
final Button reset = new Button("Reset");
reset.setOnAction(event -> presenter.setModel(createModel()));
ground.getChildren().add(reset);

// run application
final Scene scene = new Scene(ground);
primaryStage.setScene(scene);
primaryStage.setTitle("MVP Table Binding");
primaryStage.show();
}

/** Creates test data. */
private PersonsModel createModel() {
final PersonsModel model = new PersonsModel();
model.add(new Person("John Doe", LocalDate.of(1970, 1, 20)));
model.add(new Person(null, LocalDate.of(1975, 6, 15)));
model.add(new Person("Jack Hitroad", null));
model.add(new Person("Jill Outspace", LocalDate.of(1980, 12, 31)));

return model;
}
}

The Demo application is in the viewimpl package because it depends on the windowing-system. It builds together a demo PersonsModel, a JfxPersonsView and a PersonsPresenter. Then it builds a layout and adds a "Reset" button that shows how a new model can be set into the MVP.

JavaFX Model Buffering

My basic idea to implement buffering for JavaFX is to have two models, one buffers the editing-state, one holds the real data. Because JavaFX uses observable properties instead of "traditional" bean properties, the real data model is a traditional bean, while the buffering model just contains JavaFX observable properties.

package jfx.examples.mvp.table;

import java.time.LocalDate;
import java.util.*;

/**
* A list of persons with name and date-of-birth.
*/
public class PersonsModel
{
public static class Person
{
private String name;
private LocalDate dateOfBirth;

public Person() {
this(null, null);
}
public Person(String name, LocalDate dateOfBirth) {
this.name = name;
this.dateOfBirth = dateOfBirth;
}

public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}

public LocalDate getDateOfBirth() {
return dateOfBirth;
}
public void setDateOfBirth(LocalDate dateOfBirth) {
this.dateOfBirth = dateOfBirth;
}

@Override
public String toString() {
return "Person name="+name+", dateOfBirth="+dateOfBirth;
}
}

private final List<Person> persons = new ArrayList<>();

public void add(Person person) {
persons.add(person);
}

public void remove(Person person) {
persons.remove(person);
}

public List<Person> getPersons() {
return Collections.unmodifiableList(persons);
}
}

This model contains "traditional" bean properties, and no JavaFX observable properties.

Mind that getPersons() does not allow the caller to modify the original list or persons. This is called "data hiding", and it is an important feature of safe and well maintainable code. Read about the Law of Demeter to find out more.

package jfx.examples.mvp.table.viewimpl;

import java.time.LocalDate;
import javafx.beans.property.*;

/**
* Java-FX properties for data-binding.
*/
class JfxPersonItem
{
private final StringProperty name;
private final ObjectProperty<LocalDate> dateOfBirth;

JfxPersonItem() {
this(null, null);
}
JfxPersonItem(String name, LocalDate dateOfBirth) {
this.name = new SimpleStringProperty(name);
this.dateOfBirth = new SimpleObjectProperty<LocalDate>(dateOfBirth);
}

public StringProperty nameProperty() {
return name;
}

public ObjectProperty<LocalDate> dateOfBirthProperty() {
return dateOfBirth;
}
}

This model item contains no "traditional" bean properties but only the JavaFX observable properties. Thus it depends on the windowing-system and is in the viewimpl package. It is package-visible because just the JavaFX implementation will use this model item.

The list holding objects of type JfxPersonItem was not implemented here because it already exists in JavaFX: ObservableList. We'll see how this is wrapped in JfxPersonView constructor.

View with Table

The view is modelled in several abstraction layers.

package jfx.examples.mvp;

/**
* @param <W> the Component type of the windowing system, e.g.
* JComponent for Swing, Node for JavaFX, Component for Vaadin ....
*/
public interface View<W>
{
W getAddableComponent();
}

This very general View concept gets specialized to a view that can contain tables of a generic type to be defined by sub-interfaces:

package jfx.examples.mvp.table;

import java.util.List;
import jfx.examples.mvp.View;

/**
* A table represents a homogenous list of type R (row) objects.
*/
public interface ViewWithTable<W> extends View<W>
{
interface Table<R>
{
List<R> getRows();

List<R> getSelectedRows();

R addRow();

void removeRow(R row);

void clear();
}
}

From here we can specify the typed persons-view.

package jfx.examples.mvp.table;

import java.time.LocalDate;

/**
* Shows an editable list of persons.
*/
public interface PersonsView<W> extends ViewWithTable<W>
{
/** Person table definition. */
public interface PersonRow
{
String getName();
void setName(String name);

LocalDate getDateOfBirth();
void setDateOfBirth(LocalDate dateOfBirth);
}

Table<PersonRow> getPersonsTable();


/** Presenter events. */
interface Listener
{
void startEditing();

void saved();

void canceled();

void added();

void deleted();
}

void setListener(Listener viewListener);

/** Button enabling. */
void setEditable(boolean editable);
}

Remember that in MVP the view needs to expose the fields it uses to render model properties. Thus the nested PersonRow duplicates model property definitions. The getPersonsTable() method gives us all we need to edit a table of persons, without being bound to a specific windowing-system.

The nested Listener interface models all actions possible in this UI. We can edit the list, add and delete items, and save or cancel that. When cancelling, additions and deletions will be rolled back. The setEditable() method serves to switch the view from and to edit-state.

Presenter

This presenter-implementation delegates data-binding to a dedicated class. It just cares about user actions.

package jfx.examples.mvp.table;

/**
* Acts on view events, and binds model with view.
*/
public class PersonsPresenter implements PersonsView.Listener
{
private final PersonsView<?> view;
private final PersonsBinding binding;
private PersonsModel model;

public PersonsPresenter(PersonsView<?> view) {
assert view != null;

this.view = view;
view.setListener(this);
view.setEditable(false);

binding = new PersonsBinding(view);
}

public void setModel(PersonsModel model) {
binding.modelToView(this.model = model);
}

// actions

@Override
public void startEditing() {
view.setEditable(true);
}

@Override
public void saved() {
view.setEditable(false);
binding.viewToModel(model); // write to model
dumpModel();
}

@Override
public void canceled() {
view.setEditable(false);
setModel(model); // restore old model
dumpModel();
}

@Override
public void added() {
view.getPersonsTable().addRow(); // add empty row
}

@Override
public void deleted() {
for (PersonsView.PersonRow row : view.getPersonsTable().getSelectedRows())
view.getPersonsTable().removeRow(row); // remove all selected rows
}


private void dumpModel() {
for (PersonsModel.Person person : model.getPersons())
System.err.println(person);
}
}

The presenter holds references to the model and the view, and it holds a stateful binding that connects model and view whenever the presenter needs to bind data. All application logic is done here, and it's easy to understand.

Data Binding

The binding works with the view's table-abstractions.

package jfx.examples.mvp.table;

import jfx.examples.mvp.table.PersonsModel.Person;
import jfx.examples.mvp.table.PersonsView.PersonRow;
import jfx.examples.mvp.table.ViewWithTable.Table;

/**
* Part of PersonsPresenter, binds model to view.
*/
class PersonsBindingReadOnly
{
protected final PersonsView<?> view;

PersonsBindingReadOnly(PersonsView<?> view) {
this.view = view;
}

void modelToView(PersonsModel model) {
final Table<PersonsView.PersonRow> personsTable = view.getPersonsTable();
personsTable.clear();

for (PersonsModel.Person person : model.getPersons())
bindReadOnly(personsTable.addRow(), person);
}

protected void bindReadOnly(PersonRow row, Person person) {
row.setName(person.getName());
row.setDateOfBirth(person.getDateOfBirth());
}
}

For clarity I separated the read-only binding from the read/write binding. Read-only means just rendering the model properties in the view, not caring about edits. As you can see, it maps model properties to view fields in bindReadOnly(), to be overridden by the read/write binding.

package jfx.examples.mvp.table;

import java.util.*;
import jfx.examples.mvp.table.PersonsView.PersonRow;

/**
* Part of PersonsPresenter, binds model to view and view to model.
*/
class PersonsBinding extends PersonsBindingReadOnly
{
private final Map <PersonsView.PersonRow, PersonsModel.Person> binding = new Hashtable<>();

PersonsBinding(PersonsView<?> view) {
super(view);
}

@Override
void modelToView(PersonsModel model) {
binding.clear();
super.modelToView(model);
}

@Override
protected void bindReadOnly(PersonRow row, PersonsModel.Person person) {
binding.put(row, person);
super.bindReadOnly(row, person);
}

void viewToModel(PersonsModel model) {
final List<PersonRow> rows = view.getPersonsTable().getRows();

// handle deleted
final Iterator<PersonRow> iterator = binding.keySet().iterator();
while (iterator.hasNext()) {
final PersonsView.PersonRow row = iterator.next();

if (rows.contains(row) == false) { // was deleted
final PersonsModel.Person existingPerson = binding.get(row);
model.remove(existingPerson);
iterator.remove(); // removes from binding
}
}

// handle added and updated
for (PersonsView.PersonRow row : rows) {
final PersonsModel.Person existingPerson = binding.get(row);
final PersonsModel.Person person =
(existingPerson == null) ? new PersonsModel.Person() : existingPerson;
person.setName(row.getName());
person.setDateOfBirth(row.getDateOfBirth());

if (existingPerson == null) {
model.add(person);
binding.put(row, person);
}
}
}
}

When saving from view to model, the binding must know which rows were newly created, and which were deleted. It does this by using a binding-map with key = table-row and value = person-item. That map is maintained in overrides of the read-only binding, and in viewToModel(). After deleting model-items that were deleted in the view, the binding saves updated and new rows to the real model.

JavaFX View Implementation

The JfxPersonsView is the JavaFX implementation of the PersonsView interface.

package jfx.examples.mvp.table.viewimpl;

import java.time.LocalDate;
import javafx.collections.FXCollections;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import jfx.examples.mvp.table.PersonsView;

/**
* JavaFX PersonsView implementation, showing an editable table.
*/
public class JfxPersonsView implements PersonsView<Node>
{
private final Table<PersonRow> personsTable;
private final TableView<JfxPersonItem> jfxTable;
private final Button edit, cancel, save;
private final Button add, delete;
private final VBox addableView;

public JfxPersonsView() {
// build table
final TableColumn<JfxPersonItem,String> nameColumn = new TableColumn<>("Name");
nameColumn.setPrefWidth(120.0);

final TableColumn<JfxPersonItem,LocalDate> dateColumn = new TableColumn<>("Date of Birth");
dateColumn.setPrefWidth(140.0);

final JfxPersonsBinding binding = new JfxPersonsBinding();
binding.bindName(nameColumn);
binding.bindDateOfBirth(dateColumn);

jfxTable = new TableView<JfxPersonItem>();
jfxTable.getColumns().add(nameColumn);
jfxTable.getColumns().add(dateColumn);
jfxTable.setItems(FXCollections.observableArrayList());

this.personsTable = new JfxPersonsTableImpl(jfxTable);

// action buttons
edit = new Button("Edit");
save = new Button("Save");
cancel = new Button("Cancel");
add = new Button("Add");
delete = new Button("Delete");

final ToolBar toolbar = new ToolBar();
toolbar.getItems().add(edit);
toolbar.getItems().add(save);
toolbar.getItems().add(cancel);
toolbar.getItems().add(add);
toolbar.getItems().add(delete);

// put together view
final ScrollPane scrollPane = new ScrollPane(jfxTable);
addableView = new VBox(scrollPane);
addableView.getChildren().add(toolbar);
addableView.setAlignment(Pos.CENTER);
}

@Override
public Node getAddableComponent() {
return addableView;
}

@Override
public void setListener(Listener presenter) {
edit.setOnAction(event -> presenter.startEditing());
save.setOnAction(event -> presenter.saved());
cancel.setOnAction(event -> presenter.canceled());
add.setOnAction(event -> presenter.added());
delete.setOnAction(event -> presenter.deleted());
}

@Override
public Table<PersonRow> getPersonsTable() {
return personsTable;
}

@Override
public void setEditable(boolean editable) {
if (editable == false)
jfxTable.edit(-1, null); // close any open cell editor

jfxTable.setEditable(editable);

edit.setDisable(editable == true);
save.setDisable(editable == false);
cancel.setDisable(editable == false);
add.setDisable(editable == false);
delete.setDisable(editable == false);
}
}

In its constructor it first builds table-columns and binds them to JfxPersonItem properties (see JfxPersonsBinding below). Then it creates a table with these columns, and sets an ObservableList into it. We could call this "view-model". It is available through table.getItems().

To implement the ViewWithTable interface responsibilities it creates a JfxPersonsTableImpl (description see below).

Finally it builds a toolbar with all user actions needed to edit the list of persons.

This view supports only one listening presenter. In most cases this is sufficient.

package jfx.examples.mvp.table.viewimpl;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import javafx.beans.value.*;
import javafx.event.EventHandler;
import javafx.scene.control.TableColumn;
import jfx.examples.mvp.table.viewimpl.util.*;

/**
* Cell-rendering and -editing by factory.
*/
class JfxPersonsBinding
{
void bindName(final TableColumn<JfxPersonItem,String> nameColumn) {
nameColumn.setCellValueFactory(
cellDataFeatures -> cellDataFeatures.getValue().nameProperty());

nameColumn.setCellFactory(
cellDataFeatures -> new StringTableCell<JfxPersonItem>());
}

void bindDateOfBirth(final TableColumn<JfxPersonItem,LocalDate> dateOfBirthColumn) {
dateOfBirthColumn.setCellValueFactory(
cellDataFeatures -> cellDataFeatures.getValue().dateOfBirthProperty());

final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
dateOfBirthColumn.setCellFactory(
cellDataFeatures -> new LocalDateTableCell<JfxPersonItem>(formatter));
}
}

Binding fields that do not yet exist sounds like being impossible. But with the help of factories it is possible to defer the creation of the field until it is really needed. When the user double-clicks a table cell in edit-state, such a cell-editor would show up.

The cellDataFeatures.getValue() method returns a JfxPersonItem, because that is the item the table was built with. First you must tell JavaFX which is the property the column binds to, second you can tell it which type of TableCell you want to use. The StringTableCell and LocalDateTableCell are cell-editors that commit their content when they lose focus (by default JavaFX dismisses cell contents when not pressing ENTER before leaving the cell!).

package jfx.examples.mvp.table.viewimpl;

import java.time.LocalDate;
import java.util.*;
import javafx.scene.control.TableView;
import jfx.examples.mvp.table.ViewWithTable;
import jfx.examples.mvp.table.PersonsView.PersonRow;

/**
* The view-model: the table's outer representation
* that works together with PersonsBinding.
*/
class JfxPersonsTableImpl implements ViewWithTable.Table<PersonRow>
{
private static class PersonRowImpl implements PersonRow
{
final JfxPersonItem person;

PersonRowImpl(JfxPersonItem person) {
this.person = person;
}

@Override
public String getName() {
return person.nameProperty().get();
}
@Override
public void setName(String name) {
person.nameProperty().set(name);
}

@Override
public LocalDate getDateOfBirth() {
return person.dateOfBirthProperty().get();
}
@Override
public void setDateOfBirth(LocalDate dateOfBirth) {
person.dateOfBirthProperty().set(dateOfBirth);
}
}


private final TableView<JfxPersonItem> jfxTable;
private final List<PersonRow> rows = new ArrayList<>();

JfxPersonsTableImpl(TableView<JfxPersonItem> jfxTable) {
this.jfxTable = jfxTable;
}

@Override
public List<PersonRow> getRows() {
return rows;
}

@Override
public List<PersonRow> getSelectedRows() {
final List<PersonRow> selectedRows = new ArrayList<>();
for (Integer index : jfxTable.getSelectionModel().getSelectedIndices())
selectedRows.add(rows.get(index));
return selectedRows;
}

@Override
public PersonRow addRow() {
final JfxPersonItem person = new JfxPersonItem();
jfxTable.getItems().add(person);
final JfxPersonsTableImpl.PersonRowImpl row = new PersonRowImpl(person);
rows.add(row);
return row;
}

@Override
public void removeRow(PersonRow row) {
jfxTable.getItems().remove(((JfxPersonsTableImpl.PersonRowImpl) row).person);
rows.remove(row);
}

@Override
public void clear() {
jfxTable.getItems().clear();
rows.clear();
}
}

This is what the PersonsPresenter and PersonsBinding will interact with. Each of the ViewWithTable.Table<PersonRow> methods is implemented by delegating to the JavaFX TableView, and to the internal list of PersonRow rows. We could call that "table adapter".

The nested class PersonRowImpl duplicates the person's properties. It holds a JfxPersonItem that is used in the JavaFX TableView as "model item".

Which Classes Duplicate the Properties?

Model properties are duplicated in:

In other words, when adding a new person-property, you would have to edit all of these sources.

Resume

How did I come to this to this class-design? First I implemented it as JavaFX application. Then I refactored it until I achieved a clear and single responsibility for each class. Refactoring took much longer than implementing it. Most time I spent with solving JavaFX mysteries.

Could we have less sources that duplicate the model properties? Just when giving up clear responsibilities.


For completeness, here come the missing JavaFX TableCell implementations.

package jfx.examples.mvp.table.viewimpl.util;

import javafx.beans.binding.Bindings;
import javafx.scene.Node;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TableCell;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;

/**
* A JavaFX table cell that commits any open editor when it loses focus.
*/
public abstract class AutoCommitTableCell<S,T> extends TableCell<S,T>
{
private Node field;
private T defaultValue;
private boolean startEditing;
private boolean cancelling;


/** @return a newly created input field. */
protected abstract Node newInputField();

/** @return the current value of the input field. */
protected abstract T getInputValue();

/** Sets given value to the input field. */
protected abstract void setInputValue(T value);

/** @return the default in case item is null, must be never null, else cell will not be editable. */
protected abstract T getDefaultValue();

/** @return converts the given value to a string, being the cell-renderer representation. */
protected abstract String inputValueToText(T value);


@Override
public void startEdit() {
startEditing = true;
try {
super.startEdit(); // updateItem() will be called

setInputValue(getItem());
}
finally {
startEditing = false;
}
}

/** Redirects to commitEdit(). Leaving the cell should commit, just ESCAPE should cancel. */
@Override
public void cancelEdit() {
// avoid JavaFX recursion
if (cancelling)
return;

cancelling = true;
try {
// avoid JavaFX NullPointerException when calling commitEdit()
getTableView().edit(getIndex(), getTableColumn());

commitEdit(getInputValue());
}
finally {
cancelling = false;
}
}

private void cancelOnEscape() {
if (defaultValue != null) { // canceling default means writing null
setItem(defaultValue = null);
setText(null);
setInputValue(null);
}
super.cancelEdit();
}

@Override
protected void updateItem(T newValue, boolean empty) {
if (startEditing && newValue == null)
newValue = (defaultValue = getDefaultValue());

super.updateItem(newValue, empty);

if (empty || newValue == null) {
setText(null);
setGraphic(null);
}
else {
setText(inputValueToText(newValue));
setGraphic(startEditing || isEditing() ? getInputField() : null);
}
}

protected final Node getInputField() {
if (field == null) {
field = newInputField();

// a cell-editor won't be committed or canceled automatically by JFX
field.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
if (event.getCode() == KeyCode.ENTER || event.getCode() == KeyCode.TAB)
commitEdit(getInputValue());
else if (event.getCode() == KeyCode.ESCAPE)
cancelOnEscape();
});

contentDisplayProperty().bind(
Bindings.when(editingProperty())
.then(ContentDisplay.GRAPHIC_ONLY)
.otherwise(ContentDisplay.TEXT_ONLY)
);
}
return field;
}
}


package jfx.examples.mvp.table.viewimpl.util;

import javafx.scene.Node;
import javafx.scene.control.TextField;

public class StringTableCell<T> extends AutoCommitTableCell<T,String>
{
@Override
protected String getInputValue() {
return ((TextField) getInputField()).getText();
}

@Override
protected void setInputValue(String value) {
((TextField) getInputField()).setText(value);
}

@Override
protected String getDefaultValue() {
return "";
}

@Override
protected Node newInputField() {
return new TextField();
}

@Override
protected String inputValueToText(String newValue) {
return newValue;
}
}


package jfx.examples.mvp.table.viewimpl.util;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import javafx.scene.Node;
import javafx.scene.control.DatePicker;
import javafx.util.StringConverter;

public class LocalDateTableCell<T> extends AutoCommitTableCell<T,LocalDate>
{
private final StringConverter<LocalDate> converter;

public LocalDateTableCell(final DateTimeFormatter formatter) {
assert formatter != null;

converter = new StringConverter<LocalDate>() {
@Override
public LocalDate fromString(String text) {
return LocalDate.parse(text, formatter);
}
@Override
public String toString(LocalDate date) {
return formatter.format(date);
}
};
}

@Override
protected LocalDate getInputValue() {
return ((DatePicker) getInputField()).getValue();
}

@Override
protected void setInputValue(LocalDate value) {
((DatePicker) getInputField()).setValue(value);
}

@Override
protected LocalDate getDefaultValue() {
return LocalDate.now();
}

@Override
protected Node newInputField() {
final DatePicker datePicker = new DatePicker();
datePicker.setConverter(converter);
return datePicker;
}

@Override
protected String inputValueToText(LocalDate newValue) {
return converter.toString(newValue);
}

@Override
public void commitEdit(LocalDate newValue) {
// rely on text field content, not on popup
final String text = ((DatePicker) getInputField()).getEditor().getText().trim();
final LocalDate input = (text.length() > 0) ? converter.fromString(text) : null;
setInputValue(input); // put the value into the popup in case it was different

super.commitEdit(input);
}
}




ɔ⃝ Fritz Ritzberger, 2017-03-29