Putting all together
This page was contributed by Gail C. Anderson and Paul Anderson under the UPL and is from The Definitive Guide to Modern Java Clients with JavaFX 17 graciously contributed by Apress.Overview
It’s time to build a more interesting JavaFX application now, one that implements a master-detail view. As we show you this application, we’ll explain several JavaFX features that help you control the UI and keep your data and the application consistent.
First, we use Scene Builder to construct and configure the UI. Our example includes a Person
model class and an underlying ObservableList
that holds data. The program lets users make changes, but we don’t persist any data.
JavaFX has ObservableList
s that manage collections of data, and you can write listeners and bind expressions that respond to any data changes. The program uses a combination of event handlers and bind expressions to keep the application state consistent.
Master-Detail UI
For the UI, we use a JavaFX ListView control in the left window (the master view) and a Form on the right (the detail view). In Scene Builder, we select an AnchorPane
as the top-level component and the scene graph root.
A SplitPane
layout pane divides the application view into two parts, and each part has AnchorPane
as its main container.
The ListView
control lets you perform selections for a Person
object. Here, the first Person
is selected, and the details of that Person
appear in the form control on the right.
The form control has the following layout:
- The form contains a
GridPane
(two columns by four rows) that holdsTextField
s for the firstname and lastname fields ofPerson
. - A
TextArea
holds the notes field forPerson
. Labels in the first column mark each of these controls. - The bottom row of the
GridPane
consists of aButtonBar
that spans both columns and aligns on the right side by default. TheButtonBar
sizes all of its buttons to the width of the widest button label so the buttons have a uniform size. - The buttons let you perform New (create a
Person
and add thatPerson
to the list), Update (edit a selectedPerson
), and Delete (remove a selectedPerson
from the list). - Bind expressions query the state of the application and enable or disable the buttons.
The hierarchical view of our scene graph for the Person UI application looks like following:
The file structure of the application is listed below:
Person.java
contains the Person
model code, and SampleData.java
provides the data to initialize the application. FXMLController.java
is the JavaFX controller class, and PersonUI.java
holds the main application class. Under resources, the FXML file Scene.fxml
describes the UI.
The Model
The Person
class is the "model: we use for this application.
package org.modernclient.model;
import javafx.beans.Observable;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.util.Callback;
import java.util.Objects;
public class Person {
private final StringProperty firstname = new SimpleStringProperty(
this, "firstname", "");
private final StringProperty lastname = new SimpleStringProperty(
this, "lastname", "");
private final StringProperty notes = new SimpleStringProperty(
this, "notes", "sample notes");
public Person() {
}
public Person(String firstname, String lastname, String notes) {
this.firstname.set(firstname);
this.lastname.set(lastname);
this.notes.set(notes);
}
public String getFirstname() {
return firstname.get();
}
public StringProperty firstnameProperty() {
return firstname;
}
public void setFirstname(String firstname) {
this.firstname.set(firstname);
}
public String getLastname() {
return lastname.get();
}
public StringProperty lastnameProperty() {
return lastname;
}
public void setLastname(String lastname) {
this.lastname.set(lastname);
}
public String getNotes() {
return notes.get();
}
public StringProperty notesProperty() {
return notes;
}
public void setNotes(String notes) {
this.notes.set(notes);
}
@Override
public String toString() {
return firstname.get() + " " + lastname.get();
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return Objects.equals(firstname, person.firstname) &&
Objects.equals(lastname, person.lastname) &&
Objects.equals(notes, person.notes);
}
@Override
public int hashCode() {
return Objects.hash(firstname, lastname, notes);
}
}
Observable Lists
When working with JavaFX collections, you’ll typically use ObservableList
s that detect list changes with listeners. Furthermore, the JavaFX controls that display lists of data expect observable lists.
These controls automatically update the UI in response to list modifications. We’ll explain some of these intricacies as we walk you through our example program.
Implementing ListView Selection
A ListView
control displays items in an observable list and lets you select one or possibly multiple items. To display a selected Person
in the form fields in the right view, you use a change listener for the selectedItemProperty
.
This change listener is invoked each time the user either selects a different item from the ListView
or deselects the selected item. You can use the mouse for selecting, as well as the arrow keys, Home (for the first item), and End (for the last item).
On a Mac, use Fn + Left Arrow for Home and Fn + Right Arrow for End. For deselecting (either Command-click for a Mac or Control-click on Linux or Windows), the new value is null, and we clear all the form control fields.
Below you can observe the ListView
selection change listener.
listView.getSelectionModel().selectedItemProperty().addListener(
personChangeListener = (observable, oldValue, newValue) -> {
// newValue can be null if nothing is selected
selectedPerson = newValue;
modifiedProperty.set(false);
if (newValue != null) {
// Populate controls with selected Person
firstnameTextField.setText(selectedPerson.getFirstname());
lastnameTextField.setText(selectedPerson.getLastname());
notesTextArea.setText(selectedPerson.getNotes());
} else {
firstnameTextField.setText("");
lastnameTextField.setText("");
notesTextArea.setText("");
}
});
Boolean property modifiedProperty
tracks whether the user has changed any of the three text controls in the form. We reset this flag after each ListView
selection and use this property in a bind expression to control the Update button’s disable property.
Using Multiple Selection
By default, a ListView
control implements single selection so at most one item can be selected. ListView
also provides multiple selection, which you enable by configuring the selection mode, as follows:
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
With this setting, each time the user adds another item to the selection with CTRL-Shift or CTRL-Command, the selectedItemProperty
listener is invoked with the new selection.
The getSelectedItems()
method returns all of the currently selected items, and the newValue argument is the most recently selected value. For example, the following change listener collects multiple selected items and prints them:
listView.getSelectionModel().selectedItemProperty().addListener(
personChangeListener = (observable, oldValue, newValue) -> {
ObservableList<Person> selectedItems = listView.getSelectionModel().getSelectedItems();
// Do something with selectedItems
// System.out.println(selectedItems);
});
Our Person UI application uses single selection mode for the ListView.
ListView and Sort
Suppose you want to sort the list of names by last name and then first name. JavaFX has several ways to sort lists. Since we need to keep names sorted, we’ll wrap the underlying observableArrayList
in a SortedList
.
To keep the list sorted in ListView, we invoke ListView
’s setItems() method with the sorted list. A comparator specifies the ordering.
First, we compare each person’s last name for sorting and then the first names if necessary. To set the sorting, the setComparator()
method uses an anonymous class or, more succinctly, a lambda expression:
// Use a sorted list; sort by lastname; then by firstname
SortedList<Person> sortedList = new SortedList(personList);
sortedList.setComparator((p1, p2) -> {
int result = p1.getLastname().compareToIgnoreCase(p2.getLastname());
if (result == 0) {
result = p1.getFirstname().compareToIgnoreCase(p2.getFirstname());
}
return result;
});
listView.setItems(sortedList);
Note that the comparator arguments p1 and p2 are inferred as Person
types since SortedList
is generic.
Person UI Application Actions
Our Person UI application implements three actions: Delete (remove the selected Person
object from the underlying list), New (create a Person
object and add it to the underlying list), and Update (make changes to the selected Person
object and update the underlying list).
Let’s go over each action in detail, with an eye toward learning more about the JavaFX features that help you build this type of application.
Delete a Person
The controller class includes an action event handler for the Delete button. Here’s the FXML snippet that defines the Delete button:
<Button fx:id="removeButton" mnemonicParsing="false"
onAction="#removeButtonAction" text="Delete" />
The fx:id
attribute names the button so the JavaFX controller class can access it. The onAction
attribute corresponds to the ActionEvent
handler in the controller code. We’re not using keyboard shortcuts in this application, so we set attribute mnemonicParsing
to false.
NOTE
When mnemonic parsing is true, you can specify a keyboard shortcut to activate a labeled control, such as Alt-F to open a File menu, for example. You define the keyboard shortcut by preceding the targeted letter with an underbar character in the label.
You cannot update a SortedList
directly, but you can apply changes to its underlying list (ObservableList personList
). The SortedList
always keeps its elements sorted whenever you add or delete items.
Here is the event handler in the controller class:
@FXML
private void removeButtonAction(ActionEvent actionEvent) {
personList.remove(selectedPerson);
}
This handler removes the selected Person
object from the backing observable array list. The ListView
control’s selection change listener sets selectedPerson
.
Note that we don’t have to check selectedPerson
against null here. Why not? You’ll see that we disable the Delete button when the selectedItemProperty
is null.
This means the Delete button’s action event handler can never be invoked when the user deselects an element in the ListView control. Here’s the bind expression that controls the Delete button’s disable property:
removeButton.disableProperty().bind(
listView.getSelectionModel().selectedItemProperty().isNull());
This elegant statement makes the event handler more compact and subsequently less error prone. Both the button disableProperty
and the selection model selectedItemProperty
are JavaFX observables.
You can therefore use them in bind expressions. The property that invokes bind()
automatically updates when the bind()
arguments’ values change.
Add a Person
The New button adds a Person
to the list and subsequently updates the ListView
control. A new item is always sorted because the list re-sorts when elements are added to the wrapped list.
Here is the FXML that defines the New button. Similar to the Delete button, we define both the fx:id
and onAction
attributes:
<Button fx:id="createButton" mnemonicParsing="false" onAction="#createButtonAction" text="New" />
Under what circumstances should we disable the New button?
- When clicking New, no items in the
ListView
should be selected. Therefore, we disable the New button if theselectedItemProperty
is not null. Note that you can deselect the selected item with Command-click or Control-click. - We should not create a new
Person
if either the first or last name field is empty. So we disable the New button if either of these fields is empty. We do allow the Notes field to be empty, however. Here is the bind expression that implements these restrictions:
createButton.disableProperty().bind(
listView.getSelectionModel().selectedItemProperty().isNotNull()
.or(firstnameTextField.textProperty().isEmpty()
.or(lastnameTextField.textProperty().isEmpty())));
Now let’s show you the New button event handler:
@FXML
private void createButtonAction(ActionEvent actionEvent) {
Person person = new Person(firstnameTextField.getText(),
lastnameTextField.getText(), notesTextArea.getText());
personList.add(person);
// and select it
listView.getSelectionModel().select(person);
}
First, we create a new Person
object using the form’s text controls and add this Person
to the wrapped list (ObservableList personList
). To make the Person
’s data visible and editable right away, we select the newly added Person
.
Update a Person
An update of a Person
is not as straightforward as the other operations. Before we delve into the details of why, let’s first look at the Update button’s FXML code, which is similar to the other buttons:
<Button fx:id="updateButton" mnemonicParsing="false"
onAction="#updateButtonAction" text="Update" />
By default, a sorted list does not respond to individual array elements that change. For example, if Person
"Ethan Nieto" changes to "Ethan Abraham", the list will not re-sort the way it does when items are added or removed.
There’s two ways to fix this. First is to remove the item and add it back again with the new values.
The second way is to define an extractor for the underlying object. An extractor defines properties that should be observed when changes occur. Normally, changes to individual list elements are not observed. Observable objects returned by the extractor flag update changes in a list ChangeListener.
Thus, to make a ListView
control display a properly sorted list after changes to individual elements, you need to define an ObservableList
with an extractor.
The benefit of extractors is that you only include the properties that affect sorting. In our example, properties firstname and lastname affect the list’s order. These properties should go in the extractor.
An extractor is a static callback method in the model class. Here’s the extractor for our Person
class:
public class Person {
...
public static Callback<Person, Observable[]> extractor =
p-> new Observable[] {p.lastnameProperty(), p.firstnameProperty()};
}
Now the controller class can use this extractor to declare an ObservableList
called personList
, as follows:
private final ObservableList<Person> personList =
FXCollections.observableArrayList(Person.extractor);
With the extractor set up, the sorted list detects changes in both firstnameProperty
and lastnameProperty
and re-sorts as needed.
Next, we define when the Update button is enabled. In our application, the Update button should be disabled if no items are selected or if either the firstname
or lastname
text field becomes empty.
And finally, we disable Update if the user has not yet made changes to the form’s text components. We track these changes with a JavaFX Boolean property called modifiedProperty, created with the JavaFX Boolean property helper class, SimpleBooleanProperty
.
We initialize this Boolean to false in the JavaFX controller class, as follows:
private final BooleanProperty modifiedProperty = new SimpleBooleanProperty(false);
We reset this Boolean property to false in the ListView
selection change listener. The modifiedProperty
is set to true when a keystroke occurs in any of the three fields that can change: the first name, last name, and notes controls.
Here is the keystroke event handler, which is invoked when a key stroke is detected inside the focus for each of these three controls:
@FXML
private void handleKeyAction(KeyEvent keyEvent) {
modifiedProperty.set(true);
}
Of course, the FXML markup must configure attribute onKeyReleased
for all three text controls to invoke the keystroke event handler. Here is the FXML for the firstname TextField
, which links the handleKeyAction
event handler to a key release event for this control:
<TextField fx:id="firstnameTextField" onKeyReleased="#handleKeyAction"
prefWidth="248.0"
GridPane.columnIndex="1"
GridPane.hgrow="ALWAYS" />
And here is the bind expression for the Update button, which is disabled if the selectedItemProperty
is null, the modifiedProperty
is false, or the text controls are empty:
updateButton.disableProperty().bind(
listView.getSelectionModel().selectedItemProperty().isNull()
.or(modifiedProperty.not())
.or(firstnameTextField.textProperty().isEmpty()
.or(lastnameTextField.textProperty().isEmpty())));
Now let’s show you the Update button’s action event handler. This handler is invoked when the user clicks the Update button after selecting an item in the ListView
control and making at least one change to any of the text fields.
But there is one more housekeeping chore to do. Before starting the update of the selected item with the values from the form controls, we must remove the listener on the selectedItemProperty
.
Why? Recall that changes to the firstname
or lastname
properties will affect the list dynamically and possibly re-sort it. Furthermore, this may change ListView
’s idea of the currently selected item and invoke the ChangeListener
.
To prevent this, we remove the listener during the update and add the listener back when the update finishes. During the update, the selected item remains unchanged (even if the list re-sorts).
Thus, we clear the modifiedProperty
flag to ensure the Update button gets disabled:
@FXML
private void updateButtonAction(ActionEvent actionEvent) {
Person p = listView.getSelectionModel().getSelectedItem();
listView.getSelectionModel().selectedItemProperty()
.removeListener(personChangeListener);
p.setFirstname(firstnameTextField.getText());
p.setLastname(lastnameTextField.getText());
p.setNotes(notesTextArea.getText());
listView.getSelectionModel().selectedItemProperty()
.addListener(personChangeListener);
modifiedProperty.set(false);
}
Person UI with Records
One of the exciting new features in Java 16 is records. Records allow you to model classes that hold immutable data and describe state, often with a single line of code.
Let’s refactor our Person UI example to use Java records for the Person
model class. We do this for several reasons.
- Modern Java clients with JavaFX will continue to evolve as applications leverage new Java features. Afterall, JavaFX is implemented with Java APIs and can certainly take advantage of new features as they become available.
- Our UI example is a good candidate for records, since using a Person record instead of a class is a straightforward approach.
- We originally implemented
Person
with JavaFX properties, which are observable and mutable. But, in the context of our application, is this mutability necessary or even desirable? • Java records help make your code more readable, since often a single line defines the state of your model class.
Person Record
We declare a record with its name and its immutable components; each component has a name and type. These components are final instance fields in the generated class.
Java generates accessor methods for the fields, a constructor, and default implementations for methods equals()
, hashCode()
, and toString()
.
Here’s the new Person
class, which is much shorter than the non-record version:
public record Person (String firstname, String lastname, String notes) {
@Override
public String toString() {
return firstname + " " + lastname;
}
}
Note that we supply our own toString()
implementation to replace the auto-generated toString()
, since ListView
uses this to display each Person
object.
The generated accessor methods are firstname()
, lastname()
, and notes()
to match the elements declared in the record header. We update our application to use these names instead of the conventional getter forms.
This affects the selectedItemProperty
change listener and the sorted list comparator.
No changes are necessary to the createButtonAction
or removeButtonAction
event handlers. There is also no change to the code that creates our sample list of Person
objects (SampleData.java
).
Records do require changes to the updateButtonAction
event handler, however. Since a Person
object is now immutable, we cannot update its fields. Therefore, to update a Person
, we must create a new Person
object, remove the old one, and add the new one to the backing list.
The sorted list automatically updates with the new data. Here is the new updateButtonAction
event handler.
@FXML
private void updateButtonAction(ActionEvent actionEvent) {
Person person = new Person(firstnameTextField.getText(), lastnameTextField.getText(),
notesTextArea.getText());
personList.remove(listView.getSelectionModel().getSelectedItem());
personList.add(person);
listView.getSelectionModel().select(person);
modifiedProperty.set(false);
}
By removing and adding a Person
, the update process becomes simpler. The extractor to detect changes is no longer necessary, nor do we need to temporarily remove the selectedItemProperty
change listener during updates.
By restricting Person
to be an immutable container, we greatly simplify Person
and the readability of our program. However, JavaFX properties and binding are still ideal features to maintain the state of the UI.
Key Point Summary
This series has covered a lot of ground. Let’s review the key points:
- JavaFX is a modern UI toolkit that runs efficiently in desktop, mobile, and embedded environments.
- JavaFX uses a theater metaphor. The runtime system creates the primary stage and invokes the
start()
method of your application. - You create a hierarchical scene graph and install the root node in the scene.
- The JavaFX runtime system performs all UI updates and scene graph modifications on the JavaFX Application Thread. Any long-running work should be relegated to background tasks in separate threads to keep the UI responsive. JavaFX has a well-developed concurrency library that helps you keep UI code separate from background code.
- JavaFX supports both 2D and 3D graphics. The origin in 2D graphics is the upper-left corner of the scene.
- JavaFX includes a rich set of layout controls that let you arrange components in a scene. You can nest layout controls and specify resizing criteria.
- JavaFX defines a scene graph as a hierarchical collection of Nodes. Nodes are described by their properties.
- JavaFX properties are observable. You can attach listeners and use the rich bindings APIs to link properties to each other and detect changes.
- JavaFX lets you define high-level animations called transitions.
- The hierarchical nature of the scene graph means parent nodes can delegate rendering work to their children.
- JavaFX supports a wide range of events that let you react to user inputs and changes to a scene graph.
- While you can write JavaFX applications completely in Java, a better approach is to write visual descriptions in FXML, a markup language for specifying UI content. FXML helps separate visual code from model and controller code.
- Each FXML file typically describes a scene and configures a controller.
Last update: September 12, 2023