java – How to force layout update of a JavaFX XYChart

Questions:

I’m trying to force the update of a custom XYChart in a timer method, but the only thing that seems to work is resizing the window.

import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.chart.LineChart;
import javafx.scene.chart.NumberAxis;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.scene.transform.Affine;
import javafx.scene.transform.Transform;
import javafx.stage.Stage;

import java.util.*;
import java.util.stream.Collectors;

public class TestCustomLayoutUpdate extends Application {

    private LineChart<Number, Number> chart;
    private NumberAxis xAxis;
    private NumberAxis yAxis;

    private ShopItem currentShopItem;

    class ShopItem {
        private double price;

        public ShopItem(double price) {
            this.price = price;
        }
    }

    @Override
    public void start(Stage primaryStage) {

        createChart();

        Scene scene = new Scene(chart, 600, 400);
        primaryStage.setScene(scene);
        primaryStage.setHeight(600);
        primaryStage.setWidth(400);
        primaryStage.show();

        Random rng = new Random();

        // Note since this is a regular timer not javafx timer that we should use platform run later.
        TimerTask repeatedTask = new TimerTask() {
            public void run() {
                currentShopItem = new ShopItem(rng.nextDouble() * 100);
                Platform.runLater(() -> {
                    chart.layout();
                    chart.requestLayout();
                    xAxis.layout();
                });
            }
        };
        Timer timer = new Timer("Timer");

        long delay  = 1000L;
        long period = 1000L;
        timer.scheduleAtFixedRate(repeatedTask, delay, period);
    }

    public void createChart() {
        xAxis = new NumberAxis();
        yAxis = new NumberAxis();
        xAxis.setAutoRanging(false);
        xAxis.setUpperBound(100);

        chart = new LineChart<Number, Number>(xAxis, yAxis) {
            private List<Shape> shapes = new ArrayList<>();

            @Override
            public void layoutPlotChildren() {
                super.layoutPlotChildren();

                getPlotChildren().removeAll(shapes);
                shapes.clear();

                if (currentShopItem != null) {
                    Rectangle rect = new Rectangle(0, 0, 10, currentShopItem.price);

                    rect.getTransforms().setAll(chartDisplayTransform(xAxis, yAxis));
                    rect.setFill(Color.RED);
                    shapes.add(rect);
                    getPlotChildren().addAll(shapes);
                }
            }
        };
    }

    private Transform chartDisplayTransform(NumberAxis xAxis, NumberAxis yAxis) {
        return new Affine(xAxis.getScale(), 0, xAxis.getDisplayPosition(0), 0, yAxis.getScale(),
                yAxis.getDisplayPosition(0));
    }

    public static void main(String[] args) {
        Application.launch(args);
    }
}
How to&Answers:

JavaFX will automatically layout and redraw any parts of the scene graph if properties of any of the nodes that are part of the graph change. The problem with
the way you have structured the code is that you only change the scene graph (change the dimensions of the rectangle and/or change the plot children of the chart) in the layoutPlotChildren() method, which (I believe) is called as part of the layout process. So when you request a layout, JavaFX checks to see if anything in the scene graph has changed, sees that it hasn’t, and so doesn’t perform a layout. Thus layoutPlotChildren() isn’t called, and so the scene graph isn’t changed…

So to fix this, you just need to make sure the existing rectangle is updated, or that the list of plot children change, when the underlying data change. You can accomplish this by using JavaFX properties, and observing them from your chart subclass. (There are other ways too, I suppose, such as defining a method in the chart subclass that updates the rectangle, and invoking it from the animation loop. But observing a JavaFX property is the API-preferred way to do this.)

As an aside, if you want to change anything periodically that updates graphics, the preferred way to do this in JavaFX is with a Timeline, which operates entirely on the JavaFX thread and avoids the need to think about synchronization of variables, etc.

Here’s a version of your example with these changes, which works as desired:

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Scene;
import javafx.scene.chart.LineChart;
import javafx.scene.chart.NumberAxis;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.scene.transform.Affine;
import javafx.scene.transform.Transform;
import javafx.stage.Stage;
import javafx.util.Duration;

public class TestCustomLayoutUpdate extends Application {

    private LineChart<Number, Number> chart;
    private NumberAxis xAxis;
    private NumberAxis yAxis;


    private ObjectProperty<ShopItem> currentShopItem;

    class ShopItem {
        private double price;

        public ShopItem(double price) {
            this.price = price;
        }
    }

    @Override
    public void start(Stage primaryStage) {

        currentShopItem = new SimpleObjectProperty<>();


        createChart();

        Scene scene = new Scene(chart, 600, 400);
        primaryStage.setScene(scene);
        primaryStage.setHeight(600);
        primaryStage.setWidth(400);
        primaryStage.show();

        Random rng = new Random();

        Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(1),  
                evt -> currentShopItem.set(new ShopItem(rng.nextDouble() * 100))
        ));
        timeline.setCycleCount(Animation.INDEFINITE);
        timeline.play();
    }

    public void createChart() {
        xAxis = new NumberAxis();
        yAxis = new NumberAxis();
        xAxis.setAutoRanging(false);
        xAxis.setUpperBound(100);

        chart = new LineChart<Number, Number>(xAxis, yAxis) {

            private List<Shape> shapes = new ArrayList<>();

            private Rectangle rect ;

            // anonymous class constructor:
            {
                rect = new Rectangle(0,0, Color.RED);

                currentShopItem.addListener((obs, oldItem, newItem) -> {
                    if (newItem == null) {
                        rect.setWidth(0);
                        rect.setHeight(0);
                    } else {
                        rect.setWidth(10);
                        rect.setHeight(newItem.price);
                    }

                });
            }

            @Override
            public void layoutPlotChildren() {
                super.layoutPlotChildren();

                getPlotChildren().removeAll(shapes);
                shapes.clear();

                if (currentShopItem != null) {
                    rect.getTransforms().setAll(chartDisplayTransform(xAxis, yAxis));
                    shapes.add(rect);
                    getPlotChildren().addAll(shapes);
                }
            }
        };
    }

    private Transform chartDisplayTransform(NumberAxis xAxis, NumberAxis yAxis) {
        return new Affine(xAxis.getScale(), 0, xAxis.getDisplayPosition(0), 0, yAxis.getScale(),
                yAxis.getDisplayPosition(0));
    }

    public static void main(String[] args) {
        Application.launch(args);
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *