I need to develop a custom control for JavaFX 8. Unfortunately, most of the tutorials concentrate on the FXML way to do it, but I need to code in some custom painting.
How would I do it in Swing? Extend some base class and override paint. That’s it. In JavaFX, the right way seems to be overriding two classes: the control itself and the skin. OK, this actually looks like a good idea: the control is responsible for behavior, and the skin is responsible for the painting. So let’s look at the skin API:
1 2 3 4 5 6 7 |
public interface Skin<C extends Skinnable> { public C getSkinnable(); public Node getNode(); public void dispose(); } |
What? Where is the paint method? According to the docs, getSkinnable() simply returns the associated control, dispose() detaches the skin from the control and getNode() “Gets the Node which represents this Skin”. What the…? So we have one node that is the control itself and another node which is the skin? I hope we don’t need to skin the skin, considering that it’s a kind of node itself!
After looking at some examples, I got the general idea. The skin is just a bunch of nodes, and getNode() just returns the root node. If you want to really customize your paining, you can always use a canvas as a skin. But I decided to try to use some shape nodes instead.
OK, I can create some shapes, put then into a Group, for example, and then what? The skin obviously needs to handle resizing. But how does it know when to resize exactly? I could just subscribe to the control’s width and height properties (and unsubscribe in dispose). But that feels ugly. Still, Han Solo himself does exactly that, so maybe it’s the right way after all?
After trying a lot of various things, I still couldn’t get it right:
- If I just put my shapes into a Group, the control doesn’t resize properly.
- If I put my shapes into a Group and inherit from SkinBase instead of implementing Skin, the control does resize, but…
- All shapes are centered and I can’t position them. Looking at SkinBase sources, turns out it’s hardcoded.
- If I draw a vertical line of length exactly equal to the control’s height, the control automatically increases its size by one pixel at each repaint. So if I keep resizing it horizontally, for example, it keeps growing vertically forever.
All of that didn’t make any sense. After further studying SkinBase sources, I got a feeling that a skin acts like a layout manager. That is, it’s responsible for managing the relative positions of its children. It is done by applying the appropriate transformations the result of which can be queried by calling getLayoutX() and getLayoutY() on the components.
Another thing is that SkinBase cheats around getChildren() being protected in the Control class. That allows it to directly manipulate the children of the control—no Group needed.
So in the end I concluded that:
- A skin is best implemented by inheriting SkinBase.
- To add components, just call getChildren().addAll(children).
- To position the components needed to draw the skin, override layoutChildren. From it, call layoutInArea for every child that needs to be positioned.
- All shapes should be drawn in an imaginary coordinate system that is tied to the shape itself. If you need a line, you might as well start it from (0, 0). layoutInArea will move it to the required position anyway, so the lines (0, 0)–(10, 10) and (10, 10)–(20, 20) will look exactly the same in the end.
The resulting control prototype is this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
class ControlPrototype extends Control { @Override protected Skin<?> createDefaultSkin() { return new ControlSkinPrototype(this); } } class ControlSkinPrototype extends SkinBase<ControlPrototype> { private static final int LINES_COUNT = 64; private static final double LEFT_MARGIN = 2.0; private static final double RIGHT_MARGIN = 2.0; private static final double MARGINS = LEFT_MARGIN + RIGHT_MARGIN; private static final double MIN_LINE_WIDTH = 2.0; private static final double MIN_MAIN_WIDTH = MIN_LINE_WIDTH * LINES_COUNT; private final Line[] lines = new Line[LINES_COUNT]; private final double[] values = new double[LINES_COUNT]; public ControlSkinPrototype(ControlPrototype control) { super(control); initLinesAndValues(); initGraphics(control); } private void initLinesAndValues() { final Random random = new Random(); for (int i = 0; i < LINES_COUNT; ++i) { lines[i] = new Line(); values[i] = random.nextDouble(); } } private void initGraphics(ControlPrototype control) { control.setBackground(new Background(new BackgroundFill(Color.GREEN, null, null))); getChildren().addAll(lines); } @Override protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return leftInset + MIN_MAIN_WIDTH + MARGINS + rightInset; } @Override protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) { final double mainWidth = contentWidth - MARGINS, lineWidth = mainWidth / LINES_COUNT; final double mainX = contentX + LEFT_MARGIN; for (int i = 0; i < LINES_COUNT; ++i) { final Line line = lines[i]; final double value = values[i]; line.setStartX(0); line.setStartY(contentHeight); line.setEndX(0); line.setEndY(contentHeight - value * contentHeight); line.setStrokeWidth(lineWidth / 2.0); final double lineX = mainX + i * lineWidth; layoutInArea(line, lineX, contentY, lineWidth, contentHeight, -1, HPos.CENTER, VPos.BOTTOM); } } } public class Jlaj extends Application { @Override public void start(Stage primaryStage) throws Exception { VBox root = new VBox(); Control control = new ControlPrototype(); root.getChildren().addAll(control); VBox.setVgrow(control, Priority.ALWAYS); Scene scene = new Scene(root, 320, 200); primaryStage.setScene(scene); primaryStage.show(); } public static void main(String[] args) { launch(args); } } |
The resulting graphics:
As you can see, it resizes nicely and the lines are positioned exactly as I want them.
P. S. Further prototyping revealed that it still resizes randomly sometimes, especially as I update values and/or resize window with lots of controls in it. The reason is that by default, SkinBase calculates preferred width/height based on preferred widths/heights of its children. The problem is that preferred width/height of a primitive equals to its actual size (since it’s not directly resizable). Therefore, once a control is resized, its preferred size is now different. If it was the same size as other controls before that, not only it’s no longer the case, but the preferred sizes are different, so layout gives different sizes to different controls. This is repeated on each resize, which leads to a funny “rich get richer” scenario where bigger controls are given more and more space because their preferred size is greater. This issue is fixed by overriding computePrefWidth/Height to return something sensible.