Why custom control doesn't resize with its children?

94 views Asked by At

Consider the following minimal reproducible example:

public class Launcher extends Application {

    public static final double SCENE_SIZE = 500;

    @Override
    public void start(Stage primaryStage) {
        GridPane grid = new GridPane();
        grid.setHgap(10);
        grid.setVgap(10);
        grid.setGridLinesVisible(true);
        grid.getColumnConstraints().setAll(
                new ColumnConstraints(USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, Priority.NEVER, HPos.LEFT, false),
                new ColumnConstraints(USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, Priority.ALWAYS, HPos.LEFT, false)
        );
        grid.getRowConstraints().setAll(
                new RowConstraints(USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, Priority.NEVER, VPos.TOP, false),
                new RowConstraints(USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, Priority.NEVER, VPos.TOP, false),
                new RowConstraints(USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, USE_COMPUTED_SIZE, Priority.NEVER, VPos.TOP, false)
        );

        CustomControl customControl = new CustomControl("Lorem Ipsum ".repeat(10));
        customControl.prefWidthProperty().bind(grid.widthProperty());

        TextFlow textFlow = new TextFlow(new Text("Lorem Ipsum ".repeat(10)));
        textFlow.prefWidthProperty().bind(grid.widthProperty());

        grid.add(new Label("0"), 0, 0);
        grid.add(customControl, 1, 0);
        grid.add(new Label("1"), 0, 1);
        grid.add(textFlow, 1, 1);
        grid.add(new Label("2"), 0, 2);
        grid.add(new Label("..."), 1, 2);

        Scene scene = new Scene(grid, SCENE_SIZE, SCENE_SIZE);

        primaryStage.setTitle("Demo");
        primaryStage.setScene(scene);
        primaryStage.setOnCloseRequest(t -> Platform.exit());
        primaryStage.show();
    }

    static class CustomControl extends Control {

        private final StringProperty text = new SimpleStringProperty();

        public CustomControl(String text) { setText(text); }

        public String getText() { return text.get(); }

        public StringProperty textProperty() { return text; }

        public void setText(String text) { this.text.set(text); }

        @Override
        protected Skin<?> createDefaultSkin() { return new CustomControlSkin(this); }
    }

    static class CustomControlSkin extends SkinBase<CustomControl> {

        TextFlow textFlow;

        public CustomControlSkin(CustomControl control) {
            super(control);

            textFlow = new TextFlow();
            textFlow.getChildren().setAll(new Text(control.getText()));
            control.textProperty().addListener(
                    (obs, old, value) -> textFlow.getChildren().setAll(new Text(control.getText()))
            );

            control.heightProperty().addListener((observable, oldValue, newValue) -> System.out.println("control = " + newValue));
            textFlow.heightProperty().addListener((observable, oldValue, newValue) -> { System.out.println("textFlow = " + newValue); });

            getChildren().setAll(textFlow);
        }
    }
}

Window resize triggers TextFlow word wrapping and therefore TextFlow changes its height. The problem is that it doesn't lead to CustomControl height change, it remains unchanged (notice console output).

At the same time "pure" TextFlow on the next grid row behaves exactly what expect from CustomControl. It changes its height (and GridPane row height as well) back and forth as window resizes.

I thought that any control changes its own height depending on the height of the children automatically (SkinBase contains lots of methods for that), but it seems I was wrong. Simply setting control height in the TextFlow listener won't help, because this way it can be only increased.

UPDATE:

Ok, I've found the problem. This happens because TextFlow needs actual width to calculate its preferred height, but SkinBase uses negative width value. Solution:

control.widthProperty().addListener((observable, oldValue, newValue) -> {
    control.setPrefHeight(textFlow.prefHeight(newValue.doubleValue()));
});

If there's a better way feel free to share :)

UPDATE 2:

Based on the @Slaw advice, this one working as well. Weird thing is that width arg value is always -1, so we have to get width value from skinnable.

static class CustomControlSkin extends SkinBase<CustomControl> {

    // ...

    @Override
    protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset,
                                       double leftInset) {
        return textFlow.prefHeight(getSkinnable().getWidth() != 0 ? getSkinnable().getWidth() : -1);
    }

    @Override
    protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset,
                                      double leftInset) {
        return computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
    }
}
1

There are 1 answers

3
Maxim On

Based on the @Slaw advice, here is one of possible solutions. Weird thing is that width arg value is always -1, so we have to get width value from skinnable.

static class CustomControlSkin extends SkinBase<CustomControl> {

    // ...

    @Override
    protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset,
                                       double leftInset) {
        return textFlow.prefHeight(getSkinnable().getWidth() != 0 ? getSkinnable().getWidth() : -1);
    }

    @Override
    protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset,
                                      double leftInset) {
        return computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
    }
}

Each time Control width changes it calls computeMaxHeight() which simply delegates calculation to the SkinBase. Then we just force Control to always use TextFlow pref height.