Search code examples
javafxregionpane

why this setWidth() method of Pane class in JavaFX enlarges my shape?


Edit: My code works totally fine if I use StackPane instead of Pane in the start method. like

public void start(Stage primaryStage) {
StackPane pane = new StackPane();

so the problem lies with the Pane class?

I created a custom pane class that extends JavaFX Pane class and added a setWidth() method to resize its width.

I want to place a circle at the center of the stage and resize my shape when I use my mouse to drag and resize the window. But the result is weird.

  1. At first, it is a tiny point at the top-left corner. Should it be nothing at all? I think the circle as an instance of MyPane should have a width and height both as 0.
  2. No matter you enlarge or reduce the window's size, the circle continuously becomes bigger.

Initialization

the circle is getting larger

can someone help me understand the mechanism within this process?

Thank you in advance.

Here is my code.

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.stage.Stage;

public class Exercise_14_11 extends Application {
  public void start(Stage primaryStage) {
    Pane pane = new Pane();

    MyPane circle = new MyPane();
    pane.getChildren().add(circle);
    Scene scene = new Scene(pane, 400, 400);
    primaryStage.setTitle("Exercise_14_11");
    primaryStage.setScene(scene);
    primaryStage.show();

  }
  public static void main(String[] args) {
    launch(args);
  }
}
class MyPane extends Pane {
  public MyPane() {
    paint();
  }
  public void paint() {
    double centerX = getWidth() / 2;
    double centerY = getHeight() / 2;
    double radius = centerX;
    
    Circle face = new Circle(centerX, centerY, radius);
    face.setFill(Color.WHITE);
    face.setStroke(Color.BLACK);

    getChildren().clear();
    getChildren().addAll(face);
  }

  @Override
  public void setWidth(double width) {
  super.setWidth(width);
    paint();
  }

I have read Pane's document and Region's document https://docs.oracle.com/javase/8/javafx/api/javafx/scene/layout/Region.html, but still don't have a hint.

I know property binding can achieve my goal. I am more interested in the process of handling the width of a pane.


Solution

  • In the JavaFX layout system, a Pane

    resiz[es] resizable children to their preferred sizes

    So your root pane (pane) will resize your custom pane (circle) to its preferred size. Since you don't override the calculation of the preferred size, the preferred width and height (see the "Resizable Range" section of the documentation are calculated as the

    width/height required to encompass each child at its current x location and preferred width/height

    For a Shape (such as Circle), the preferred width/height will be computed as its layout bounds, which are the geometric bounds ("geometry plus stroke").

    The behavior you observe happens because you do not account for the stroke in calculating the radius of the circle.

    Basically, when paint() is called, you set the radius to half the width. The layout bounds of the circle are double the radius (the "geometry") plus the stroke, which will be the width of the pane plus the width of the stroke (i.e., by default, one pixel more than the width of the pane).

    The next time the pane (circle) is laid out by its parent (pane), its width is increased by one pixel to accommodate the layout bounds of the circle. This is accomplished by an internal call, at some point, to setWidth(..), which increases the width, causing the circle's radius to increase again, and then the next time the pane is laid out, the pane increases its size again as its preferred width has increased. So every time the pane is laid out, it increases in size.

    This simply isn't the way to subclass Pane. Any time you subclass a library class, you must do so in a way that is documented. Inheritance is essentially incompatible with encapsulation, so any time you create a subclass you risk interfering with the superclass' functionality.

    Indeed, the documentation for a pane's width (inherited from Region) explicitly says

    This property is set by the region's parent during layout and may not be set by the application.

    Therefore, you must not set it, and if you do, the results may (as you have discovered) be unpredictable.

    I normally advocate not subclassing JavaFX library classes, except for the cell classes in virtualized controls (which are specifically designed for extension). The one exception to this is when you want to manage the layout of non-resizable nodes (Shape, ImageView, and MediaView). So this might be a case where subclassing Pane or Region is appropriate.

    If you do so, you should do so in the manner described in the documentation (here the documentation for Region:

    To implement a more custom layout, a Region subclass must override computePrefWidth, computePrefHeight, and layoutChildren. Note that layoutChildren is called automatically by the scene graph while executing a top-down layout pass and it should not be invoked directly by the region subclass.

    So you can do something like

    class MyPane extends Region {
    
        private static final double MIN_FACE_RADIUS = 5 ;
        private static final double PREF_FACE_RADIUS = 20 ;
    
        private final Circle face;
        public MyPane() {
            face = new Circle();
            face.setFill(Color.WHITE);
            face.setStroke(Color.BLACK);
            getChildren().setAll(face);
        }
    
        @Override
        protected void layoutChildren() {
            double x = snappedLeftInset();
            double y = snappedTopInset();
            double availableWidth = getWidth() - x - snappedRightInset();
            double availableHeight = getHeight() - y - snappedBottomInset();
            face.setCenterX(x + availableWidth / 2);
            face.setCenterY(y + availableHeight / 2);
            face.setRadius((availableWidth - face.getStrokeWidth()) / 2);
        }
    
        @Override
        protected double computePrefWidth(double height) {
            return 2 * (PREF_FACE_RADIUS + face.getStrokeWidth());
        }
    
        @Override
        protected double computePrefHeight(double width) {
            return 2 * (PREF_FACE_RADIUS + face.getStrokeWidth());
        }
    
        @Override
        protected double computeMinWidth(double height) {
            return 2 * (MIN_FACE_RADIUS + face.getStrokeWidth());
        }
    
        @Override
        protected double computeMinHeight(double width) {
            return 2 * (MIN_FACE_RADIUS + face.getStrokeWidth());
        }
    
        @Override
        protected double computeMaxWidth(double height) {
            return Double.MAX_VALUE;
        }
    
        @Override
        protected double computeMaxHeight(double width) {
            return Double.MAX_VALUE;
        }
    }
    

    This does things the "right way around": the minimum, preferred (and, trivially, maximum) size of the custom region are determined by the minimum and preferred size of the circle, and the circle is (correctly) sized for the width and height allocated to the region.

    Now, putting this region in a plain pane will simply always size it to its preferred size, so you always end up with a circle of radius 20 (or less, if there is not enough room). To allow the pane containing the circle to grow, you should put it in a layout pane that allocates more space to it. Either use a StackPane, which (again, read the documentation)

    or in the center of a BorderPane, so it

    will be resized to fill the available space in the middle

    Complete example:

    
    import javafx.application.Application;
    import javafx.scene.Scene;
    import javafx.scene.layout.BorderPane;
    import javafx.scene.layout.Region;
    import javafx.scene.paint.Color;
    import javafx.scene.shape.Circle;
    import javafx.stage.Stage;
    
    public class Exercise_14_11 extends Application {
        public void start(Stage primaryStage) {
            BorderPane pane = new BorderPane();
    
            MyPane circle = new MyPane();
            pane.setCenter(circle);
            Scene scene = new Scene(pane, 400, 400);
            primaryStage.setTitle("Exercise_14_11");
            primaryStage.setScene(scene);
            primaryStage.show();
    
        }
        public static void main(String[] args) {
            launch(args);
        }
    }
    class MyPane extends Region {
    
        private static final double MIN_FACE_RADIUS = 5 ;
        private static final double PREF_FACE_RADIUS = 20 ;
    
        private final Circle face;
        public MyPane() {
            face = new Circle();
            face.setFill(Color.WHITE);
            face.setStroke(Color.BLACK);
            getChildren().setAll(face);
        }
    
        @Override
        protected void layoutChildren() {
            double x = snappedLeftInset();
            double y = snappedTopInset();
            double availableWidth = getWidth() - x - snappedRightInset();
            double availableHeight = getHeight() - y - snappedBottomInset();
            face.setCenterX(x + availableWidth / 2);
            face.setCenterY(y + availableHeight / 2);
            face.setRadius((availableWidth - face.getStrokeWidth()) / 2);
        }
    
        @Override
        protected double computePrefWidth(double height) {
            return 2 * (PREF_FACE_RADIUS + face.getStrokeWidth());
        }
    
        @Override
        protected double computePrefHeight(double width) {
            return 2 * (PREF_FACE_RADIUS + face.getStrokeWidth());
        }
    
        @Override
        protected double computeMinWidth(double height) {
            return 2 * (MIN_FACE_RADIUS + face.getStrokeWidth());
        }
    
        @Override
        protected double computeMinHeight(double width) {
            return 2 * (MIN_FACE_RADIUS + face.getStrokeWidth());
        }
    
        @Override
        protected double computeMaxWidth(double height) {
            return Double.MAX_VALUE;
        }
    
        @Override
        protected double computeMaxHeight(double width) {
            return Double.MAX_VALUE;
        }
    }
    

    will attempt to resize each child to fill its content area