Recently I updated my application from JavaFX 8 to JavaFX 18. After the migration I found some weird issues related to layout of TreeView. If I understand correctly, by the end of a scene pulse, all the nodes (in fact parents) are rendered completely and the isNeedsLayout is turned to false, till the next change occurs.
This is working as expected in JavaFX 8. Whereas in JavaFX 18, for some nodes isNeedsLayout flag is still true even after the pulse is completed. Is this a bug? Or is it deliberately implemented like that?
In the below demo, I tried to print all the nodes (children of TreeView) state after the pulse is completed. And I can clearly see the difference in output between the two JavaFX versions.
Can anyone tell me, how can I ensure that all the nodes are rendered/laid-out correctly.
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.util.Duration;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class TreeViewLayoutIssue extends Application {
int k = 1;
#Override
public void start(Stage primaryStage) throws Exception {
final TreeView<String> fxTree = new TreeView<>();
fxTree.setCellFactory(t -> new TreeCell<String>() {
Label lbl = new Label();
#Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
setText(null);
if (item != null) {
lbl.setText(item);
setGraphic(lbl);
} else {
setGraphic(null);
}
}
#Override
protected void layoutChildren() {
super.layoutChildren();
if (getItem() != null) {
System.out.println("Layouting ::> " + getItem());
}
}
});
fxTree.setShowRoot(false);
StackPane root = new StackPane(fxTree);
root.setPadding(new Insets(15));
final Scene scene = new Scene(root, 250, 250);
scene.getStylesheets().add(this.getClass().getResource("treeview.css").toExternalForm());
primaryStage.setTitle("TreeView FX18");
primaryStage.setScene(scene);
primaryStage.show();
addData(fxTree);
final Timeline timeline = new Timeline(new KeyFrame(Duration.millis(2000), e -> {
System.out.println("\nIteration #" + k++);
printNeedsLayout(fxTree);
System.out.println("-----------------------------------------------------------------------------");
}));
timeline.setCycleCount(3);
timeline.play();
}
private void printNeedsLayout(final Parent parent) {
System.out.println(" " + parent + " isNeedsLayout: " + parent.isNeedsLayout());
for (final Node n : parent.getChildrenUnmodifiable()) {
if (n instanceof Parent) {
printNeedsLayout((Parent) n);
}
}
}
private void addData(TreeView<String> fxTree) {
final TreeItem<String> rootNode = new TreeItem<>("");
fxTree.setRoot(rootNode);
final TreeItem<String> grp1Node = new TreeItem<>("Group 1");
final TreeItem<String> grp2Node = new TreeItem<>("Group 2");
rootNode.getChildren().addAll(grp1Node, grp2Node);
final TreeItem<String> subNode = new TreeItem<>("Team");
grp1Node.getChildren().addAll(subNode);
final List<TreeItem<String>> groups = Stream.of("Red", "Green", "Yellow", "Blue").map(TreeItem::new).collect(Collectors.toList());
groups.forEach(itm -> subNode.getChildren().add(itm));
grp1Node.setExpanded(true);
grp2Node.setExpanded(true);
subNode.setExpanded(true);
}
}
treeview.css
.virtual-flow .clipped-container .sheet .tree-cell .tree-disclosure-node > .arrow {
-fx-background-color: #77797a;
}
.virtual-flow .clipped-container .sheet .tree-cell .tree-disclosure-node {
-fx-padding: 5px 6px 3px 8px; /* default is 4px 6px 4px 8px */
}
.virtual-flow .clipped-container .sheet .tree-cell:expanded > .tree-disclosure-node {
-fx-padding: 7px 6px 1px 8px; /* default is 4px 6px 4px 8px */
}
.virtual-flow .clipped-container .sheet .tree-cell:selected > .tree-disclosure-node > .arrow {
-fx-background-color: #f7f7f7;
}
And the output is as follows:
When selecting the node for the first time, a slight shift in text is observed in FX18. Whereas there is no issue with FX8.
Output (JavaFX 8): All the node's isNeedsLayout is false.
TreeView#7abc5bb4[styleClass=tree-view] isNeedsLayout: false
VirtualFlow[id=virtual-flow, styleClass=virtual-flow] isNeedsLayout: false
VirtualFlow$ClippedContainer#187e8c80[styleClass=clipped-container] isNeedsLayout: false
Group#57d7468f[styleClass=sheet] isNeedsLayout: false
TreeViewLayoutIssue$1#11a5c9a9[styleClass=cell indexed-cell tree-cell]'Group 1' isNeedsLayout: false
StackPane#7c0429[styleClass=tree-disclosure-node] isNeedsLayout: false
StackPane#731322a7[styleClass=arrow] isNeedsLayout: false
TreeViewLayoutIssue$1#198e401a[styleClass=cell indexed-cell tree-cell]'Team' isNeedsLayout: false
StackPane#e2e9b3[styleClass=tree-disclosure-node] isNeedsLayout: false
StackPane#18615368[styleClass=arrow] isNeedsLayout: false
TreeViewLayoutIssue$1#1632f423[styleClass=cell indexed-cell tree-cell]'Red' isNeedsLayout: false
TreeViewLayoutIssue$1#1f706faa[styleClass=cell indexed-cell tree-cell]'Green' isNeedsLayout: false
TreeViewLayoutIssue$1#2fc143cc[styleClass=cell indexed-cell tree-cell]'Yellow' isNeedsLayout: false
TreeViewLayoutIssue$1#6cd95e88[styleClass=cell indexed-cell tree-cell]'Blue' isNeedsLayout: false
TreeViewLayoutIssue$1#1d2b9016[styleClass=cell indexed-cell tree-cell]'Group 2' isNeedsLayout: false
TreeViewLayoutIssue$1#7ed38c68[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
TreeViewLayoutIssue$1#4a73e93a[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
TreeViewLayoutIssue$1#2a65b6dd[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
Group#2c481877 isNeedsLayout: false
TreeViewLayoutIssue$1#1621d92f[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
VirtualScrollBar#9a98f02[styleClass=scroll-bar] isNeedsLayout: false
StackPane#343dbe88[styleClass=track-background] isNeedsLayout: false
ScrollBarSkin$2#4727ac31[styleClass=increment-button] isNeedsLayout: false
Region#6d0e770[styleClass=increment-arrow] isNeedsLayout: false
ScrollBarSkin$3#201819d7[styleClass=decrement-button] isNeedsLayout: false
Region#38d26811[styleClass=decrement-arrow] isNeedsLayout: false
StackPane#2e0a5131[styleClass=track] isNeedsLayout: false
ScrollBarSkin$1#771a6386[styleClass=thumb] isNeedsLayout: false
VirtualScrollBar#3177bd8a[styleClass=scroll-bar] isNeedsLayout: false
StackPane#39ae89a6[styleClass=track-background] isNeedsLayout: false
ScrollBarSkin$2#3393f75c[styleClass=increment-button] isNeedsLayout: false
Region#20002045[styleClass=increment-arrow] isNeedsLayout: false
ScrollBarSkin$3#1230eb63[styleClass=decrement-button] isNeedsLayout: false
Region#5dcf6e46[styleClass=decrement-arrow] isNeedsLayout: false
StackPane#342b0a3d[styleClass=track] isNeedsLayout: false
ScrollBarSkin$1#798546a7[styleClass=thumb] isNeedsLayout: false
StackPane#7094a28f[styleClass=corner] isNeedsLayout: false
Output (JavaFX 18): Some node's isNeedsLayout is still true.
TreeView#6d663ddb[styleClass=tree-view] isNeedsLayout: true
VirtualFlow[id=virtual-flow, styleClass=virtual-flow] isNeedsLayout: false
VirtualFlow$ClippedContainer#25755334[styleClass=clipped-container] isNeedsLayout: false
Group#5ec62b29[styleClass=sheet] isNeedsLayout: true
TreeViewLayoutIssue$1#61b62840[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
TreeViewLayoutIssue$1#1995039d[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
TreeViewLayoutIssue$1#5401ae6f[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
TreeViewLayoutIssue$1#2d51271e[styleClass=cell indexed-cell tree-cell]'Group 2' isNeedsLayout: true
TreeViewLayoutIssue$1#500b9275[styleClass=cell indexed-cell tree-cell]'Blue' isNeedsLayout: true
TreeViewLayoutIssue$1#342e3899[styleClass=cell indexed-cell tree-cell]'Yellow' isNeedsLayout: true
TreeViewLayoutIssue$1#68c87749[styleClass=cell indexed-cell tree-cell]'Green' isNeedsLayout: true
TreeViewLayoutIssue$1#18ed5cce[styleClass=cell indexed-cell tree-cell]'Red' isNeedsLayout: true
TreeViewLayoutIssue$1#2f10a091[styleClass=cell indexed-cell tree-cell]'Team' isNeedsLayout: true
StackPane#16cb362[styleClass=tree-disclosure-node] isNeedsLayout: true
StackPane#7b6a2594[styleClass=arrow] isNeedsLayout: false
TreeViewLayoutIssue$1#70c30d4[styleClass=cell indexed-cell tree-cell]'Group 1' isNeedsLayout: true
StackPane#698dbc70[styleClass=tree-disclosure-node] isNeedsLayout: true
StackPane#45cafbcd[styleClass=arrow] isNeedsLayout: false
Group#461a2cd9 isNeedsLayout: false
VirtualScrollBar#13f48448[styleClass=scroll-bar] isNeedsLayout: false
StackPane#1dea4632[styleClass=track-background] isNeedsLayout: false
ScrollBarSkin$2#6acd8e11[styleClass=increment-button] isNeedsLayout: false
Region#5ab4b98c[styleClass=increment-arrow] isNeedsLayout: false
ScrollBarSkin$3#36602364[styleClass=decrement-button] isNeedsLayout: false
Region#178b3dbd[styleClass=decrement-arrow] isNeedsLayout: false
StackPane#d25f45e[styleClass=track] isNeedsLayout: false
ScrollBarSkin$1#4231e81e[styleClass=thumb] isNeedsLayout: false
VirtualScrollBar#6662e7e5[styleClass=scroll-bar] isNeedsLayout: false
StackPane#63f40cf6[styleClass=track-background] isNeedsLayout: false
ScrollBarSkin$2#36c6ae74[styleClass=increment-button] isNeedsLayout: false
Region#261de839[styleClass=increment-arrow] isNeedsLayout: false
ScrollBarSkin$3#7f53c4b5[styleClass=decrement-button] isNeedsLayout: false
Region#6c2c3d1f[styleClass=decrement-arrow] isNeedsLayout: false
StackPane#721aca85[styleClass=track] isNeedsLayout: false
ScrollBarSkin$1#314afb8c[styleClass=thumb] isNeedsLayout: false
StackPane#5183e9a5[styleClass=corner] isNeedsLayout: false
One observation regarding the shift in text:
If I add the data to treeView after showing the stage, the layout of the cells is in opposite direction to the layout of the cells when adding data before showing the stage.
Case#1: Layout of cells when data is added before showing the stage:
Layouting ::> Group 1
Layouting ::> Team
Layouting ::> Red
Layouting ::> Green
Layouting ::> Yellow
Layouting ::> Blue
Layouting ::> Group 2
Case#2: Layout of cells when data is added after showing the stage:
Layouting ::> Group 2
Layouting ::> Blue
Layouting ::> Yellow
Layouting ::> Green
Layouting ::> Red
Layouting ::> Team
Layouting ::> Group 1
I can see that this is another issue in TreeView. To breifly explain the issue:
TreeCellSkin internally maintains a static map(maxDisclosureWidthMap) to keep track of the widest disclosure node width in a TreeView.
Lets say I customized the disclosure node width (via css) to have width greater than 18px (18px is the hardcoded value in TreeCellSkin !!).
If the nodes are laid-out from bottom to top (case#2), it uses the default 18px disclosure node width for all the cells till it finds the first arrow (here it is 'Team').
Once it finds a width greater than the default width, it updates the map. But there is no code to recompute the already computed cells !!.
When the next layout request comes, now all the cells are computed with the updated disclosure-node width. And that is the reason for the shift in text.
To prove this, if I update my css to have a larger left-padding to the disclosure node (say 50px). In the below gif, you can see that, the cells above "Team" are laid-out correctly. And when I select the treeView(which forces a layout request), then the other cells use the correct disclosure node width and shifts the text.
.virtual-flow .clipped-container .sheet .tree-cell .tree-disclosure-node {
-fx-padding: 5px 6px 3px 50px; /* default is 4px 6px 4px 8px */
}
.virtual-flow .clipped-container .sheet .tree-cell:expanded > .tree-disclosure-node {
-fx-padding: 7px 6px 1px 50px; /* default is 4px 6px 4px 8px */
}
My final solution: [Outdated][see another solution below]
Considering all the above issues (inconsistent isNeedsLayout, text shift.. etc), I came up with the below solution.
The idea is to start an AnimationTimer to keep checking if any of of the child nodes isNeedsLayout is true. If it is true, then it forces to layout by calling the layout() method. Once I add the below code after my treeView initialiation, all the issues are fixed.
// Code to add after initializing TreeView
new AnimationTimer() {
#Override
public void handle(final long now) {
forceLayout(fxTree);
}
}.start();
private void forceLayout(final Parent parent) {
for (final Node n : parent.getChildrenUnmodifiable()) {
if (n instanceof final Parent p) {
forceLayout(p);
}
}
if (parent.isNeedsLayout()) {
parent.layout();
}
}
Now my next biggest fear is: Will it degrade the performance ??
[Update] Alternate Solution:
Though the answer provided by #kleopatra fixes the issue, I can still see a quick layout jump when the window is opened. It is quite obvious behaviour, as we are requesting layout in some unknown time in future(Platform.runLater).
To get rid of this effect, I need to ensure that the layout is corrected with in the same pulse. So I came with a solution to forceLayout (my previous solution) at the end of the pulse whenever there is a change in disclosure node width(#kleopatra's solution)
The new solution is :
Whenever there is a change in disclosure node width, we register a listener in scene's postLayoutPulseListener to force layout the treeView .
After the layoutPass in the pulse, this postLayoutPulseListener will force the layout of all children in the treeView.
Once the force layout is done, we remove this listener to ensure that this will not trigger in next pulses.
Below is the complete working code of the example with the new solution (using the same treeview.css):
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.skin.TreeCellSkin;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.util.Duration;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class TreeViewLayoutIssue extends Application {
int k = 1;
/**
* Utility class to hack around JDK-8288665: broken layout of
* nested TreeCells.
*/
public class DisclosureNodeHack {
/**
* Key for max disclosure node width
*/
public static final String DISCLOSURE_NODE_WIDTH = "disclosureNodeWidth";
public static class HackedTreeCell<String> extends TreeCell<String> {
Label lbl = new Label();
#Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
setText(null);
if (item != null) {
lbl.setText((java.lang.String) item);
setGraphic(lbl);
} else {
setGraphic(null);
}
}
#Override
protected void layoutChildren() {
super.layoutChildren();
if (getItem() != null) {
System.out.println("Laid-out TreeCell ::> " + getItem());
}
}
#Override
protected Skin<?> createDefaultSkin() {
return new DisclosureNodeHack.HackedTreeCellSkin<>(this);
}
public final HackedTreeView<String> getHackedTreeView() {
return (HackedTreeView<String>) getTreeView();
}
}
/**
* Custom skin that puts the width of the disclosure node in the
* Tree's properties.
*/
public static class HackedTreeCellSkin<T> extends TreeCellSkin<T> {
HackedTreeCell<T> cell;
public HackedTreeCellSkin(HackedTreeCell<T> control) {
super(control);
cell = control;
}
#Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
if (getSkinnable().getTreeItem() == null || getSkinnable().getTreeView() == null) return;
Node disclosure = getSkinnable().lookup(".tree-disclosure-node");
if (disclosure instanceof Region) {
double width = ((Region) disclosure).getWidth();
Object prevWidth = getSkinnable().getTreeView().getProperties().get(DISCLOSURE_NODE_WIDTH);
getSkinnable().getTreeView().getProperties().put(DISCLOSURE_NODE_WIDTH, width);
if (prevWidth == null || ((Double) prevWidth).doubleValue() != width) {
cell.getHackedTreeView().installListener();
}
}
}
}
public static class HackedTreeView<T> extends TreeView<T> {
private Runnable listener = new Runnable() {
#Override
public void run() {
System.out.println("------ Forcing Layout ------");
forceLayout(HackedTreeView.this);
getScene().removePostLayoutPulseListener(this);
}
};
public HackedTreeView() {
setCellFactory(t -> new DisclosureNodeHack.HackedTreeCell());
}
private void forceLayout(final Parent parent) {
for (final Node n : parent.getChildrenUnmodifiable()) {
if (n instanceof final Parent p) {
forceLayout(p);
}
}
if (parent.isNeedsLayout()) {
parent.layout();
}
}
public final void installListener() {
getScene().removePostLayoutPulseListener(listener);
getScene().addPostLayoutPulseListener(listener);
}
}
private DisclosureNodeHack() {
}
}
#Override
public void start(Stage primaryStage) throws Exception {
final DisclosureNodeHack.HackedTreeView<String> fxTree = new DisclosureNodeHack.HackedTreeView<>();
fxTree.setShowRoot(false);
StackPane root = new StackPane(fxTree);
root.setPadding(new Insets(15));
final Scene scene = new Scene(root, 250, 250);
scene.getStylesheets().add(this.getClass().getResource("treeview.css").toExternalForm());
primaryStage.setTitle("TreeView FX18");
primaryStage.setScene(scene);
primaryStage.show();
addData(fxTree);
final Timeline timeline = new Timeline(new KeyFrame(Duration.millis(2000), e -> {
System.out.println("\nIteration #" + k++);
printNeedsLayout(fxTree);
System.out.println("-----------------------------------------------------------------------------");
}));
timeline.setCycleCount(1);
timeline.play();
}
private void printNeedsLayout(final Parent parent) {
System.out.println(" " + parent + " isNeedsLayout: " + parent.isNeedsLayout());
for (final Node n : parent.getChildrenUnmodifiable()) {
if (n instanceof Parent) {
printNeedsLayout((Parent) n);
}
}
}
private void addData(TreeView<String> fxTree) {
final TreeItem<String> rootNode = new TreeItem<>("");
fxTree.setRoot(rootNode);
final TreeItem<String> grp1Node = new TreeItem<>("Group 1");
final TreeItem<String> grp2Node = new TreeItem<>("Group 2");
rootNode.getChildren().addAll(grp1Node, grp2Node);
final TreeItem<String> subNode = new TreeItem<>("Team");
grp1Node.getChildren().addAll(subNode);
final List<TreeItem<String>> groups = Stream.of("Red", "Green", "Yellow", "Blue").map(TreeItem::new).collect(Collectors.toList());
groups.forEach(itm -> subNode.getChildren().add(itm));
grp1Node.setExpanded(true);
grp2Node.setExpanded(true);
subNode.setExpanded(true);
}
}
As already noted in my comment: it's a bug. The underlying reason seems to be sub-optimal cell layout, in particular the layout of the disclosure node (see Sai's self-answer for details). This bug bubbled up after some optimization of layout in VirtualFlow - which still is correct, IMO, but exposed the mis-behavior of cell layout.
An alternative to constantly checking the layout tree in an AnimationTimer is to implement a collaboration between cell and tree:
a custom cell skin sets the disclosure node width in the tree's properties
the tree listens to the property change and forces a layout of the cell's parent
Below is a utility class providing support for both. It relies on the style tree which is not fully specified for TreeView (but is similar to ListView which is specified) and Node lookup, otherwise uses only public api. Choosing the one or other hack is a matter of personal preferences.
To use the utility:
// in application code
DisclosureNodeHack.installListener(tree);
// set the custom treeCell skin as default via css
.tree-cell {
-fx-skin: "mypackage.DisclosureNodeHack$HackedTreeCellSkin";
}
// for visualization when debugging
.tree-cell > .tree-disclosure-node {
-fx-padding: 4 6 4 50;
-fx-background-color: yellow;
}
The utility class (beware: not formally tested, and most probably with much leeway to improve):
/**
* Utility class to hack around JDK-8288665: broken layout of
* nested TreeCells.
*/
public class DisclosureNodeHack {
/** Key for max disclosure node width */
public static final String DISCLOSURE_NODE_WIDTH = "disclosureNodeWidth";
/**
* Custom skin that puts the width of the disclosure node in the
* Tree's properties.
*/
public static class HackedTreeCellSkin<T> extends TreeCellSkin<T> {
public HackedTreeCellSkin(TreeCell<T> control) {
super(control);
}
#Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
if (getSkinnable().getTreeItem() == null || getSkinnable().getTreeView() == null) return;
Node disclosure = getSkinnable().lookup(".tree-disclosure-node");
if (disclosure instanceof Region) {
double width = ((Region) disclosure).getWidth();
getSkinnable().getTreeView().getProperties().put(DISCLOSURE_NODE_WIDTH, width);
}
}
}
/**
* Utility method to register a listener to the tree's properties and
* forces a re-layout of the flow's sheet if the disclosure width changes.
*
* Note: experiments seem to indicate that layout must be done
* - after the current layout run is ready, that is in Platform.runlater
* - on the parent of the cell (== sheet), not on the tree
*/
public static <T> void installListener(TreeView<T> tree) {
tree.getProperties().addListener((MapChangeListener<Object,Object>)c -> {
if (DISCLOSURE_NODE_WIDTH.equals(c.getKey())) {
Platform.runLater(() -> {
Node sheet = tree.lookup(".sheet");
if (sheet instanceof Parent)
((Parent) sheet).requestLayout();
});
}
});
};
private DisclosureNodeHack() {};
}
Related
I'm trying to come up with a solution to allow multiple Pane nodes handle mouse events independently when assembled into a StackPane
StackPane
Pane 1
Pane 2
Pane 3
I'd like to be able to handle mouse events in each child, and the first child calling consume() stops the event going to the next child.
I'm also aware of setPickOnBounds(false), but this does not solve all cases as some of the overlays will be pixel based with Canvas, i.e. not involving the scene graph.
I've tried various experiments with Node.fireEvent(). However these always lead to recursion ending in stack overflow. This is because the event is propagated from the root scene and triggers the same handler again.
What I'm looking for is some method to trigger the event handlers on the child panes individually without the event travelling through its normal path.
My best workaround so far is to capture the event with a filter and manually invoke the handler. I'd need to repeat this for MouseMoved etc
parent.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> {
for (Node each : parent.getChildren()) {
if (!event.isConsumed()) {
each.getOnMouseClicked().handle(event);
}
}
event.consume();
});
However this only triggers listeners added with setOnMouseClicked, not addEventHandler, and only on that node, not child nodes.
Another sort of solution is just to accept JavaFX doesn't work like this, and restructure the panes like this, this will allow normal event propagation to take place.
Pane 1
Pane 2
Pane 3
Example
import javafx.application.Application;
import javafx.event.Event;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
public class EventsInStackPane extends Application {
public static void main(String[] args) {
launch(args);
}
private static class DebugPane extends Pane {
public DebugPane(Color color, String name) {
setBackground(new Background(new BackgroundFill(color, CornerRadii.EMPTY, Insets.EMPTY)));
setOnMouseClicked(event -> {
System.out.println("setOnMouseClicked " + name + " " + event);
});
addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
System.out.println("addEventHandler " + name + " " + event);
});
addEventFilter(MouseEvent.MOUSE_CLICKED, event -> {
System.out.println("addEventFilter " + name + " " + event);
});
}
}
#Override
public void start(Stage primaryStage) throws Exception {
DebugPane red = new DebugPane(Color.RED, "red");
DebugPane green = new DebugPane(Color.GREEN, "green");
DebugPane blue = new DebugPane(Color.BLUE, "blue");
setBounds(red, 0, 0, 400, 400);
setBounds(green, 25, 25, 350, 350);
setBounds(blue, 50, 50, 300, 300);
StackPane parent = new StackPane(red, green, blue);
eventHandling(parent);
primaryStage.setScene(new Scene(parent));
primaryStage.show();
}
private void eventHandling(StackPane parent) {
parent.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (!event.isConsumed()) {
for (Node each : parent.getChildren()) {
Event copy = event.copyFor(event.getSource(), each);
parent.fireEvent(copy);
if (copy.isConsumed()) {
break;
}
}
}
event.consume();
});
}
private void setBounds(DebugPane panel, int x, int y, int width, int height) {
panel.setLayoutX(x);
panel.setLayoutY(y);
panel.setPrefWidth(width);
panel.setPrefHeight(height);
}
}
Using the hint from #jewelsea I was able to use a custom chain. I've done this from a "catcher" Pane which is added to the front of the StackPane. This then builds a chain using all the children, in reverse order, excluding itself.
private void eventHandling(StackPane parent) {
Pane catcher = new Pane() {
#Override
public EventDispatchChain buildEventDispatchChain(EventDispatchChain tail) {
EventDispatchChain chain = super.buildEventDispatchChain(tail);
for (int i = parent.getChildren().size() - 1; i >= 0; i--) {
Node child = parent.getChildren().get(i);
if (child != this) {
chain = chain.prepend(child.getEventDispatcher());
}
}
return chain;
}
};
parent.getChildren().add(catcher);
}
I want to synchronize dividers in SplitPane, when divider(0) moves, I also want to make the same move by divider(1). I guess I have to bind the positionProperty of divider(0) with something.
How can I achieve this?
You need to add listeners to the positions of each divider, and update the "linked" divider when it changes. It's important to make sure you don't end up in an infinite recursive loop; the simplest way to do this is to set a flag indicating your updating, and not propagate the update if it's set.
Here's a proof-of-concept example that binds two dividers so the portion between them is always 1/3 of the split pane:
import java.util.List;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.SplitPane;
import javafx.scene.control.SplitPane.Divider;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.stage.Stage;
public class SplitPaneDemo extends Application {
// helper class that binds two divider positions so the portion between them
// is always 1/3 of the split pane
private static class DividerPositionBinder {
private static final double ONE_THIRD = 1.0/3.0;
private boolean updating ;
DividerPositionBinder(List<Divider> dividers) {
dividers.get(0).positionProperty().addListener((obs, oldPos, newPos) -> {
// don't propagate update if already in an update:
if (updating) return ;
// special handling for right edge of split pane:
if (newPos.doubleValue() > 1.0 - ONE_THIRD) {
dividers.get(0).setPosition(1.0 - ONE_THIRD);
dividers.get(1).setPosition(1.0);
return ;
}
// make right divider the new value + 1/3:
updating = true ;
dividers.get(1).setPosition(newPos.doubleValue() + ONE_THIRD);
updating = false ;
});
dividers.get(1).positionProperty().addListener((obs, oldPos, newPos) -> {
// don't propagate update if already in an update:
if (updating) return ;
// special handling for left edge of split pane:
if (newPos.doubleValue() < ONE_THIRD) {
dividers.get(1).setPosition(ONE_THIRD);
dividers.get(0).setPosition(0.0);
return ;
}
// make left divider the new value - 1/3:
updating = true ;
dividers.get(0).setPosition(newPos.doubleValue() - ONE_THIRD);
updating = false ;
});
}
}
#Override
public void start(Stage primaryStage) {
Region left = new Pane();
left.setStyle("-fx-background-color: coral; ");
Region middle = new Pane();
middle.setStyle("-fx-background-color: aquamarine ;");
Region right = new Pane();
right.setStyle("-fx-background-color: cornflowerblue ;");
SplitPane splitPane = new SplitPane(left, middle, right);
new DividerPositionBinder(splitPane.getDividers());
Scene scene = new Scene(splitPane, 800, 800);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Ok, heres my situation. I need to disable both spinner buttons when the Editor is empty, which is the final piece i need to complete this "custom" component. Heres my SSCCE.
When the focus is lost: Default value sets to zero and text is updated.
It only accepts decimal values with 2 decimal places, it is meant to only accept money or percentages values.
Nothing else to add.
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class test extends Application{
#Override
public void start(Stage primaryStage) throws Exception {
VBox v = new VBox();
v.setPadding(new Insets(20));
Spinner<Double> spinner = new Spinner<>();
spinner.setEditable(true);
Button dummy = new Button("dummy focus");
v.getChildren().addAll(spinner,dummy);
//----------------------------------HERE IS EVERYTHING RELATED TO THE SPINNER---------------------------------------------
spinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0, 100));
spinner.getValueFactory().setValue(0.0);
spinner.getEditor().textProperty().addListener((obs,old,gnu)->{
if(gnu.isEmpty()) {
System.out.println("empty, buttons should be disabled here, they will be disabled after this ");
spinner.getValueFactory().setValue(0.0);
return;
}
System.out.println("enabling buttons");
if(!gnu.matches("^\\d*\\.?\\d*$")) {
try {
spinner.getEditor().setText(old);
spinner.getValueFactory().setValue(Double.parseDouble(old));
}catch (NumberFormatException e) {
System.out.println("invalid string, previous value was empty, no biggie you are safe: Current value : "+spinner.getValueFactory().getValue());
}
} else {
if((Double.parseDouble(gnu)*100)%1!=0) {
spinner.getEditor().setText(old);
spinner.getValueFactory().setValue(Double.parseDouble(old));
}
/*
* You can use this to validate inside a range, for example. PERCENTAGES : 0 ~ 100
*
double val = Double.parseDouble(gnu)*100;
if(val%1!=0 || val>10000 || val<0) {
spinner.getEditor().setText(old);
spinner.getValueFactory().setValue(Double.parseDouble(old));
}
*/
}
});
spinner.getEditor().setOnKeyPressed(e->{
switch (e.getCode()) {
case UP:
spinner.increment(1);
break;
case DOWN:
spinner.decrement(1);
break;
default:
break;
}
});
spinner.setOnScroll(e->{
if(e.getDeltaY()>0)
spinner.increment(1);
else
spinner.decrement(1);
});
spinner.getEditor().focusedProperty().addListener((obs,old,niu)->{
if(!niu && spinner.getEditor().getText().isEmpty()) {
spinner.getEditor().setText("0");
spinner.getValueFactory().setValue(0.0);
}
});
//-----------------------------------------------------------------------------------------------------------------------------------------
Scene sc = new Scene(v);
primaryStage.setScene(sc);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
EDIT:
It also happens with keypress and scroll events.
I was also searching for a way to disable only the buttons. I needed to disable changing the Spinner's value while still allowing copy/paste, so I did some digging in the source code.
What I found was that, while the buttons are not actually part of the Spinner object itself, they're part of the Spinner's SpinnerSkin (they're also not Buttons, but StackPanes), so I managed to disable only the buttons with the following (in Kotlin):
val editing = SimpleBooleanProperty(false)
val spinner = Spinner<Int>()
spinner.skinProperty().addListener { observable, oldValue, newValue ->
// only bind if the skin is an instance of `SpinnerSkin`
if (newValue != null && newValue is SpinnerSkin<*>) {
(skin as SpinnerSkin<*>).children
// only select the children that are `StackPane`s (the buttons)
.filter { it is StackPane }
// bind the `disableProperty` of the buttons to our property for whether we're editing
.forEach { disableProperty().bind(editing.not()) }
}
}
I had to listen to the property change because the the skinProperty is not set on initialization, but only after the CSS gets processed. If you are absolutely sure that your spinner has already displayed and the skin is set, you can just call getSkin instead.
I'm afraid you can't disable only the spinner's buttons. But what about setting the value just after the Editor (which is actually the TextField) is empty? By using such a solution you don't get any exceptions after clicking buttons - value is just incremented from 0. I modified your gnu.isEmpty() code a little.
if(gnu.isEmpty()) {
System.out.println("empty, buttons should be disabled here, they will be disabled after this ");
double valueToSet = 0.0;
spinner.getValueFactory().setValue(valueToSet);
Platform.runLater(() -> spinner.getEditor().setText(Double.toString(valueToSet)));
return;
}
Another thing is, that your code allows to put '0' as a first number, even if there are another numbers after. Check that code, should fix the problem (swap it with the whole if/else statement starting with if(!gnu.matches("^\\d*\\.?\\d*$"))):
if (!isDouble(gnu)) {
gnu = old;
}
spinner.getEditor().setText(gnu);
Where isDouble is a method:
private boolean isDouble(String string) {
boolean startsWithZero =
string.startsWith("0") &&
(string.length() > 1) &&
(!string.startsWith("0."));
boolean minusZeroCondition =
string.startsWith("-0") &&
(string.length() > 2) &&
(!string.startsWith("-0."));
boolean containsTypeSpecificLetters =
Pattern.matches(".*[a-zA-Z].*", string);
boolean isEmpty = string.equals("");
boolean isMinus = string.equals("-");
try {
Double.parseDouble(string);
return !(startsWithZero || minusZeroCondition || containsTypeSpecificLetters);
} catch (IllegalArgumentException exception) {
return isEmpty || isMinus;
}
}
I have a JavaFX TreeView with an invisible root and a handful of 'folder' TreeItems that have many 'file' TreeItems as children. The 'folder' TreeItems typically fit inside the TreeView without there being any scrollbars.
invisible-root/
folder/
folder/
folder/
file
file
file
...
file
Sometimes, when I expand a 'folder' TreeItem, the scrollbars appear but the scroll position remains the same. (This is what I want!) However, sometimes, expanding a TreeItem causes the scrollbars appear and the TableView scrolls to the last child of the expanded TreeItem!
This is very unexpected and surprising, especially since I have difficulty predicting which of the two behaviors I will see: (1) stay put, or (2) scroll to last item. Personally, I think behavior (1) is less surprising and preferable.
Any thoughts on how to deal with this?
I see this behavior on Java8u31.
The problem is in VirtualFlow. In layoutChildren() there is this section:
if (lastCellCount != cellCount) {
// The cell count has changed. We want to keep the viewport
// stable if possible. If position was 0 or 1, we want to keep
// the position in the same place. If the new cell count is >=
// the currentIndex, then we will adjust the position to be 1.
// Otherwise, our goal is to leave the index of the cell at the
// top consistent, with the same translation etc.
if (position == 0 || position == 1) {
// Update the item count
// setItemCount(cellCount);
} else if (currentIndex >= cellCount) {
setPosition(1.0f);
// setItemCount(cellCount);
} else if (firstCell != null) {
double firstCellOffset = getCellPosition(firstCell);
int firstCellIndex = getCellIndex(firstCell);
// setItemCount(cellCount);
adjustPositionToIndex(firstCellIndex);
double viewportTopToCellTop = -computeOffsetForCell(firstCellIndex);
adjustByPixelAmount(viewportTopToCellTop - firstCellOffset);
}
The problem arises if position is 1.0 (== scrolled to bottom), because in that case there is no recalculation. A workaround would be to override the TreeViewSkin to provide your own VirtualFlow and fix the behavior there.
The code below is meant to illustrate the problem, it's not a real solution, just a starting point if you really want to fix it:
import com.sun.javafx.scene.control.skin.TreeViewSkin;
import com.sun.javafx.scene.control.skin.VirtualFlow;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.IndexedCell;
import javafx.scene.control.Skin;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class TreeViewScrollBehaviour extends Application {
#Override
public void start(Stage primaryStage) {
TreeView treeView = new TreeView() {
#Override
protected Skin createDefaultSkin() {
return new TTreeViewSkin(this); //To change body of generated methods, choose Tools | Templates.
}
};
TreeItem<String> treeItem = new TreeItem<String>("Root");
for (int i = 0; i < 20; i++) {
TreeItem<String> treeItem1 = new TreeItem<>("second layer " + i);
treeItem.getChildren().add(treeItem1);
for (int j = 0; j < 20; j++) {
treeItem1.getChildren().add(new TreeItem<>("Third Layer " + j));
}
}
treeView.setRoot(treeItem);
StackPane root = new StackPane();
root.getChildren().addAll(treeView);
Scene scene = new Scene(root, 300, 250);
primaryStage.setTitle("Hello World!");
primaryStage.setScene(scene);
primaryStage.show();
}
/**
* #param args the command line arguments
*/
public static void main(String[] args) {
launch(args);
}
class TTreeViewSkin<T extends IndexedCell> extends TreeViewSkin<T> {
public TTreeViewSkin(TreeView treeView) {
super(treeView);
}
#Override
protected VirtualFlow createVirtualFlow() {
return new TVirtualFlow<T>(); //To change body of generated methods, choose Tools | Templates.
}
}
class TVirtualFlow<T extends IndexedCell> extends VirtualFlow<T> {
#Override
public double getPosition() {
double position = super.getPosition();
if (position == 1.0d) {
return 0.99999999999;
}
return super.getPosition(); //To change body of generated methods, choose Tools | Templates.
}
#Override
public void setPosition(double newPosition) {
if (newPosition == 1.0d) {
newPosition = 0.99999999999;
}
super.setPosition(newPosition); //To change body of generated methods, choose Tools | Templates.
}
}
}
When you create a TextArea, you can listen to its "focusedProperty".
But if the user touch the inner scrollBar of the TextArea (if it's too small), the focus of the TextArea is lost (since the scrollBar has the focus).
But as far as I am concerned, the TextArea is still having the focus because the scrollBar are part or the TextArea and there's even no way of accessing them.
How can I hack the textArea so that I would detect when the user is using the scrollBar? I want to hack/create a focusedProperty that will return true when the user is typing text or using the scrollBar.
Observe the Scene's focusOwner property, and create a BooleanBinding that is true if it is a descendant of the text area and false otherwise:
import java.util.stream.IntStream;
import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class TextAreaFocusTest extends Application {
#Override
public void start(Stage primaryStage) {
TextArea textArea = new TextArea();
IntStream.rangeClosed(1, 200).forEach(i -> textArea.appendText(" "));
IntStream.rangeClosed(1, 80).forEach(i -> textArea.appendText("\nLine "+i));
Label label = new Label();
TextField textField = new TextField();
VBox root = new VBox(10, textArea, textField, label);
Scene scene = new Scene(root, 400, 400);
BooleanBinding focus = Bindings.createBooleanBinding(() -> {
for (Node n = scene.getFocusOwner(); n!= null ; n=n.getParent()) {
if (n == textArea) return true ;
}
return false ;
}, scene.focusOwnerProperty());
label.textProperty().bind(Bindings.when(focus).then("Focused").otherwise("Not Focused"));
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Here is a variation on #James_D's answer, in case you need to be able to obtain the focus binding from his answer without having a reference to the scene, e.g. if you need to set up the bindings before the text area is added to the scene, are implementing a library, or just want to have your code less entangled.
This solution uses the EasyBind library for convenient selection of nested property (selecting focusOwnerProperty from the sceneProperty).
public static Binding<Boolean> containsFocus(Node node) {
return EasyBind.monadic(node.sceneProperty())
.flatMap(Scene::focusOwnerProperty)
.map(owner -> {
for (Node n = owner; n != null; n = n.getParent()) {
if (n == node) return true ;
}
return false ;
})
.orElse(false); // when node.getScene() is null
}