I would like to add such kind of bar chart to my application using JavaFX:
Essentially: A (potentially large, i.e. up to 50 entries) table. For each row there are several columns with information. One piece of information are percentages about win/draw/loss ratio, i.e. say three numbers 10%, 50%, 40%. I would like to display these three percentages graphically as a vertical bar, with three different colors. So that a user can get a visual impression of each of these percentages.
I have not found a simple or straight-forward method of doing that with JavaFX. There seems at least no control for that right now. I also could not find a control from ControlsFX that seemd suitable. What I am curently doing is having the information itself, and three columns for the percentages like this:
Option Win Draw Loss
============================
option1 10% 50% 40%
option2 20% 70% 10%
option3 ...
But that's just not so nice. How can I achieve the above mentioned graphical kind of display?
(added an image for better understanding; it's from the lichess.org where they do exactly that in html)
This uses a combination of trashgod's and James_D's ideas:
a TableView with a custom cell factory and graphic,
The graphic could just be three appropriately-styled labels in a single-row grid pane with column constraints set.
Other than that, it is a standard table view implementation.
Numbers in my example don't always add up to 100% due to rounding, so you may wish to do something about that, if so, I leave that up to you.
import javafx.application.Application;
import javafx.beans.property.*;
import javafx.collections.*;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import java.text.NumberFormat;
import java.util.Arrays;
public class ChartTableApp extends Application {
private final ObservableList<Outcomes> outcomes = FXCollections.observableList(
Arrays.asList(
new Outcomes("Qxd5", 5722, 5722, 3646),
new Outcomes("Kf6", 2727, 2262, 1597),
new Outcomes("c6", 11, 1, 5),
new Outcomes("e6", 0, 1, 1)
)
);
public static void main(String[] args) {
launch(args);
}
#Override
public void start(Stage stage) {
stage.setScene(new Scene(createOutcomesTableView()));
stage.show();
}
private TableView<Outcomes> createOutcomesTableView() {
final TableView<Outcomes> outcomesTable = new TableView<>(outcomes);
TableColumn<Outcomes, String> moveCol = new TableColumn<>("Move");
moveCol.setCellValueFactory(o ->
new SimpleStringProperty(o.getValue().move())
);
TableColumn<Outcomes, Integer> totalCol = new TableColumn<>("Total");
totalCol.setCellValueFactory(o ->
new SimpleIntegerProperty(o.getValue().total()).asObject()
);
totalCol.setCellFactory(p ->
new IntegerCell()
);
totalCol.setStyle("-fx-alignment: BASELINE_RIGHT;");
TableColumn<Outcomes, Outcomes> outcomesCol = new TableColumn<>("Outcomes");
outcomesCol.setCellValueFactory(o ->
new SimpleObjectProperty<>(o.getValue())
);
outcomesCol.setCellFactory(p ->
new OutcomesCell()
);
//noinspection unchecked
outcomesTable.getColumns().addAll(
moveCol,
totalCol,
outcomesCol
);
outcomesTable.setPrefSize(450, 150);
return outcomesTable;
}
public record Outcomes(String move, int wins, int draws, int losses) {
public int total() { return wins + draws + losses; }
public double winPercent() { return percent(wins); }
public double drawPercent() { return percent(draws); }
public double lossPercent() { return percent(losses); }
private double percent(int value) { return value * 100.0 / total(); }
}
private static class OutcomesCell extends TableCell<Outcomes, Outcomes> {
OutcomesBar bar = new OutcomesBar();
#Override
protected void updateItem(Outcomes item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
} else {
bar.setOutcomes(item);
setGraphic(bar);
}
}
}
private static class OutcomesBar extends GridPane {
private final Label winsLabel = new Label();
private final Label drawsLabel = new Label();
private final Label lossesLabel = new Label();
private final ColumnConstraints winsColConstraints = new ColumnConstraints();
private final ColumnConstraints drawsColConstraints = new ColumnConstraints();
private final ColumnConstraints lossesColConstraints = new ColumnConstraints();
public OutcomesBar() {
winsLabel.setStyle("-fx-background-color : lightgray");
drawsLabel.setStyle("-fx-background-color : darkgray");
lossesLabel.setStyle("-fx-background-color : gray");
winsLabel.setAlignment(Pos.CENTER);
drawsLabel.setAlignment(Pos.CENTER);
lossesLabel.setAlignment(Pos.CENTER);
winsLabel.setMaxWidth(Double.MAX_VALUE);
drawsLabel.setMaxWidth(Double.MAX_VALUE);
lossesLabel.setMaxWidth(Double.MAX_VALUE);
addRow(0, winsLabel, drawsLabel, lossesLabel);
getColumnConstraints().addAll(
winsColConstraints,
drawsColConstraints,
lossesColConstraints
);
}
public void setOutcomes(Outcomes outcomes) {
winsLabel.setText((int) outcomes.winPercent() + "%");
drawsLabel.setText((int) outcomes.drawPercent() + "%");
lossesLabel.setText((int) outcomes.lossPercent() + "%");
winsColConstraints.setPercentWidth(outcomes.winPercent());
drawsColConstraints.setPercentWidth(outcomes.drawPercent());
lossesColConstraints.setPercentWidth(outcomes.lossPercent());
}
}
private static class IntegerCell extends TableCell<Outcomes, Integer> {
#Override
protected void updateItem(Integer item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
} else {
setText(
NumberFormat.getNumberInstance().format(
item
)
);
}
}
}
}
Related
I have a table in JavaFX. I want to control the show/hide of the thousand commas. Currently, I can control the color by column1.setStyle("-fx-text-fill: green"), but how can I show the thousand commas (and then hide them back in some later stage), probably via a similar approach?
public static void main(String[] args) {
launch(args);
}
#Override
public void start(Stage primaryStage) {
TableView tableView = new TableView();
TableColumn<Integer, Person> column1 = new TableColumn<>("Salary");
column1.setCellValueFactory(new PropertyValueFactory("salary"));
tableView.getColumns().add(column1);
tableView.getItems().add(new Person(27000));
tableView.getItems().add(new Person(48000));
column1.setStyle("-fx-text-fill: green");
VBox vbox = new VBox(tableView);
Scene scene = new Scene(vbox);
primaryStage.setScene(scene);
primaryStage.show();
}
}
Person:
import javafx.beans.property.SimpleIntegerProperty;
public class Person {
private SimpleIntegerProperty salaryProperty;
public Person() {
}
public Person(int salary) {
this.salaryProperty = new SimpleIntegerProperty(salary);
}
public int getSalary() {
return salaryProperty.get();
}
public void setSalary(int salary) {
this.salaryProperty = new SimpleIntegerProperty(salary);
}
}
I think that it is not possible to do with css. You need to use some kind of NumberFormat like in this example:
App:
package formatter;
import javafx.application.Application;
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.control.cell.PropertyValueFactory;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import java.text.NumberFormat;
import java.util.Locale;
public class App extends Application {
#Override
public void start(Stage primaryStage) {
// Create the table view and its column (like you already did):
TableView<Person> tableView = new TableView<>();
TableColumn<Person, Integer> salaryColumn = new TableColumn<>("Salary");
salaryColumn.setCellValueFactory(new PropertyValueFactory<>("salary"));
tableView.getColumns().add(salaryColumn);
// Using a check box in this example to change between formats::
CheckBox useGroupingCheckBox = new CheckBox("use grouping");
// Create a currency formatter with a locale which is important for internationalization:
NumberFormat formatter = NumberFormat.getCurrencyInstance(Locale.CANADA);
formatter.setMaximumFractionDigits(0);
// Create a custom cell:
salaryColumn.setCellFactory(column -> new TableCell<>() {
#Override
protected void updateItem(Integer item, boolean empty) {
super.updateItem(item, empty);
if (item == null || empty) {
setText("");
} else {
// Use grouping when check box selected, don't when not selected:
formatter.setGroupingUsed(useGroupingCheckBox.isSelected());
setText(formatter.format(item));
}
}
});
// Refresh table on check box action:
useGroupingCheckBox.setOnAction(event -> tableView.refresh());
// Add some test data:
tableView.getItems().add(new Person(27000));
tableView.getItems().add(new Person(48000));
// Prepare scene and stage:
VBox vbox = new VBox(useGroupingCheckBox, tableView);
Scene scene = new Scene(vbox);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch();
}
}
Person class:
package formatter;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
public class Person {
private IntegerProperty salary;
public Person() {
salary = new SimpleIntegerProperty();
}
public Person(int salary) {
this();
this.salary.set(salary);
}
public Integer getSalary() {
return salary.get();
}
public IntegerProperty salaryProperty() {
return salary;
}
public void setSalary(int salary) {
this.salary.set(salary);
}
}
Preview:
The accepted answer is just fine though a bit smelly because it requires to manually refresh the table when changing the format's property.
An alternative is
to wrap the format - which is not observable - into something that is observable
implement a custom cell which listens to the change/s and updates itself as needed.
An example of how to implement the first (for the grouping state) is FormattingHandler in the code below. Note that
the wrapping property itself implements the update of the contained format
the NumberFormat is completely hidden inside the handler: that's doing the best to not allow changes of its properties under the feet of the handler (obviously it's not entirely fool-proof because outside code can still keep a reference to the format and change it at will - it's a similar isolation level as f.i. the backing list in core ObservableList implementations)
An example of how to implement the second is FormattingCell. It takes a not-null FormattingHandler, registers a listener to the grouping property and updates itself on invalidation notification. Note that this might introduce a memory leak (even though the listener is weak!) if the observable doesn't change at all (it's a known issue in the design of weak listeners that will not be changed, unfortunately) - the only way out would be to move the listening into a custom cell skin and remove the listener in the skin's dispose.
The code (boilderplate stolen from Anko's answer :)
public class DynamicFormattingCellBinding extends Application {
/**
* Observable wrapper around NumberFormat.
*/
public static class FormattingHandler {
/*
* Property controlling the grouping of the format.
*/
private BooleanProperty useGrouping = new SimpleBooleanProperty(this, "useGrouping", false) {
#Override
protected void invalidated() {
super.invalidated();
groupingInvalidated();
}
};
private NumberFormat formatter;
public FormattingHandler(NumberFormat formatter) {
this.formatter = formatter;
setGrouping(formatter.isGroupingUsed());
}
public BooleanProperty groupingProperty() {
return useGrouping;
}
public boolean isGrouping() {
return groupingProperty().get();
}
public void setGrouping(boolean grouping) {
groupingProperty().set(grouping);
}
public String format(Number number) {
return formatter.format(number);
}
private void groupingInvalidated() {
formatter.setGroupingUsed(isGrouping());
}
}
public static class FormattingCell<T, S extends Number> extends TableCell<T, S> {
private FormattingHandler formattingHandler;
private InvalidationListener groupingListener = o -> updateItem(getItem(), isEmpty());
public FormattingCell(FormattingHandler formattingHandler) {
this.formattingHandler = Objects.requireNonNull(formattingHandler, "formatter must not be null");
// Beware: a weak listener isn't entirely safe
// will introduce memory leaks if the observable doesn't change!
formattingHandler.groupingProperty().addListener(new WeakInvalidationListener(groupingListener));
}
#Override
protected void updateItem(S item, boolean empty) {
super.updateItem(item, empty);
if (item == null || empty) {
setText("");
} else {
setText(formattingHandler.format(item));
}
}
}
#Override
public void start(Stage primaryStage) {
TableView<Person> tableView = new TableView<>();
TableColumn<Person, Integer> salaryColumn = new TableColumn<>("Salary");
salaryColumn.setCellValueFactory(cc -> cc.getValue().salaryProperty().asObject());
tableView.getColumns().add(salaryColumn);
// instantiate the formatting support and register bidi binding with a view element
FormattingHandler formatter = new FormattingHandler(NumberFormat.getCurrencyInstance());
CheckBox useGroupingCheckBox = new CheckBox("use grouping");
useGroupingCheckBox.selectedProperty().bindBidirectional(formatter.groupingProperty());
// install custom formatting cell
salaryColumn.setCellFactory(column -> new FormattingCell<>(formatter));
// Add some test data:
tableView.getItems().add(new Person(27000));
tableView.getItems().add(new Person(48000));
// Prepare scene and stage:
VBox vbox = new VBox(useGroupingCheckBox, tableView);
Scene scene = new Scene(vbox);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch();
}
private static class Person {
// exact same as in the other answer
}
}
TL;DR: Listener is also activated on other cells.
I have a TreeView containing different TreeItems representing data of a custom class. If a BooleanProperty of the underlying data changes, the cell should change its colour. If it changes again, the colour should be removed.
I use a listener, but when I scroll through the TreeView, a changing property of a certain cell also changes the colour of other cells. This behaviour can be reproduced when running my MWE and right clicking on some cells, scrolling, clicking again, and so on. The TreeView might be cleaned by just scrolling so that the concerned cells are out a view for a moment.
I could remove the listener, but then the colour will only be changed, if the cell reappears after scrolling away and back to it.
The question is: How can I use a listener properly in a cellFactory?
MWE
CellFactoryQuestion.java
package cellfactoryquestion;
import javafx.application.Application;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.css.PseudoClass;
import javafx.scene.Scene;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class CellFactoryQuestion extends Application {
/** Custom class used as underlying data of TreeItems */
class CustomObject {
String label;
BooleanProperty state = new SimpleBooleanProperty(false);
CustomObject(String s) { label = s; }
}
/** Cell Factory for CustomObject */
class CustomTreeCell extends TreeCell<CustomObject>{
PseudoClass customClass = PseudoClass.getPseudoClass("custom");
#Override
protected void updateItem(CustomObject co, boolean empty) {
super.updateItem(co, empty);
if (empty || co == null) {
setText(null);
setGraphic(null);
pseudoClassStateChanged(customClass, false);
} else {
setText(co.label);
setGraphic(null);
// BEGIN PROBLEMATIC
/* define background color of cell according to state */
pseudoClassStateChanged(customClass, co.state.getValue());
co.state.addListener((o, ov, nv) -> {
pseudoClassStateChanged(customClass, nv);
});
// END PROBLEMATIC
/* if right click, switch state */
this.setOnContextMenuRequested(e -> {
co.state.setValue(co.state.getValue() ^ true);
});
}
}
}
#Override
public void start(Stage primaryStage) {
/* define TreeView 1/3 */
TreeView tw = new TreeView();
TreeItem rootTreeItem = new TreeItem(new CustomObject("Root"));
rootTreeItem.setExpanded(true);
/* define TreeView 2/3 */
for (int c = 0; c != 5; c++) {
TreeItem ci = new TreeItem(new CustomObject("Cat " + c));
rootTreeItem.getChildren().add(ci);
ci.setExpanded(true);
for (int i = 0; i != 5; i++) {
TreeItem ii = new TreeItem(new CustomObject("Item " + i));
ci.getChildren().add(ii);
}
}
/* define TreeView 3/3 */
tw.setRoot(rootTreeItem);
tw.setCellFactory(value -> new CustomTreeCell());
/* define Scene */
StackPane root = new StackPane();
root.getChildren().add(tw);
Scene scene = new Scene(root, 300, 250);
scene.getStylesheets().add("/styles/Styles.css");
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Styles.css
.tree-cell:custom {
-fx-background-color: salmon;
}
The problem is that you do not unregister the listener. Do this before calling super.updateItem. This allows you to retrieve the old item using getItem:
class CustomTreeCell extends TreeCell<CustomObject>{
private final ChangeListener<Boolean> listener = (o, ov, nv) -> pseudoClassStateChanged(customClass, nv);
PseudoClass customClass = PseudoClass.getPseudoClass("custom");
#Override
protected void updateItem(CustomObject co, boolean empty) {
// remove listener from old item
CustomObject oldItem = getItem();
if (oldItem != null) {
oldItem.state.removeListener(listener);
}
super.updateItem(co, empty);
if (empty || co == null) {
setText(null);
setGraphic(null);
pseudoClassStateChanged(customClass, false);
} else {
setText(co.label);
setGraphic(null);
/* define background color of cell according to state */
pseudoClassStateChanged(customClass, co.state.getValue());
co.state.addListener(listener);
...
I wrote the following method that is used to react when I press a button:
private void handlePlayButton(ActionEvent e){
for (int i = 0; i < commands.size(); i++) {
list.getSelectionModel().select(i);
try {
Thread.sleep(1000);
} catch (InterruptedException ie){
System.out.println("Error at handlPlayButton: interruption");
}
}
}
In this code I try to select each element starting at the first line and than wait 1 second to select the next one, but it seems like it waits n-1 seconds (where n is the size of the items) and than selects the last item. Is there any way to fix this?
The field list is a ListView<String> by the way.
This question is relatively obscure, so I don't think the solution will be generally applicable to anybody else. The basic solution is to use a Timeline to automate updating the selection in the ListView when the user presses a "Cycle" button in the UI.
There is a bit of additional logic to handle edge cases such as what to do if the user modifies the selection while the cycling is ongoing or if the user restarts the cycling process. If the user clicks on the currently selected item, the automated cycling does not stop, so the original asker might wish to add some of his own code to do that if he adopts a similar solution.
Also there is some logic for placing ImageViews in the ListView, but that isn't central to the application and can be ignored for more common types stored used in a ListView such as Strings. The ImageView related stuff is just there to make the app look a bit better.
import javafx.animation.*;
import javafx.application.Application;
import javafx.collections.*;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.image.*;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import javafx.util.Duration;
import java.util.Arrays;
import java.util.stream.Collectors;
public class RotatingSushiMenu extends Application {
private static final Duration AUTO_CHANGE_PAUSE = Duration.seconds(2);
private boolean autoChange;
#Override
public void start(Stage stage) {
ObservableList<Image> images = FXCollections.observableList(
Arrays.stream(IMAGE_LOCS)
.map(Image::new)
.collect(Collectors.toList())
);
ListView<Image> list = new ListView<>(FXCollections.observableList(images));
list.setCellFactory(param -> new ImageListCell());
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO),
new KeyFrame(
AUTO_CHANGE_PAUSE,
e -> {
int curIdx = list.getSelectionModel().getSelectedIndex();
if (curIdx < list.getItems().size() - 1) {
autoChange = true;
list.scrollTo(curIdx + 1);
list.getSelectionModel().select(curIdx + 1);
autoChange = false;
}
}
)
);
timeline.setCycleCount(list.getItems().size());
Button cycle = new Button("Cycle");
cycle.setOnAction(event -> {
if (list.getItems().size() > 0) {
list.scrollTo(0);
list.getSelectionModel().select(0);
timeline.playFromStart();
}
});
list.getSelectionModel().getSelectedItems().addListener((ListChangeListener<Image>) c -> {
if (!autoChange) {
timeline.stop();
}
});
VBox layout = new VBox(10, cycle, list);
layout.setPadding(new Insets(10));
stage.setScene(new Scene(layout));
stage.show();
}
public static void main(String[] args) {
launch(args);
}
private class ImageListCell extends ListCell<Image> {
final ImageView imageView = new ImageView();
ImageListCell() {
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
}
#Override
protected void updateItem(Image item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
imageView.setImage(null);
setText(null);
setGraphic(null);
}
imageView.setImage(item);
setGraphic(imageView);
}
}
// image license: linkware - backlink to http://www.fasticon.com
private static final String[] IMAGE_LOCS = {
"http://icons.iconarchive.com/icons/fasticon/fish-toys/128/Blue-Fish-icon.png",
"http://icons.iconarchive.com/icons/fasticon/fish-toys/128/Red-Fish-icon.png",
"http://icons.iconarchive.com/icons/fasticon/fish-toys/128/Yellow-Fish-icon.png",
"http://icons.iconarchive.com/icons/fasticon/fish-toys/128/Green-Fish-icon.png"
};
}
At work I created a TableView that needs to have specific cells flash from one color to the other simultaneously. This is relatively easy using Rectangles, FillTransitions, and a ParallelTransition as shown in the toy example below. After assigning the rectangle to the FillTransition, I set the TableCell's graphic to the rectangle. Then I just had to add/remove the FillTransition from the ParallelTransition depending on whether or not the cell should be blinking.
An area where I had a lot of difficulty, however, was in figuring out a way to scale the rectangle to the size of the TableCell containing it as a graphic. The problem I had was that the TableCell would always resize itself to have empty space between its boundaries and the boundaries of the rectangle.
I had to solve this in a very tedious and round-about way: I had to call setFixedCellSize to fix the table's cell height to whatever my rectangle's height was, reposition the rectangle up and to the left by trial and error through calling its setTranslateX/Y, and set the minwidth and minheight of the column to slightly less than whatever my rectangle's width and height was set to. It solved the problem, but I would've hoped for something a little less tedious and annoying.
I would have assumed this could be avoided by doing one or more of the following with the cell:
Calling setScaleShape(true)
Calling setContentDisplay(ContentDisplay.GRAPHIC_ONLY)
Setting the cell's CSS style to include "-fx-scale-shape: true"
Sadly, none of these had any noticeable effect...
My question is a three-parter:
Is there a better way to size a Shape assigned as a graphic for a Cell to fill the boundaries of the Cell?
Why would none of the three methods above have any effect in my case and what is their actual intended purpose? Do they only apply for a shape assigned with setShape() as opposed to setGraphic()?
Are there any legitimate reasons why JavaFx wouldn't support setting the preferred width or height of Nodes other than those that subclass Region? Autosizing seems like something that should be universal to all Nodes in the hierarchy, and it seems intuitive that any Parent node should be able to dictate the size of its children when necessary.
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import javafx.animation.Animation;
import javafx.animation.FillTransition;
import javafx.animation.ParallelTransition;
import javafx.application.Application;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.util.Duration;
public class FlashingPriorityTable extends Application {
public static void main(String args[]) {
FlashingPriorityTable.launch();
}
#Override
public void start(Stage primaryStage) throws Exception {
// periodically add prioritized items to an observable list
final ObservableList<PItem> itemList = FXCollections.observableArrayList();
class ItemAdder {
private int state, count = 0; private final int states = 3;
public synchronized void addItem() {
state = count++ % states;
PItem item;
if(state == 0)
item = new PItem(Priority.LOW, count, "bob saget");
else if(state == 1)
item = new PItem(Priority.MEDIUM, count, "use the force");
else
item = new PItem(Priority.HIGH, count, "one of us is in deep trouble");
itemList.add(item);
}
};
final ItemAdder itemAdder = new ItemAdder();
Executors.newScheduledThreadPool(1).scheduleAtFixedRate(
() -> itemAdder.addItem(),
0, // initial delay
1, // period
TimeUnit.SECONDS); // time unit
// set up a table view bound to the observable list
final TableColumn<PItem, Priority> priCol = new TableColumn<>("Priority");
priCol.setCellValueFactory(new PropertyValueFactory<PItem, Priority>("priority"));
priCol.setCellFactory((col) -> new PriorityCell()); // create a blinking cell
priCol.setMinWidth(50);
priCol.setMaxWidth(50);
final TableColumn<PItem, Integer> indexCol = new TableColumn<>("Index");
indexCol.setCellValueFactory(new PropertyValueFactory<PItem, Integer>("index"));
indexCol.setCellFactory((col) -> makeBorderedTextCell());
final TableColumn<PItem, String> descriptionCol = new TableColumn<>("Description");
descriptionCol.setCellValueFactory(new PropertyValueFactory<PItem, String>("description"));
descriptionCol.setCellFactory((col) -> makeBorderedTextCell());
descriptionCol.setMinWidth(300);
final TableView<PItem> table = new TableView<>(itemList);
table.getColumns().setAll(priCol, indexCol, descriptionCol);
table.setFixedCellSize(25);
// display the table view
final Scene scene = new Scene(table);
primaryStage.setScene(scene);
primaryStage.show();
}
// render a simple cell text and border
private <T> TableCell<PItem, T> makeBorderedTextCell() {
return new TableCell<PItem, T>() {
#Override protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
if(item == null || empty) {
setText(null);
} else {
setBorder(new Border(new BorderStroke(Color.GREEN, BorderStrokeStyle.SOLID, null, null)));
setText(item.toString());
}
}
};
}
/* for cells labeled as high priority, render an animation that blinks (also include a border) */
public static class PriorityCell extends TableCell<PItem, Priority> {
private static final ParallelTransition pt = new ParallelTransition();
private final Rectangle rect = new Rectangle(49.5, 24);
private final FillTransition animation = new FillTransition(Duration.millis(100), rect);
public PriorityCell() {
rect.setTranslateX(-2.75);
rect.setTranslateY(-2.7);
animation.setCycleCount(Animation.INDEFINITE); animation.setAutoReverse(true); }
#Override
protected void updateItem(Priority priority, boolean empty) {
super.updateItem(priority, empty);
if(priority == null || empty) {
setGraphic(null);
return;
}
setGraphic(rect);
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
setBorder(new Border(new BorderStroke(Color.GREEN, BorderStrokeStyle.SOLID, null, null)));
if(priority == Priority.HIGH) {
if(!pt.getChildren().contains(animation)) {
animation.setFromValue(Color.BLACK);
animation.setToValue(priority.getColor());
animation.setShape(rect);
pt.getChildren().add(animation);
pt.stop(); pt.play();
}
} else {
if(pt.getChildren().contains(animation)) {
pt.getChildren().remove(animation);
pt.stop(); pt.play();
}
rect.setFill(priority.getColor());
}
}
}
/* an item that has a priority assigned to it */
public static class PItem {
private ObjectProperty<Priority> priority = new SimpleObjectProperty<>();
private IntegerProperty index = new SimpleIntegerProperty();
private StringProperty description = new SimpleStringProperty();
public PItem(Priority priority, Integer index, String description) {
setPriority(priority); setIndex(index); setDescription(description);
}
public void setPriority(Priority priority_) { priority.set(priority_); }
public Priority getPriority() { return priority.get(); }
public void setIndex(int index_) { index.set(index_); }
public Integer getIndex() { return index.get(); }
public void setDescription(String description_) { description.set(description_); }
public String getDescription() { return description.get(); }
}
/* a priority */
public enum Priority {
HIGH(Color.RED), MEDIUM(Color.ORANGE), LOW(Color.BLUE);
private final Color color;
private Priority(Color color) { this.color = color; }
public Color getColor() { return color; }
}
}
Regarding:
the TableCell would always resize itself to have empty space between its boundaries and the boundaries of the rectangle.
This is because the cell has by default 2 px of padding, according to modena.css:
.table-cell {
-fx-padding: 0.166667em; /* 2px, plus border adds 1px */
-fx-cell-size: 2.0em; /* 24 */
}
One easy way to get rid of this empty space is just override it:
#Override
protected void updateItem(Priority priority, boolean empty) {
super.updateItem(priority, empty);
...
setGraphic(rect);
setStyle("-fx-padding: 0;");
...
}
The next problem you also mention is autosizing. According to JavaDoc, for Node.isResizable():
If this method returns true, then the parent will resize the node (ideally within its size range) by calling node.resize(width,height) during the layout pass. All Regions, Controls, and WebView are resizable classes which depend on their parents resizing them during layout once all sizing and CSS styling information has been applied.
If this method returns false, then the parent cannot resize it during layout (resize() is a no-op) and it should return its layoutBounds for minimum, preferred, and maximum sizes. Group, Text, and all Shapes are not resizable and hence depend on the application to establish their sizing by setting appropriate properties (e.g. width/height for Rectangle, text on Text, and so on). Non-resizable nodes may still be relocated during layout.
Clearly, a Rectangle is not resizable, but this doesn't mean you can't resize it: if the layout doesn't do it for you, you'll need to take care of it.
So one easy solution may be binding the dimensions of the rectangle to those of the cell (minus 2 pixels for the cell borders):
private final Rectangle rect = new Rectangle();
#Override
protected void updateItem(Priority priority, boolean empty) {
super.updateItem(priority, empty);
if(priority == null || empty) {
setGraphic(null);
return;
}
setGraphic(rect);
setStyle("-fx-padding: 0;");
rect.widthProperty().bind(widthProperty().subtract(2));
rect.heightProperty().bind(heightProperty().subtract(2));
...
}
Note that you won't need to translate the rectangle, and it won't be necessary to fix the size of the cell nor the width of the column, unless you want to give it a fixed size.
Note also that setShape() is intended to change the cell shape, that by default is already a rectangle.
This may answer your first two questions. For the third one, sometimes you wish the nodes were always resizable... but if that were the case we will have the opposite problem, trying to keep them constrained...
The trigger for my experiment was a recent question - one cell in a row should visualize the relative proportion of values in several cell values in the same row. In fx, such a visualization is supported in StackedBarChart (degenerate to a single category and with yAxis being the category axis).
Unfortunately, using such a chart as cell graphics has weird effects when updating the item, depending on how we do the update:
scenario A: initialize the chart with the series and update the data in the series. The bars appear fine only on the very first showing, scrolling back and forth leaves random "gaps" inside
scenario B: create and set new series in each round. The bars seem to have the correct width, but their colors changes randomly on scrolling
Also, there are minor visual quirks f.i. can't find a way to restrict the height of the cell as needed.
The questions:
how to make it work correctly, or
what's wrong, which part of the rendering mechanism interfers?
The example:
import javafx.application.Application;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.chart.CategoryAxis;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.StackedBarChart;
import javafx.scene.chart.XYChart;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.stage.Stage;
/**
* from SO, how to show relative bars with colors of
* a related chart
*
* https://stackoverflow.com/a/28141421/203657
*
* That's a solution with manually calculating and
* filling a rectangle with base chart colors
*
* Here trying to use StackedBarChart .. problems as noted in cell doc.
* Extracted TableStackedBarChart for SO question.
*/
public class TableStackedBar extends Application {
public static void main(String[] args) { launch(args); }
#Override
public void start(Stage stage) {
ObservableList<Data> data = FXCollections.observableArrayList();
for (int i = 0; i<10; i++) data.add(new Data());
TableView<Data> tv = new TableView<>(data);
TableColumn<Data, Number> col1 = new TableColumn<>("num1");
TableColumn<Data, Number> col2 = new TableColumn<>("num2");
col1.setCellValueFactory((p)->{return p.getValue().num1;});
col2.setCellValueFactory((p)->{return p.getValue().num2;});
//make this column hold the entire Data object so we can access all fields
TableColumn<Data, Data> col3 = new TableColumn<>("bar");
col3.setPrefWidth(500);
col3.setCellValueFactory((p)->{return new ReadOnlyObjectWrapper<>(p.getValue());});
col3.setCellFactory(p -> new StackedBarChartCell(2000.));
tv.getColumns().addAll(col1,col2,col3);
tv.setFixedCellSize(50.);
Scene scene = new Scene(tv);
stage.setScene(scene);
stage.show();
}
/**
* TableCell that uses a StackedBarChart to visualize relation of
* data.
*
* Problems with updating items:
* - scenario A: updating the series leaves empty patches horizontally
* - scenario B: re-setting the series changes colors randomly
*
* Other problems
* - runs amok without fixedCellSize on tableView
* - can't max the height of the chart (so it's cut-off in the middle
*/
public static class StackedBarChartCell extends TableCell<Data, Data> {
NumberAxis xAxisHoriz = new NumberAxis();
CategoryAxis yAxisHoriz = new CategoryAxis();
StackedBarChart<Number, String> sbcHoriz = new StackedBarChart<>(xAxisHoriz, yAxisHoriz);
XYChart.Series<Number, String> series1Horiz = new XYChart.Series<>();
XYChart.Series<Number, String> series2Horiz = new XYChart.Series<>();
public StackedBarChartCell(double upperBound) {
yAxisHoriz.setTickLabelsVisible(false);
yAxisHoriz.setTickMarkVisible(false);
yAxisHoriz.setStyle("-fx-border-color: transparent transparent transparent transparent;");
xAxisHoriz.setTickLabelsVisible(false);
xAxisHoriz.setTickMarkVisible(false);
xAxisHoriz.setMinorTickVisible(false);
xAxisHoriz.setStyle("-fx-border-color: transparent transparent transparent transparent;");
xAxisHoriz.setAutoRanging(false);
xAxisHoriz.setUpperBound(upperBound);
xAxisHoriz.setLowerBound(0.);
sbcHoriz.setHorizontalGridLinesVisible(false);
sbcHoriz.setVerticalGridLinesVisible(false);
sbcHoriz.setLegendVisible(false);
sbcHoriz.setAnimated(false);
// scenario A: set series initially
sbcHoriz.getData().setAll(series1Horiz, series2Horiz);
sbcHoriz.setCategoryGap(0);
// no effect
sbcHoriz.setMaxHeight(20);
}
#Override
protected void updateItem(Data item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setGraphic(null);
} else {
setGraphic(sbcHoriz);
// scenario B: set new series
// uncomment for scenario A
// XYChart.Series<Number, String> series1Horiz = new XYChart.Series<>();
// XYChart.Series<Number, String> series2Horiz = new XYChart.Series<>();
// sbcHoriz.getData().setAll(series1Horiz, series2Horiz);
//---- end of scenario B
series1Horiz.getData().setAll(new XYChart.Data(item.num1.get(), "none"));
series2Horiz.getData().setAll(new XYChart.Data(item.num2.get(), "none"));
}
}
}
private static class Data{
private SimpleIntegerProperty num1 = new SimpleIntegerProperty((int)(Math.random()*1000));
private SimpleIntegerProperty num2 = new SimpleIntegerProperty((int)(Math.random()*1000));
public SimpleIntegerProperty num1Property(){return num1;}
public SimpleIntegerProperty num2Property(){return num2;}
}
}
Update: seems to be a regression in 8u40 - works for 8u20/25, not for 8u40b20. Reported as RT-39884
Here's just where I copied stuff into my CellFactory
col3.setCellFactory((TableColumn<Data, Data> param) -> {
return new TableCell<Data, Data>() {
#Override
protected void updateItem(Data item, boolean empty) {
super.updateItem(item, empty);
if (empty) setGraphic(null);
else {
super.updateItem(item, empty);
if (item == null || empty) {
setGraphic(null);
} else {
NumberAxis xAxisHoriz = new NumberAxis(0, 2000, 1000);
CategoryAxis yAxisHoriz = new CategoryAxis(FXCollections.observableArrayList(""));
XYChart.Series<Number, String> series1Horiz = new XYChart.Series<>();
XYChart.Series<Number, String> series2Horiz = new XYChart.Series<>();
StackedBarChart<Number, String> sbcHoriz = new StackedBarChart<>(xAxisHoriz, yAxisHoriz);
sbcHoriz.getData().setAll(series1Horiz, series2Horiz);
yAxisHoriz.setStyle("-fx-border-color: transparent transparent transparent transparent;"
+ "-fx-tick-labels-visible: false;"
+ "-fx-tick-mark-visible: false;"
+ "-fx-minor-tick-visible: false;"
+ "-fx-padding: 0 0 0 0;");
xAxisHoriz.setStyle("-fx-border-color: transparent transparent transparent transparent;"
+ "-fx-tick-labels-visible: false;"
+ "-fx-tick-mark-visible: false;"
+ "-fx-minor-tick-visible: false;"
+ "-fx-padding: 0 0 0 0;");
sbcHoriz.setHorizontalGridLinesVisible(false);
sbcHoriz.setVerticalGridLinesVisible(false);
sbcHoriz.setLegendVisible(false);
sbcHoriz.setAnimated(false);
xAxisHoriz.setMaxWidth(100);
sbcHoriz.setMaxWidth(100);
sbcHoriz.setPadding(Insets.EMPTY);
sbcHoriz.setCategoryGap(0);
setGraphic(sbcHoriz);
series1Horiz.getData().setAll(new XYChart.Data(item.num1.get(), ""));
series2Horiz.getData().setAll(new XYChart.Data(item.num2.get(), ""));
}
}
}
};
});
and also after I set this tv.setFixedCellSize(30);
I also had to change the column width to 200, I can't make the chart smaller.