Issue
Im working on a small JavaFX project. In one of my scenes, I want to dynamically add and remove a custom component I implemented, which extends from TitledPane, to respectively from an Accordion. This works all well and good, but after removing the pane from the Accordion the damn thing won't resize immidately, but only after I click somewhere on the gui. I prepared the following GIF to visualize the problem to you.
Can someone tell me why the accordion only resizes after I clicked somewhere on the gui interface and not immidiately? I mean auto resizing doesn't seem to be a problem, it just won't trigger...can someone tell me why that is the case? Maybe this is obvious, but I am not very familiar with JavaFX, so I am really stuck here. I also observed a similar behavior with other component, so maybe I am missing something fundamentally here.
UPDATE
Ok I created a minimal example for you to reproduce my problem. You can clone the repository on GitHub javafx-demo and try it out yourself. Doing this I noticed, that the Accordion resizes only if I click on it and not when I click anywhere else on the gui.
UPDATE 1
I simplified the example further. You can find the example in the GitHub repository above or see the code below:
App
public class App extends Application {
@Override
public void start(Stage stage) throws IOException {
FXMLLoader fxmlLoader = new FXMLLoader(App.class.getResource("parentView.fxml"));
Scene scene = new Scene(fxmlLoader.load(), 640, 480);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
}
ParentController
public class ParentController {
@FXML
private Accordion accordion;
public void onAddAction() {
var itemControl = new ItemControl();
EventHandler<ActionEvent> removeEventHandler = event -> {
accordion.getPanes().remove(itemControl);
};
itemControl.setOnRemoveProperty(removeEventHandler);
accordion.getPanes().add(itemControl);
}
}
Parent View
<StackPane xmlns="http://javafx.com/javafx/16"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="org.example.ParentController">
<Group StackPane.alignment="CENTER">
<VBox>
<Accordion fx:id="accordion"/>
<Button onAction="#onAddAction" text="Add"/>
</VBox>
</Group>
</StackPane>
ItemControl
public class ItemControl extends TitledPane {
private final UUID id = UUID.randomUUID();
private final ObjectProperty<EventHandler<ActionEvent>> onRemoveProperty = new SimpleObjectProperty<>();
@FXML
private Button removeButton;
public ItemControl() {
FXMLLoader fxmlLoader = new FXMLLoader(ItemControl.class.getResource("itemControl.fxml"));
fxmlLoader.setRoot(this);
fxmlLoader.setController(this);
try {
fxmlLoader.load();
} catch (IOException e) {
e.printStackTrace();
}
}
@FXML
public void initialize() {
removeButton.onActionProperty().bind(onRemoveProperty);
}
public void setOnRemoveProperty(EventHandler<ActionEvent> onRemoveProperty) {
this.onRemoveProperty.set(onRemoveProperty);
}
// equals and hashCode omitted for brevity (id instance variable is used as identifier)
}
ItemControl FXML
<fx:root type="javafx.scene.control.TitledPane" xmlns="http://javafx.com/javafx/16" xmlns:fx="http://javafx.com/fxml/1">
<VBox>
<Button fx:id="removeButton" text="Remove"/>
</VBox>
</fx:root>
Solution
The behavior is a bug in AccordionSkin. The technical reason is that it keeps internal references to the current and previous expanded pane - both used in calculating the min/pref height - which are not updated correctly on removing the expanded pane. A fix would be to null those references if the panes are no longer part of the accordion, f.i. from the skin's listener to the panes list.
There is no clean way to work around this because all involved fields/methods are private - if we are allowed to go dirty, though, we can hack around the bug with reflection.
The basics:
- extend AccordionSkin and let our accordion use the extended version
- in the skin, override both computeMin/Pref/Height to check/fix the references before returning super
- check: the panes should be contained in the accordion's panes
- fix: if not, set the reference to null
Notes:
- the reflective access to internal fields requires that the package is opened at runtime
- the usual beware: tweaking/relying on implementation internals is highly version dependent and might/will break eventually
- FXUtils is my local utility class for reflection, you have to replace it with your own implementation
The code:
public class SimpleLayoutAccordionOnRemove extends Application {
/**
* AccordionSkin that hacks the broken layout after remove of expanded pane.
*/
public static class HackedAccordionSkin extends AccordionSkin {
public HackedAccordionSkin(Accordion control) {
super(control);
}
@Override
protected double computeMinHeight(double width, double topInset, double rightInset,
double bottomInset, double leftInset) {
checkPaneFields();
return super.computeMinHeight(width, topInset, rightInset, bottomInset, leftInset);
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset,
double bottomInset, double leftInset) {
checkPaneFields();
return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
}
private void checkPaneFields() {
checkPaneField("previousPane");
checkPaneField("expandedPane");
}
/**
* Check if the pane referenced by the field with the given name is contained
* in the accordion's panes and sets it to null if not.
*/
private void checkPaneField(String fieldName) {
TitledPane prev = (TitledPane) FXUtils.invokeGetFieldValue(AccordionSkin.class, this, fieldName);
if (!getSkinnable().getPanes().contains(prev)) {
FXUtils.invokeSetFieldValue(AccordionSkin.class, this, fieldName, null);
}
}
}
private Parent createContent() {
Accordion accordion = new Accordion() {
@Override
protected Skin<?> createDefaultSkin() {
return new HackedAccordionSkin(this);
}
};
Button add = new Button("add");
add.setOnAction(e -> {
addTitledPane(accordion);
});
VBox accBox = new VBox(accordion, add);
StackPane content = new StackPane(new Group(accBox));
return content;
}
int count;
private void addTitledPane(Accordion accordion) {
TitledPane pane = new TitledPane();
pane.setText("Pane " + count++);
Button remove = new Button("remove");
remove.setOnAction(e -> {
accordion.getPanes().remove(pane);
});
VBox box = new VBox(remove);
pane.setContent(box);
accordion.getPanes().add(pane);
}
@Override
public void start(Stage stage) throws Exception {
stage.setScene(new Scene(createContent(), 600, 400));
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Answered By - kleopatra
Answer Checked By - Clifford M. (JavaFixing Volunteer)