Harry
Harry

Reputation: 2961

JavaFx Group contents position getting changed upon transformation

I'm simulating a compass where I need to display current angular position upon reception of angular data from another source(via network). But somehow the circle group is getting shifted upon applying transformation to Text node based upon angular position. This is a sample code

MainApp.java

import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import javafx.scene.text.Text;
import javafx.scene.transform.Rotate;
import javafx.stage.Screen;
import javafx.stage.Stage;

public class MainApp extends Application {
    static BorderPane borderPane;
    static Group clock;
    static Group angStatus;
    public static Circle clockCircle;
    public static Line angHand;
    public static Text angText;
    public static Rotate angRotate;
    public static Label angLabel;

    public static void main(String[] args) {
        borderPane = new BorderPane();

        clock = new Group();
        angStatus = new Group();

        clockCircle = new Circle();
        clockCircle.centerXProperty().bind(borderPane.layoutXProperty());
        clockCircle.centerYProperty().bind(borderPane.layoutYProperty());
        clockCircle.setRadius(360);
        clockCircle.setFill(Color.RED);

        angHand = new Line();
        angHand.startXProperty().bind(clockCircle.centerXProperty());
        angHand.startYProperty().bind(clockCircle.centerYProperty());

        angText = new Text();

        angRotate = new Rotate();
        angRotate.pivotXProperty().bind(angText.xProperty());
        angRotate.pivotYProperty().bind(angText.yProperty());
        angText.getTransforms().addAll(angRotate);

        angLabel = new Label();
        angLabel.layoutXProperty().bind(borderPane.layoutXProperty());
        angLabel.layoutYProperty().bind(borderPane.layoutYProperty());

        clock.getChildren().addAll(clockCircle, angHand, angText);
        angStatus.getChildren().addAll(angLabel);

        borderPane.setCenter(clock);
        borderPane.setBottom(angStatus);

        DataReceiver objDataReceiver = new DataReceiver();
        Thread dataRecvThread = new Thread(objDataReceiver, "DATARECVR");

        dataRecvThread.start();

        launch(args);
    }

    @Override
    public void start(Stage primaryStage) {
        primaryStage.setTitle("CLOCK");
        primaryStage.setMaximized(true);
        primaryStage.setScene(new Scene(borderPane, Screen.getPrimary().getVisualBounds().getWidth(), Screen.getPrimary().getVisualBounds().getHeight()));
        primaryStage.show();
    }
}

DataReceiver.java

import javafx.application.Platform;

import java.time.Instant;

import static java.lang.Thread.sleep;

public class DataReceiver implements Runnable {
    public static int deg;

    DataReceiver() {
        deg = 0;
    }


    public void run() {
        while(true) {
            long startTime = Instant.now().toEpochMilli();

            deg++;
            while(deg >= 360)
                deg -= 360;
            while(deg < 0)
                deg += 360;

            long endTime = Instant.now().toEpochMilli();

            Platform.runLater(() -> {
                MainApp.angHand.endXProperty().bind(MainApp.clockCircle.radiusProperty().multiply(Math.sin(Math.toRadians(deg))));
                MainApp.angHand.endYProperty().bind(MainApp.clockCircle.radiusProperty().multiply(-Math.cos(Math.toRadians(deg))));

                MainApp.angText.xProperty().bind(MainApp.angHand.endXProperty().add(10 * Math.sin(Math.toRadians(deg))));
                MainApp.angText.yProperty().bind(MainApp.angHand.endYProperty().subtract(10 * Math.cos(Math.toRadians(deg))));
                MainApp.angText.setText(String.valueOf(deg));

                MainApp.angRotate.setAngle(deg);
                MainApp.angLabel.setText("Angle: " + deg);
            });

            try {
                sleep(1000 - (endTime - startTime));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

I'm using jdk-13.0.1 and javafx-sdk-11.0.2

Upvotes: 1

Views: 527

Answers (1)

fabian
fabian

Reputation: 82461

The problem here is the fact that Group itself is non-resizeable, but changes it's own size to fit the contents. Modifying the transformations of the text results in the bounds of the Group changing horizontally and/or vertically which in turn makes the parent layout reposition the group to keep it centered. Depending on your needs you may want wrap the clock in a parent not doing this (Pane). If you want to keep the center of the circle centered, you'll probably need to implement your own layout.

Notes:

  • I don't recommend doing this using a Thread. Instead do this using AnimationTimer or similar utilities provided by JavaFX.
  • Bindings are only necessary for dynamic updates. If you just want to set values, it's the wrong choice.
  • There is the remainder operator (%) which could deal with the logic of one of the while loops. Furthermore the second loop is not actually needed, since the value of deg is never decreased below 0.
  • I wouldn't recommend putting the logic using threads in the main method. The way you implement the logic could result in an exception, if Platform.runLater is called before the toolkit has been started. Better initialize this kind of logic from Application.start.
  • static should be avoided, if possible, since it makes controlling the flow of data more complicated. And what if you want to display 2 clocks for different timezones? There's no way of reusing the class, if it relies on static data in the way your classes do.
  • A binding like the following makes no sense: borderPane is the root of the scene and therefore will keep the position (0,0); furthermore angLabel is a descendant of the borderPane. I recommend not wrapping the label in a group any use the static BorderPane.alignment property to tell borderPane how to position the node.

    angLabel.layoutXProperty().bind(borderPane.layoutXProperty());
    angLabel.layoutYProperty().bind(borderPane.layoutYProperty());
    

The following example makes Clock a Control with a property of type LocalTime and calculates the positions of the children itself:

public class Clock extends Control {

    private final ObjectProperty<LocalTime> time = new SimpleObjectProperty<>(this, "time", LocalTime.MIDNIGHT) {

        @Override
        public void set(LocalTime newValue) {
            // make sure the value is non-null
            super.set(newValue == null ? LocalTime.MIDNIGHT : newValue);
        }

    };

    public ObjectProperty<LocalTime> timeProperty() {
        return time;
    }

    public LocalTime getTime() {
        return time.get();
    }

    public void setTime(LocalTime value) {
        time.set(value);
    }

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

}
public class ClockSkin extends SkinBase<Clock> {

    private static final double SPACE = 20;
    private static final double FACTOR = 360d / 60;

    private final Circle face;
    private final Line secondsHand;
    private final Rotate rotate;
    private final Text secondsText;

    public ClockSkin(Clock control) {
        super(control);
        face = new Circle(360, Color.RED);


        // line straight up from center to circle border
        secondsHand = new Line();
        secondsHand.endXProperty().bind(face.centerXProperty());
        secondsHand.endYProperty().bind(face.centerYProperty().subtract(face.getRadius()));
        secondsHand.startXProperty().bind(face.centerXProperty());
        secondsHand.startYProperty().bind(face.centerYProperty());

        secondsText = new Text();

        rotate = new Rotate();
        rotate.pivotXProperty().bind(face.centerXProperty());
        rotate.pivotYProperty().bind(face.centerYProperty());

        secondsHand.getTransforms().add(rotate);
        secondsText.getTransforms().add(rotate);

        registerChangeListener(control.timeProperty(), (observable) -> {
            LocalTime value = (LocalTime) observable.getValue();
            update(value);
            control.requestLayout();
        });
        getChildren().addAll(face, secondsHand, secondsText);
        update(control.getTime());
    }

    protected void update(LocalTime time) {
        int seconds = time.getSecond();
        secondsText.setText(Integer.toString(seconds));
        rotate.setAngle(seconds * FACTOR);
    }

    @Override
    protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) {
        // center face
        face.setCenterX(contentX + SPACE + contentWidth / 2);
        face.setCenterY(contentY + SPACE + contentHeight / 2);

        // position text
        secondsText.setX(contentX + SPACE + (contentWidth - secondsText.prefWidth(-1)) / 2);
        secondsText.setY(face.getCenterY() - face.getRadius() - SPACE / 2);
    }

    @Override
    protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset,
            double leftInset) {
        return 2 * (SPACE + face.getRadius()) + leftInset + rightInset;
    }

    @Override
    protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset,
            double leftInset) {
        return 2 * (SPACE + face.getRadius()) + topInset + bottomInset;
    }

    @Override
    protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset,
            double leftInset) {
        return computeMinWidth(height, topInset, rightInset, bottomInset, leftInset);
    }

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

    @Override
    public void dispose() {
        getChildren().clear();
        super.dispose();
    }

}
@Override
public void start(Stage primaryStage) throws Exception {
    BorderPane borderPane = new BorderPane();

    Clock clock = new Clock();
    clock.setTime(LocalTime.now());

    Label angLabel = new Label();
    BorderPane.setAlignment(angLabel, Pos.TOP_LEFT);

    borderPane.setCenter(clock);
    borderPane.setBottom(angLabel);

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

    AnimationTimer timer = new AnimationTimer() {

        @Override
        public void handle(long now) {
            LocalTime time = LocalTime.now();
            clock.setTime(time);
            angLabel.setText(formatter.format(time));
        }

    };
    timer.start();

    primaryStage.setTitle("CLOCK");
    primaryStage.setMaximized(true);
    primaryStage.setScene(new Scene(borderPane));
    primaryStage.show();
}

Note that here the separation between the logic for updating the visuals and updating the data is done much more cleanly than in your code. In general you want to prevent access of other classes to internal logic, since this prevents outside interference that could possibly break your control.

Upvotes: 2

Related Questions