4

JavaFX 19 introduced three new methods to the ObservableValue interface:

JavaFX 20 further introduced the method

What is the intended use of these methods? Why were they introduced and how can we use them?

James_D
  • 201,275
  • 16
  • 291
  • 322

1 Answers1

3

History of the API

JavaFX, in its current form, was first released as version 2.0 (version 1.x was entirely different from the perspective of the developer, and used a scripting language). Version 2.0 was released in 2011. That placed it before the release of Java 8, which meant that many modern language features (such as lambda expressions and functional interfaces) were not available.

JavaFX makes extensive use of bindings and properties. These properties are objects wrapping a single variable and providing get and set methods for accessing and modifying the value. In addition to the accessor and modifier, they also provide a mechanism to register a listener which will be notified when the value changes. This makes writing applications using a MVC (or related, such as MVP or MVVM) architecture much more straightforward, as the model can be implemented with these properties making notification to the view very easy.

One requirement that arises from time to time is the need to observe or bind to a "property of a property" (some examples are shown below). Prior to lambda expressions, an API that supported this in a robust, typesafe manner would have been very unwieldy to use. The compromise API that JavaFX 2.0 used was the Bindings.select API. While this API works, it relies on reflection, has no compile-time checking, and does not handle null results elegantly.

After the release of Java 8 and JavaFX 8, a feature request was filed to replace Bindings.select(...) with a more robust solution. An implementation was written by Tomas Mikula and included in his third party libraries: initially in the (no longer maintained) Easy Bind and also in ReactFX (which also seems to be no longer maintained). As of JavaFX 19, some of this functionality was rolled into the library. Great credit should go to Tomas Mikula for pioneering this.

Simple Example: map()

Suppose we have a class with an immutable value. Here we'll implement this as a record:

record Person(String name){}

We can have a ListView<Person> which uses a custom list cell to display the person's name:

    ListView<Person> list = new ListView<>();

    list.setCellFactory(lv -> new ListCell<>() {
        @Override
        protected void updateItem(Person person, boolean empty) {
            super.updateItem(person, empty);
            if (empty || person == null) {
                setText("");
            } else {
                setText(person.name());
            }
        }
    });

Suppose (somewhat artificially) we want a label to display the name of the currently-selected person. A naïve attempt to do this might look like

Label selectedPerson = new Label();
selectedPerson.textProperty().bind(
    list.getSeletionModel().selectedItemProperty().asString()
);

The problem with this is that asString() will call String.valueOf() on the selected Person. This will give the literal string "null" if the selected person is null (i.e. there is no selection) and will give the result of Person.toString() otherwise. This will not give the desired display.

Instead, we can use map:

selectedItemProperty().map(Person::name)

results in an ObservableValue<String>. This observable value holds the value null if selectedItemProperty() is null, and holds the result of selectedItemProperty().getValue().name() otherwise. If selectedItemProperty() changes, the ObservableValue<String> is automatically updated, and notifies its listeners (including ones registered by any bindings to it).

So we can do

ObservableValue<String> name = list.getSelectionModel().selectedItemProperty().map(Person::name);
selectedPerson.textProperty().bind(name);

If nothing is selected, then name contains null, and the text is set to null, which is allowed by a Label. If a person is selected, the text is set to the person's name, exactly as required.

Here is the complete example:

import javafx.application.Application;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;

import java.io.IOException;
import java.util.List;

record Person(String name) {}
public class HelloApplication extends Application {
    @Override
    public void start(Stage stage) throws IOException {
        Label selectedPerson = new Label();
        ListView<Person> list = new ListView<>();

        list.setCellFactory(lv -> new ListCell<>() {
            @Override
            protected void updateItem(Person person, boolean empty) {
                super.updateItem(person, empty);
                if (empty || person == null) {
                    setText("");
                } else {
                    setText(person.name());
                }
            }
        });

        ObservableValue<String> selectedName = list.getSelectionModel().selectedItemProperty()
                .map(Person::name);
        selectedPerson.textProperty().bind(selectedName);

        BorderPane root = new BorderPane();
        root.setTop(new HBox(5, new Label("Selection:"), selectedPerson));
        root.setCenter(list);

        List.of("James", "Jennifer", "Jim", "Joanne", "John").stream()
                .map(Person::new)
                .forEach(list.getItems()::add);

        Scene scene = new Scene(root);
        stage.setScene(scene);
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}

flatMap

In the first example of using a "property of a property" the initial property was the selected person, and the property of the property was the person's name, which was immutable. Hence the only change we needed to care about was a change to the selected person.

In more complicated scenarios, the "property of the property" might itself be some kind of observable value.

Here is a very basic clock implementation:

import javafx.animation.AnimationTimer;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class Clock {

    private final Label label ;
    private final AnimationTimer timer ;
    private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("h:mm:ss a");
    private final ReadOnlyObjectWrapper<LocalTime> time = new ReadOnlyObjectWrapper<>(LocalTime.now());
    private final ObservableValue<String> formattedTime = time.map(formatter::format);

    public Clock() {
        this.label = new Label();
        label.textProperty().bind(formattedTime);
        timer = new AnimationTimer() {
            @Override
            public void handle(long now) {
                time.set(LocalTime.now());
            }
        };
        timer.start();    

    }

    public void start() {
        System.out.println("Starting");
        timer.start();
    }

    public void stop() {
        System.out.println("Stopping");
        timer.stop();
    }

    public Node getView() {
        return label;
    }

    public ReadOnlyObjectProperty<LocalTime> timeProperty() {
        return time.getReadOnlyProperty();
    }

    public ObservableValue<String> formattedTimeProperty() {
        return formattedTime;
    }
    public String getFormattedTime() {
        return formattedTime.getValue();
    }
}

It contains a label, which displays the time (as text). An AnimationTimer updates the label every time the JavaFX system renders a frame, setting its text to the current time.

Notice that the timer is always running. While this probably doesn't matter much in practice, it would be good to run the timer only when the label is displayed in a window. How do we find that out?

The label has a sceneProperty() which references the current scene in which the label is displayed (or null if it is not in a scene). In turn, the Scene has a windowProperty() which references the window containing the scene (or null, if the scene is not in a window). Finally, the window has a showingProperty(), which is a BooleanProperty representing whether or not the window is showing.

So to get the current window containing the label, we need logic of the form

private boolean isLabelShowing(Label label) {
    Scene scene = label.sceneProperty().get();
    if (scene == null) {
        return false ;
    }
    Window window = scene.windowProperty().get();
    if (window == null) {
        return false;
    }
    return window.showingProperty().get();
}

This needs to be recomputed if

  • The label is placed in a new scene (or removed from a scene)
  • The scene containing the label is placed in a new window (or removed from a window)
  • The window is shown or hidden

Note that using map() here doesn't give us what we want.

label.sceneProperty().map(Scene::windowProperty)

would return an ObservableValue<ReadOnlyObjectProperty<Window>>. This is not the correct type, and would not change if the scene was placed in a different window (the value contained by windowProperty() would change, but the ReadOnlyObjectProperty<Window> itself would still be the same object.

The flatMap() method addresses this problem:

label.sceneProperty()
     .flatMap(Scene::windowProperty)

is an ObservableValue<Window>. Its value is the window containing the scene containing the label. It will be null if label.getScene() is null, or if label.getScene().getWindow() is null. It will fire updates if either the label is placed in a different scene, or if the scene is placed in a different window.

Similarly,

label.sceneProperty()
     .flatMap(Scene::windowProperty)
     .flatMap(Window::showingProperty)

is an ObservableValue<Boolean>. It will contain null if label.sceneProperty().flatMap(Scene::windowProperty) contains null (i.e. if label.getScene() is null or if label.getScene().getWindow() is null), and will otherwise contain the (boxed) result of calling label.getScene().getWindow().isShowing().

So we can make the timer for our Clock automatically start when it is in a scene which is in a window which is showing, and automatically stop when that is no longer true:

import javafx.animation.AnimationTimer;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class Clock {

    private final Label label ;
    private final AnimationTimer timer ;
    private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("h:mm:ss a");
    private final ReadOnlyObjectWrapper<LocalTime> time = new ReadOnlyObjectWrapper<>(LocalTime.now());
    private final ObservableValue<String> formattedTime = time.map(formatter::format);

    public Clock() {
        this.label = new Label();
        label.textProperty().bind(formattedTime);
        timer = new AnimationTimer() {
            @Override
            public void handle(long now) {
                time.set(LocalTime.now());
            }
        };

       ObservableValue<Boolean> showing = label.sceneProperty()
            .flatMap(Scene::windowProperty)
            .flatMap(window -> window.showingProperty());
            showing.addListener((obs, wasShowing, isNowShowing) -> {
                if (isNowShowing != null || isNowShowing.booleanValue()) {
                    start();
                } else {
                    stop();
                }
            });
    }

    public void start() {
        System.out.println("Starting");
        timer.start();
    }

    public void stop() {
        System.out.println("Stopping");
        timer.stop();
    }

    public Node getView() {
        return label;
    }

    public ReadOnlyObjectProperty<LocalTime> timeProperty() {
        return time.getReadOnlyProperty();
    }

    public ObservableValue<String> formattedTimeProperty() {
        return formattedTime;
    }
    public String getFormattedTime() {
        return formattedTime.getValue();
    }
}

Here is a quick test app for this:

import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ToggleButton;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

import java.io.IOException;

public class ClockApp extends Application {
    @Override
    public void start(Stage stage) throws IOException {
        VBox root = new VBox(5);
        root.setPadding(new Insets(10));

        Clock clock = new Clock();


        VBox clockBox = new VBox(5, new Label("Current Time:"), clock.getView());
        ToggleButton showClock = new ToggleButton("Show Time");
        showClock.selectedProperty().addListener((obs, wasSelected, isNowSelected) -> {
            if (isNowSelected) {
                root.getChildren().add(clockBox);
            } else {
                root.getChildren().remove(clockBox);
            }
        });
        root.getChildren().add(showClock);

        Scene scene = new Scene(root, 400, 400);
        stage.setScene(scene);
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}

orElse()

In the previous example we had to specifically handle the possibility of a null value:

        ObservableValue<Boolean> showing = label.sceneProperty()
                .flatMap(Scene::windowProperty)
                .flatMap(window -> window.showingProperty());

        showing.addListener((obs, wasShowing, isNowShowing) -> {
            if (isNowShowing != null && isNowShowing.booleanValue()) {
                start();
            } else {
                stop();
            }
        });

The issue here is that showing is an ObservableValue wrapping a Boolean object reference (not a boolean primitive). That reference can take three possible values: Boolean.TRUE, Boolean.FALSE, or null. It will actually take on a null value if any of the steps in the "chain" of flatMap calls results in a reference which is null. This will occur, for example, if the label is not in a scene (so label.sceneProperty() contains null) or the scene is not in a window. In this case, calling isNowShowing.booleanValue() (or implicitly unboxing with code such as if (isNowShowing){...}) will throw a null pointer exception.

In this example, if showing contains null, it means the label is not in a window, and so we want to treat it equivalently to the way we treat Boolean.FALSE. The orElse(...) method creates an ObservableValue which contains the same value as the original ObservableValue if it is not null, and the provided "default" value if it is null. So we can clean up this section of code:

    ObservableValue<Boolean> showing = label.sceneProperty()
            .flatMap(Scene::windowProperty)
            .flatMap(window -> window.showingProperty())
            .orElse(Boolean.FALSE);

    showing.addListener((obs, wasShowing, isNowShowing) -> {
        if (isNowShowing) {
            start();
        } else {
            stop();
        }
    });

or (in an even more fluid style):

   label.sceneProperty()
        .flatMap(Scene::windowProperty)
        .flatMap(window -> window.showingProperty())
        .orElse(Boolean.FALSE)
        .addListener((obs, wasShowing, isNowShowing) -> {
            if (isNowShowing) {
                start();
            } else {
                stop();
            }
        });

when()

The when() method creates a new ObservableValue that only fires updates when the provided ObservableValue<Boolean> is true (or becomes true, if the underlying value has changed).

To demonstrate this, we can add an alarm to our clock, and a check box which activates the alarm. To keep things simple, our alarm will just go off every minute (i.e. when the seconds reach zero). We will fire a notification (via an Alert box) when the alarm is triggered, but only if the check box is selected.

We can use the previous techniques to create an ObservableValue that changes to true when the seconds reach zero (and will change back to false a second later):

clock.timeProperty()
     .map(time -> time.getSecond() == 0)

will contain true when the seconds portion of the time is zero, and false otherwise. We can use when() to create an observable that only fires notifications to its listeners if it changes when a checkbox is checked:

CheckBox alarm = new CheckBox("Activate Alarm");
Alert alert = new Alert(Alert.AlertType.INFORMATION);
clock.timeProperty()
     .map(time -> time.getSecond() == 0)
     .when(alarm.selectedProperty())
     .addListener((obs, oldValue, isNewMinute) -> {
         if (isNewMinute) {
             alert.setContentText("Time is "  + clock.getFormattedTime());
             alert.show()
         }
     });

Use in Virtualized Control Cells

The map() and flatMap() methods can provide some nice solutions for custom cell implementations in virtualized controls (such as ListView and TableView).

For example, the first example in this post had a ListView defined as

    ListView<Person> list = new ListView<>();

    list.setCellFactory(lv -> new ListCell<>() {
        @Override
        protected void updateItem(Person person, boolean empty) {
            super.updateItem(person, empty);
            if (empty || person == null) {
                setText("");
            } else {
                setText(person.name());
            }
        }
    });

The cell factory here returns a custom cell that sets the text to null if the item is null, or to the result of calling name() on the item otherwise. This can be accomplished essentially in a single line, and without subclassing ListCell, by binding the text property:

list.setCellFactory(lv -> {
    ListCell<Person> cell = new ListCell<>();
    cell.textProperty().bind(cell.itemProperty().map(Person::name));
    return cell;
});

For a more complex example, consider a stock monitoring application which displays a table of stocks and their prices, which may change at any time. We want to style the rows depending on whether the stock has recently increased or decreased in price, using the following style sheet:

.table-row-cell:recently-increased {
    -fx-control-inner-background: #c0c0ff;
}

.table-row-cell:recently-decreased {
    -fx-control-inner-background: #ffa0a0;
}

The Stock class is implemented as follows. It has two read/write properties (a name and a price), and two read-only properties indicating if the price has recently increased or decreased. When the price changes, one of those properties is set to true, and a PauseTransition is turns that off after a fixed period of time:

import javafx.animation.PauseTransition;
import javafx.beans.property.*;
import javafx.util.Duration;

public class Stock {

    private static final Duration RECENT_CHANGE_EXPIRATION = Duration.seconds(2);

    private final StringProperty name = new SimpleStringProperty();
    private final DoubleProperty price = new SimpleDoubleProperty();
    private final ReadOnlyBooleanWrapper recentlyIncreased = new ReadOnlyBooleanWrapper();
    private final ReadOnlyBooleanWrapper recentlyDecreased = new ReadOnlyBooleanWrapper();

    private final PauseTransition resetDelay = new PauseTransition(RECENT_CHANGE_EXPIRATION);

    public Stock(String name, double price) {
        setName(name);
        setPrice(price);
        resetDelay.setOnFinished(e -> {
            recentlyIncreased.set(false);
            recentlyDecreased.set(false);
        });
        priceProperty().addListener((obs, oldPrice, newPrice) -> {
            resetDelay.stop();
            recentlyIncreased.set(newPrice.doubleValue() > oldPrice.doubleValue());
            recentlyDecreased.set(newPrice.doubleValue() < oldPrice.doubleValue());
            resetDelay.playFromStart();
        });
    }

    public final String getName() {
        return nameProperty().get();
    }

    public StringProperty nameProperty() {
        return name;
    }

    public final void setName(String name) {
        this.nameProperty().set(name);
    }

    public final double getPrice() {
        return priceProperty().get();
    }

    public DoubleProperty priceProperty() {
        return price;
    }

    public final void setPrice(double price) {
        this.priceProperty().set(price);
    }

    public void adjustPrice(double percentage) {
        setPrice((1+percentage/100) * getPrice());
    }

    public boolean isRecentlyIncreased() {
        return recentlyIncreased.get();
    }

    public ReadOnlyBooleanWrapper recentlyIncreasedProperty() {
        return recentlyIncreased;
    }


    public boolean isRecentlyDecreased() {
        return recentlyDecreased.get();
    }

    public ReadOnlyBooleanWrapper recentlyDecreasedProperty() {
        return recentlyDecreased;
    }

}

To style the rows we set the recently-increased or recently-decreased CSS pseudoclasses depending on the state of the item in the row. A rowFactory with a couple of listeners mapping the properties to the CSS pseudoclasses is all that is needed:

    TableView<Stock> stockTable = new TableView<>();

    // ...

    stockTable.setRowFactory(tv -> {
        TableRow<Stock> row = new TableRow<>();
        row.itemProperty()
           .flatMap(Stock::recentlyIncreasedProperty)
           .orElse(false)
           .addListener(
               (obs, wasIncreased, isNowIncreased) -> row.pseudoClassStateChanged(INCREASED_PC, isNowIncreased)
           );
        row.itemProperty()
           .flatMap(Stock::recentlyDecreasedProperty)
           .orElse(false)
           .addListener(
               (obs, wasDecreased, isNowDecreased) -> row.pseudoClassStateChanged(DECREASED_PC, isNowDecreased)
           );
        return row;
    });

Here is the complete working example:

import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.scene.Scene;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;

import java.util.Random;

public class StockTrader extends Application {

    public static final int STOCK_NAME_LENGTH = 3;
    public static final PseudoClass INCREASED_PC = PseudoClass.getPseudoClass("recently-increased");
    public static final PseudoClass DECREASED_PC = PseudoClass.getPseudoClass("recently-decreased");
    private final Random rng = new Random();
    private final ObservableList<Stock> stocks = FXCollections.observableArrayList();

    private void startTrading() {
        Thread stockTrader = new Thread(() -> {
            // A stock trader is an object that sleeps a lot
            // and occasionally randomly changes the value of a stock
            while(true) {
                try  {
                    Thread.sleep(rng.nextInt(500));
                } catch (InterruptedException exc) {
                    // ignored: this thread cannot be interrupted
                }
                Stock randomStock = stocks.get(rng.nextInt(stocks.size()));
                double randomPercentage = rng.nextDouble() * 5 - 2.5;
                randomStock.adjustPrice(randomPercentage);
            }
        });
        stockTrader.setDaemon(true);
        stockTrader.start();
    }


    @Override
    public void start(Stage stage)  {
        TableView<Stock> stockTable = new TableView<>();
        TableColumn<Stock, String> stockCol = new TableColumn<>("Stock");
        stockCol.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
        stockTable.getColumns().add(stockCol);

        TableColumn<Stock, Number> priceCol = new TableColumn<>("Price");
        priceCol.setCellValueFactory(cellData -> cellData.getValue().priceProperty());
        priceCol.setCellFactory(tc -> new TableCell<>(){
            @Override
            protected void updateItem(Number price, boolean empty) {
                super.updateItem(price, empty);
                if (empty || price == null) {
                    setText(null);
                } else {
                    setText(String.format("$%.2f", price.doubleValue()));
                }
            }
        });
        stockTable.getColumns().add(priceCol);

        // Add 50 random stocks to the table:
        for (int i = 0 ; i < 50 ; i++) {
            stocks.add(new Stock(randomStockName(), rng.nextDouble() * 100));
        }
        stockTable.setItems(stocks);

        stockTable.setRowFactory(tv -> {
            TableRow<Stock> row = new TableRow<>();
            row.itemProperty()
               .flatMap(Stock::recentlyIncreasedProperty)
               .orElse(false)
               .addListener(
                   (obs, wasIncreased, isNowIncreased) -> row.pseudoClassStateChanged(INCREASED_PC, isNowIncreased)
               );
            row.itemProperty()
               .flatMap(Stock::recentlyDecreasedProperty)
               .orElse(false)
               .addListener(
                   (obs, wasDecreased, isNowDecreased) -> row.pseudoClassStateChanged(DECREASED_PC, isNowDecreased)
               );
            return row;
        });

        BorderPane root = new BorderPane(stockTable);
        Scene scene = new Scene(root);
        scene.getStylesheets().add(getClass().getResource("style.css").toExternalForm());
        stage.setScene(scene);
        stage.show();

        startTrading();
    }

    private String randomStockName() {
        StringBuilder name = new StringBuilder();
        for (int i = 0 ; i < STOCK_NAME_LENGTH; i++) {
            name.append((char) ('A' + rng.nextInt(26)));
        }
        return name.toString();
    }

    public static void main(String[] args) {
        launch();
    }
}
James_D
  • 201,275
  • 16
  • 291
  • 322