There seem to be two kinds of JavaFX applications: the first one is using a scene graph with nodes and CSS styling, and the second one is using a single canvas. However, it is perfectly legal to mix these two approaches. Especially when your application has to show a lot of detailed information where you would easily end up creating thousands and thousands of nodes. Even though the overall performance of JavaFX is fantastic you will most likely bring your system down to its knees when styling is required for all of these nodes (especially when styling is required over and over again because of the dynamic nature of your visualization).
For me it was an epiphany when I realized that the only way to guarantee high performance in FlexGanttFX was to use a ListView with each cell containing a canvas. Unfortunately the code of this framework is too complex to share it with you in a small blog, so I wrote a small example that illustrates the basic concepts. The image below shows the result when running the example. The data shown by the ListView covers the years of my life span with randomly generated values for each day of each year.
The most important class is called CanvasCell. It is a specialized list view cell containing a label and a canvas. The label is used to display the year, the canvas is used to draw the chart.
import java.util.Collections; import java.util.List; import javafx.geometry.Pos; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.ContentDisplay; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.CycleMethod; import javafx.scene.paint.LinearGradient; import javafx.scene.paint.Stop; public class CanvasCell extends ListCell<YearEntry> { private Label yearLabel; private ResizableCanvas canvas; public CanvasCell() { /* * Important, otherwise we will keep seeing a horizontal scrollbar. */ setStyle("-fx-padding: 0px;"); yearLabel = new Label(); yearLabel .setStyle("-fx-padding: 10px; -fx-font-size: 1.2em; -fx-font-weight: bold;"); StackPane.setAlignment(yearLabel, Pos.TOP_LEFT); /* * Create a resizable canvas and bind its width and height to the width * and height of the table cell. */ canvas = new ResizableCanvas(); canvas.widthProperty().bind(widthProperty()); canvas.heightProperty().bind(heightProperty()); StackPane pane = new StackPane(); pane.getChildren().addAll(yearLabel, canvas); setGraphic(pane); setContentDisplay(ContentDisplay.GRAPHIC_ONLY); } @Override protected void updateItem(YearEntry entry, boolean empty) { if (empty || entry == null) { yearLabel.setText(""); canvas.setData(Collections.emptyList()); canvas.draw(); } else { yearLabel.setText(Integer.toString(entry.getYear())); canvas.setData(entry.getValues()); canvas.draw(); } } /* * Canvas is normally not resizable but by overriding isResizable() and * binding its width and height to the width and height of the cell it will * automatically resize. */ class ResizableCanvas extends Canvas { private List<Double> data = Collections.emptyList(); public ResizableCanvas() { /* * Make sure the canvas draws its content again when its size * changes. */ widthProperty().addListener(it -> draw()); heightProperty().addListener(it -> draw()); } @Override public boolean isResizable() { return true; } @Override public double prefWidth(double height) { return getWidth(); } @Override public double prefHeight(double width) { return getHeight(); } public void setData(List<Double> data) { this.data = data; } /* * Draw a chart based on the data provided by the model. */ private void draw() { GraphicsContext gc = getGraphicsContext2D(); gc.clearRect(0, 0, getWidth(), getHeight()); Stop[] stops = new Stop[] { new Stop(0, Color.SKYBLUE), new Stop(1, Color.SKYBLUE.darker().darker()) }; LinearGradient gradient = new LinearGradient(0, 0, 0, 300, false, CycleMethod.NO_CYCLE, stops); gc.setFill(gradient); double availableHeight = getHeight() * .8; double counter = 0; for (Double value : data) { double x = getWidth() / 365 * counter; double barHeight = availableHeight * value / 100; double barWidth = getWidth() / 365 + 1; gc.fillRect(x, getHeight() - barHeight, barWidth, barHeight); counter++; } } } }
For the data we use a very simple class that stores the year and a list of values.
import java.util.ArrayList; import java.util.List; /** * Just some fake model object. */ public class YearEntry { private int year; public YearEntry(int year) { this.year = year; } public int getYear() { return year; } private List<Double> values = new ArrayList<>(); /** * Stores the values shown in the chart. */ public List<Double> getValues() { return values; } }
And the following listing shows the main class.
import javafx.application.Application; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.Scene; import javafx.scene.control.ListView; import javafx.stage.Stage; public class CanvasApp extends Application { @Override public void start(Stage stage) throws Exception { /* * Create some random data for my life span. */ ObservableList<YearEntry> data = FXCollections.observableArrayList(); for (int year = 1969; year &lt; 2015; year++) { YearEntry entry = new YearEntry(year); for (int day = 0; day < 365; day++) { entry.getValues().add(Math.random() * 100); } data.add(entry); } ListView<YearEntry> listView = new ListView<>(data); listView.setCellFactory(param -> new CanvasCell()); listView.setFixedCellSize(200); Scene scene = new Scene(listView); stage.setTitle("Canvas Cell"); stage.setScene(scene); stage.setWidth(600); stage.setHeight(600); stage.show(); } public static void main(String[] args) { launch(args); } }
[…] Lemmermann posted two new JavaFX tips: one suggesting the benefits of the JavaFX Canvas, and the other about making use of […]
[…] https://dlemmermann.wordpress.com/2015/06/16/javafx-tip-20-a-lot-to-show-use-canvas/ […]
Hello,
I would to thank you for your wonderfull work !
It’s amazing !
I just have a small question on how to had a listener on the different gc.fillRect that we created in the canvas.
I want to clic on a gc.fillRect to focus on the right listview line etc…
Thanks !
The things you draw in a canvas are not “clickable objects”. Only nodes used by the scenegraph are. What you need to do is to remember the location of everything you draw in the canvas (e.g. store the bounds in a hash map). Then you can write code to perform a hit detection (see if mouse location is contained by one of the bounds).
I see, right now i’m trying to get the X & Y location of a mouseclic event …
Let’s say i have 10 fillRect, my canvas is divide in 10 parts, if the event is in the 1/10 Y axis of the canvas so it’s my first fillRect, if it’s the 4/10 Y axis so it’s 4th fillRect …
i think it’s more fast and consumme less memory, but maybe it’s not a good solution, anyway …
Thank you so much for your fast response, and i will try my solution and your solution to compare performance with a 2000 line files !
Keep the good work.