This is an MVP example showing
- how a buffered binding can be implemented for JavaFX 8.0.25
- how model properties can be bound to fields that are not yet present at binding time
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:
- Windowing-system independent model/view/binding:
- PersonModel
- PersonsBindingReadOnly, PersonsBinding
- PersonsView.PersonRow
- JavaFX dependent model/view/binding:
- JfxPersonItem
- JfxPersonsBinding
- JfxPersonsView
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); } }
Keine Kommentare:
Kommentar veröffentlichen