Derived Observable Values
In a user interface, we often want to display not only the actual data but also values calculated from that data, such as sums, averages, combinations, or similar derived values.
For example, a person's full name can be calculated by combining the first name and last name:
String fullName = person.getFirstName() + person.getLastName();
However, a value calculated this way does not automatically update in the user interface if the first name or last name changes.
Example
Consider the following application for entering player information:
// Imports hidden
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.util.converter.NumberStringConverter;
import java.net.URL;
import java.util.ResourceBundle;
public class MainController implements Initializable {
@FXML
private TableColumn<Player, String> nameColumn;
@FXML
private TableColumn<Player, Number> birthYearColumn;
@FXML
private TableColumn<Player, Number> ageColumn;
@FXML
private TableView<Player> playersTable;
@FXML
private Label playerCountLabel;
@FXML
private Button addPlayerButton;
private ObservableList<Player> players = FXCollections.observableArrayList();
@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
nameColumn.setCellFactory(TextFieldTableCell.forTableColumn());
nameColumn.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
birthYearColumn.setCellFactory(TextFieldTableCell.forTableColumn(new NumberStringConverter("####")));
birthYearColumn.setCellValueFactory(cellData -> cellData.getValue().birthYearProperty());
// This does not work!
//
// setCellValueFactory requires an ObservableValue,
// but age is an int value.
//
// ageColumn.setCellValueFactory(cellData -> LocalDate.now().getYear() - cellData.getValue().getBirthYear()
// );
addPlayerButton.setOnAction(event -> {
Player newPlayer = new Player();
newPlayer.setName("New Player");
newPlayer.setBirthYear(2000);
players.add(newPlayer);
});
playersTable.setItems(players);
}
}
In this example we can see two problems:
- Adding a new player does not update the player count.
- Editing a player's birth year does not update the player's age.
Converting an Observable Value into Another Value
All ObservableValue types, such as StringProperty, IntegerProperty, FloatProperty, and so on, provide a helper method called map() (See
JavaDoc)
that allows calculations to be performed on the value.
For example, a player's age can be calculated from the birth year as follows:
ObservableValue<Number> age = player.birthYearProperty().map(year -> LocalDate.now().getYear() - year.intValue());
The value contained in the age variable is then recalculated using
LocalDate.now().getYear() - year.intValue()
whenever the player's birth year changes.
Because ObservableValue is itself observable, it can be used directly in a setCellValueFactory call:
public void initialize(URL url, ResourceBundle resourceBundle) {
nameColumn.setCellFactory(TextFieldTableCell.forTableColumn());
nameColumn.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
birthYearColumn.setCellFactory(TextFieldTableCell.forTableColumn(new NumberStringConverter("####")));
birthYearColumn.setCellValueFactory(cellData -> cellData.getValue().birthYearProperty());
// HIGHLIGHT_GREEN_BEGIN
ageColumn.setCellValueFactory(cellData ->
cellData.getValue().birthYearProperty().map(
birthYear -> LocalDate.now().getYear() - birthYear.intValue()));
// HIGHLIGHT_GREEN_END
addPlayerButton.setOnAction(event -> {
Player newPlayer = new Player();
newPlayer.setName("New Player");
newPlayer.setBirthYear(2000);
players.add(newPlayer);
});
playersTable.setItems(players);
}
Converting a Function into an Observable Value
The number of players can be obtained by calling: players.size().
However, the size() method does not return an observable value, and an ObservableList does not provide the map() method discussed above.
Nevertheless, any function can be converted into an observable value using the Bindings
(See JavaDoc)
class and its createXBinding helper methods.
Here, X refers to the type of observable value, for example Integer, Long, String, or Object.
Since size() returns an integer, we use Bindings.createIntegerBinding()
(See
JavaDoc)
:
IntegerBinding playerCount = Bindings.createIntegerBinding(() -> players.size(), players);
Bindings.createIntegerBinding() takes at least two parameters:
a lambda expression used to calculate the observable value and
one or more Observable values whose changes trigger recalculation.
In this example, the first parameter specifies that playerCount is always calculated using:
players.size().
The second parameter, players, specifies that the value must be updated whenever the contents of the players list change.
The Bindings.createXBinding method returns a binding object, which can be used like any other observable value.
In this case, we can bind the text of playerCountLabel to it:
public void initialize(URL url, ResourceBundle resourceBundle) {
nameColumn.setCellFactory(TextFieldTableCell.forTableColumn());
nameColumn.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
birthYearColumn.setCellFactory(TextFieldTableCell.forTableColumn(new NumberStringConverter("####")));
birthYearColumn.setCellValueFactory(cellData -> cellData.getValue().birthYearProperty());
ageColumn.setCellValueFactory(cellData -> cellData.getValue().birthYearProperty().map(birthYear -> LocalDate.now().getYear() - birthYear.intValue()));
addPlayerButton.setOnAction(event -> {
Player newPlayer = new Player();
newPlayer.setName("New Player");
newPlayer.setBirthYear(2000);
players.add(newPlayer);
});
// HIGHLIGHT_GREEN_BEGIN
IntegerBinding playerCount = Bindings.createIntegerBinding(() -> players.size(), players);
playerCountLabel.textProperty().bind(playerCount.asString());
// HIGHLIGHT_GREEN_END
playersTable.setItems(players);
}
Using the bind() method of a Property, we can bind one value to another observable value.
In this case, the text of playerCountLabel is bound to the number of players.
As a result, whenever the players list changes:
playerCountdetects the change and recalculates its value usingplayers.size().playerCount.asString()detects the change inplayerCountand updates its own value by callingplayerCount.toString().playerCountLabel.textProperty()detects the change inplayerCount.asString()and updates its displayed text accordingly.
After these changes, both the player count and each player's age update automatically.