Reputation: 316
Is an adaption of an extractor callback approach a good solution if I want to cause a TableView to refresh (without calling the refresh() method) when some arbitrary ObservableValue has changed but the underlying TableView data has not changed?
Here is an example of an implementation that uses the TableView refresh() method.
package com.example.rtv1;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.scene.Scene;
import javafx.scene.control.CheckBox;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import javafx.util.Callback;
public class App extends Application {
/**
* A data class exemplar.
*/
public static class Planet {
private final StringProperty name;
private final DoubleProperty mass;
private final DoubleProperty uncertainty;
public Planet(String name, double mass, double uncertainty) {
this.name = new SimpleStringProperty(name);
this.mass = new SimpleDoubleProperty(mass);
this.uncertainty = new SimpleDoubleProperty(uncertainty);
}
public StringProperty getNameProperty() {
return name;
}
public DoubleProperty getMassProperty() {
return mass;
}
public DoubleProperty getUncertaintyProperty() {
return uncertainty;
}
}
/**
* Provides a CellFactory and CellValueFactory that use an external
* (to the TableView) property to control how data is represented.
*/
public class DerivedCell<S extends Planet> {
private final ObservableValue<Boolean> watch;
private final NumberFormat valueFormatter;
private final NumberFormat percentFormatter;
private final DoubleProperty value;
public DerivedCell(ObservableValue<Boolean> watch) {
this.watch = watch;
valueFormatter = new DecimalFormat("0.000E0");
percentFormatter = new DecimalFormat("0.0000");
value = new SimpleDoubleProperty(Double.NaN);
// I could put a listener here to invalidate value (perhaps set
// it to NAN) when the boolean property is toggled, but my concern
// is that could fire many listeners. Is that less overhead than
// calling TableView.refresh()?
}
/**
* Provides a CellFactory that will change formatting based on
* an external property.
*/
public Callback<TableColumn<S, Double>, TableCell<S, Double>> forCell() {
return list -> new TableCell<S, Double>() {
@Override
public void updateItem(Double item, boolean empty
) {
super.updateItem(item, empty);
if (empty) {
setText(null);
} else {
setText(watch.getValue()
? percentFormatter.format(item)
: valueFormatter.format(item));
}
}
};
}
/**
* Provides a CellValueFactory that will change the representation of
* the data based on an external property.
*/
public Callback<TableColumn.CellDataFeatures<S, Double>, ObservableValue<Double>> getValue() {
return r -> {
var u = r.getValue().getUncertaintyProperty().get();
if (watch.getValue()) {
var v = r.getValue().getMassProperty().get();
value.setValue(100.0 * u / v);
} else {
value.setValue(u);
}
return value.asObject();
};
}
}
@Override
public void start(Stage stage) throws Exception {
var planets = FXCollections.observableArrayList(
// From: https://en.wikipedia.org/wiki/List_of_Solar_System_objects_by_size
new Planet("Mercury", 330.11E21, 0.02E21),
new Planet("Venus", 4867.5E21, 0.2E21),
new Planet("Earth", 5972.4E21, 0.3E21),
new Planet("Mars", 641.71E21, 0.03E21),
new Planet("Jupiter", 1898187E21, 88E21),
new Planet("Saturn", 568317E21, 13E21),
new Planet("Uranus", 86813E21, 4E21),
new Planet("Neptune", 102413E21, 5E21),
new Planet("Pluto", 13.03E21, 0.03E21)
);
var layout = new VBox();
var toggle = new CheckBox("Uncertainty as %");
toggle.selectedProperty().setValue(true);
var table = new TableView<Planet>();
var nameCol = new TableColumn<Planet, String>("Name");
var massCol = new TableColumn<Planet, Double>("Mass");
var uncCol = new TableColumn<Planet, Double>("Uncertainty");
var derived = new DerivedCell<Planet>(toggle.selectedProperty());
nameCol.setCellValueFactory(
r -> r.getValue().getNameProperty());
massCol.setCellValueFactory(
r -> r.getValue().getMassProperty().asObject());
uncCol.setCellValueFactory(derived.getValue());
uncCol.setCellFactory(derived.forCell());
table.getColumns().addAll(nameCol, massCol, uncCol);
table.setItems(planets);
// Call the maligned refresh() TableView method when the CheckBox
// changes state. It would be fantastic if there was a way to
// call the fireChanged() method in the observable list...
toggle.selectedProperty().addListener(
(var ov, var t, var t1) -> {
table.refresh();
});
layout.getChildren().addAll(toggle, table);
var scene = new Scene(layout);
stage.setTitle("Refreshable TableView");
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
}
When a similar question was asked previously, @kleopatra stated "no, never-ever use refresh."
I have a case where the underlying data is not changing, only how it is represented in the TableView. Specifically, I have two columns, a measurement and an uncertainty. The user is able to control the measurement units (e.g. kg) and whether the uncertainty should be displayed in the units of the measurement or as a percentage.
The cell factory and the cell value factory use properties that control how to represent the value and numerical formatting. When one of those properties change, a change listener is fired and the TableView is refreshed.
I've tried #2 and #5 and both obviously work. Options 1, 3, and 4 seem like kludge. I'm not a fan of #2 because it is wasteful of memory and I personally don't think the underlying data should be changed to reflect how it is represented to the user.
I looked at the fireChanged() method in ObservableList, however, it is protected and not public. Philosophically, it isn't the data that has changed, just the representation--so I am not a fan of this solution.
I think this approach can be adapted to solve this problem, but I have not implemented a solution yet (and is the rationale for this question).
I can create TableColumns for the different representations and change the visiblity to reflect the desired configuration, but that seems wasteful (but it might be better than refresh()--I'm not sure if refresh() causes all the cells to be created each time it is called).
I am thinking that doing something with the style might trigger a refresh, but I have not explored this in any detail.
Of the above solutions, I think the extractor callback is the way to proceed. I do think I am missing the obvious solution, so any insight is greatly appreciated.
Upvotes: 3
Views: 749
Reputation: 316
Thanks to the insight provided by kleopatra and a similar example that James_D had posted in the past, I made an implementation that works without calling the TableView.refresh() method. I provide the solution here in case anyone is interested--though all credit really goes to kleopatra and James_D.
/**
* Demonstrates a TableView that refreshes via the use of a binding for
* the cell value.
*
* This approach will fire for each affected cell, which might be an
* issue for large tables.
*/
package com.example.rtv3;
import java.text.DecimalFormat;
import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.scene.Scene;
import javafx.scene.control.CheckBox;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class App extends Application {
/**
* A data class exemplar.
*/
public static class Planet {
private final StringProperty name;
private final DoubleProperty mass;
private final DoubleProperty uncertainty;
public Planet(String name, double mass, double uncertainty) {
this.name = new SimpleStringProperty(name);
this.mass = new SimpleDoubleProperty(mass);
this.uncertainty = new SimpleDoubleProperty(uncertainty);
}
public StringProperty nameProperty() {
return name;
}
public DoubleProperty massProperty() {
return mass;
}
public DoubleProperty uncertaintyProperty() {
return uncertainty;
}
}
@Override
public void start(Stage stage) throws Exception {
var planets = FXCollections.observableArrayList(
// From: https://en.wikipedia.org/wiki/List_of_Solar_System_objects_by_size
new Planet("Mercury", 330.11E21, 0.02E21),
new Planet("Venus", 4867.5E21, 0.2E21),
new Planet("Earth", 5972.4E21, 0.3E21),
new Planet("Mars", 641.71E21, 0.03E21),
new Planet("Jupiter", 1898187E21, 88E21),
new Planet("Saturn", 568317E21, 13E21),
new Planet("Uranus", 86813E21, 4E21),
new Planet("Neptune", 102413E21, 5E21),
new Planet("Pluto", 13.03E21, 0.03E21)
);
var layout = new VBox();
var toggle = new CheckBox("Uncertainty as %");
toggle.selectedProperty().setValue(true);
var table = new TableView<Planet>();
var nameCol = new TableColumn<Planet, String>("Name");
var massCol = new TableColumn<Planet, Double>("Mass");
var uncCol = new TableColumn<Planet, Number>("Uncertainty");
nameCol.setCellValueFactory(
r -> r.getValue().nameProperty());
massCol.setCellValueFactory(
r -> r.getValue().massProperty().asObject());
// Implement a CellValueFactory that uses a DoubleBinding to
// dynamically change the representation of the data based
// on an external property.
// NOTE: Even though DoubleBinding has "Double" in the name,
// it implements ObservableValue<Number> not ObservableValue<Double>,
// thus the cell type for the column needs to be Number vice Double.
// NOTE: More complexity can be achieved by extending the
// DoubleBinding class instead of using the Binding.createDoubleBinding
// method. A listener will need to be added to the checkbox selected
// property that calls the invalidate() method on the binding.
uncCol.setCellValueFactory(
(TableColumn.CellDataFeatures<Planet, Number> r) -> {
// Instantiate a DoubleBinding that uses a Callable
// to change the representation of the uncertainity
// and use the selected property from the checkbox
// as a dependency (the dependency will cause invalidate
// the binding).
return Bindings.createDoubleBinding(
// The Callable that computes the value
() -> {
var u = r.getValue().uncertaintyProperty().get();
if (toggle.selectedProperty().getValue()) {
var v = r.getValue().massProperty().get();
return 100.0 * u / v;
} else {
return u;
}
},
// The dependency that we want to watch
toggle.selectedProperty());
});
// Setup the formatting of the uncertainty column to change
// based on the toggle
var valueFormatter = new DecimalFormat("0.000E0");
var percentFormatter = new DecimalFormat("0.0000");
uncCol.setCellFactory(
v -> {
return new TableCell<>() {
@Override
public void updateItem(Number item, boolean empty
) {
super.updateItem(item, empty);
if (empty) {
setText(null);
} else {
setText(toggle.selectedProperty().getValue()
? percentFormatter.format(item)
: valueFormatter.format(item));
}
}
};
});
table.getColumns().addAll(nameCol, massCol, uncCol);
table.setItems(planets);
layout.getChildren().addAll(toggle, table);
var scene = new Scene(layout);
stage.setTitle("Refreshable TableView");
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
}
Upvotes: 2