FX My Life 2.3: Gridding The Gang Back Together
OK, let's try to put our (still pretty non-functional) grid into our app. If we made a JAR, we could pretty much just add it to SceneBuilder, but I'm not there yet. Let's cheat by taking our anchor pane with the circle on it:
Let's delete that and replace it with an empty StackPane. Huh. That process actually hung things up for me. Let me try again.
Wow! It hung up again. OK, I guess we don't want to be deleting the anchor pane from a pre-populated tab pane. Well, what if we delete it completely and then add the stack pane? And this also hangs up.
Waitaminute. Can I add anything to a TabPane? Does it have to be an anchor pane?
ten minutes later...
You ever experience one of those weird sets of events where it feels like you're taking crazy pills? Nothing was working for me with TabPanes. I could delete the anchor pane, but then couldn't add an anchor pane back in. But then I could, and then everything started working, including adding a StackPane.
It's the sort of thing you don't put in books because it's too weird and possibly just some local idiosyncrasy that no one else can relate to. But I'm keeping this in here just in case it happens to you, and everyone tells you you're crazy even though you know it happened.
You're welcome.
Anyway, this is what we're going for in ds-main.fxml:
We've replaced the anchor pane with an empty stack pane and saved it. The FXML should look like this:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Tab?>
<?import javafx.scene.control.TabPane?>
<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.layout.StackPane?>
<TabPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="446.0" prefWidth="615.0" tabClosingPolicy="UNAVAILABLE" xmlns="http://javafx.com/javafx/11.0.1" xmlns:fx="http://javafx.com/fxml/1">
<tabs>
<Tab text="Untitled Tab 1">
<content>
<StackPane prefHeight="527.0" prefWidth="767.0" />
</content>
</Tab>
<Tab text="Untitled Tab 2">
<content>
<AnchorPane minHeight="0.0" minWidth="0.0" prefHeight="180.0" prefWidth="200.0" />
</content>
</Tab>
</tabs>
</TabPane>
And we're going to replace StackPane
with Grid
. Ooh, and we should have some columns and rows, too. Now, we defined our colCount and rowCount as private
int
s, but I think they have to be public
and possibly final
:
public class Grid extends StackPane {
public final int colCount;
public final int rowCount;
Such that when the fxml is loaded, the loader looks at those properties listed in the file, and then puts those values into the Java object. So here:
<StackPane prefHeight="527.0" prefWidth="767.0" />
A StackPane
is created and given those prefHeight
values. This makes me wonder if our JavaFX component can even work without allowing its column and rows to be dynamically settable. Hmmm.
Sure enough, if we add our Grid
class in to the FXML:
<?import fxgames.Grid?>
And then replace StackPane
with Grid
:
<Grid prefHeight="527.0" prefWidth="767.0" />
We get a squiggly red line under Grid
with the warning: Unable to instantiate
.
Nota bene that a StackPane can be instantiated with no parameters. It's just:
new StackPane()
This is doubtless not a coincidence. Most GUI editors work on a contract-less basis: They want to create the component and then fill it up with all the properties to make it actually work. A component generally has to be viable at any point—that is, stable regardless of any modifiable state—even if it's not usable.
So, what if we add another constructor? With no parameters?
public Grid() {
colCount = 2;
rowCount = 2;
}
No more "unable to instantiate". OK, so we'll have to make setters for colCount
and rowCount
. We can't just do this, alas:
<Grid prefHeight="527.0" prefWidth="767.0" colCount="10" />
in our FXML. Let's go to the source for an existing property to see how it's done. Let's, arbitrarily, take prefWidth
. In Region
this is defined as:
private double _prefWidth;
And with a little searching I see there's a setPrefWidth:
public final void setPrefWidth(double var1) {
if (this.prefWidth == null) {
this._prefWidth = var1;
this.requestParentLayout();
} else {
this.prefWidth.set(var1);
}
}
And a getPrefWidth
:
public final double getPrefWidth() {
return this.prefWidth == null ? this._prefWidth : this.prefWidth.get();
}
So, for us, that would mean changing Grid's internal vars back to private, while prefixing an underscore:
private int _colCount;
private int _rowCount;
Hmmm. We'll also have to either change all our existing internal uses of rowCount
and colCount
to _rowCount
and _colCount
or getRowCount()
and getColCount()
. I think we'll take the direct approach for now.
The hazard is we may come to build dependencies on code that does things when the column and row counts are written to (or even read from, though this is much less common), and by relying on the actual values everywhere, we run the chance of breaking those dependencies. Currently this is not a big hazard. (And it may never be.)
Now, we need methods to read and write the internal vars:
public final int getColCount() {
return this._colCount;
}
public final void setColCount(int var1) {
this._colCount = var1;
}
public final int getRowCount() {
return this._rowCount;
}
public final void setRowCount(int var1) {
this._rowCount = var1;
We're assuming a whole lot here, but now if we change our Grid in the FXML to:
<Grid prefHeight="527.0" prefWidth="767.0" colCount="3" rowCount="3" />
We'll get no warnings. But does it work? Well...
At least it's not crashing. But our Grid is completely invisible so we don't know how big it is, much less whether it has the number of columns and rows we wanted.
Color Me Whatever
OK, let's give our Grid some kind of color. How would that work? Well, there's a Region
class undergirding a lot of JavaFX components, and it has a background
property. Could we just:
public Grid(int columns, int rows) {
_colCount = columns;
_rowCount = rows;
this.setBackground(new Background(new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, Insets.EMPTY)));
}
Why, yes we can! And it looks like this:
OK. Now what? Well, what are we going to need? Well, I think we're going to need grid lines. But before we do grid lines, I think it would be helpful to be able to color the grid squares. If we can get some kind of color scheme going, we'll be able to see whether the lines are correct.
I feel like the easiest way to do this would probably be to create a floor "layer" and to populate it with rectangles representing each cell. But in a shocking twist, I'm going to take us down another route: Background fills. We created a background fill just above, made it fill the whole region and colored it blue:
this.setBackground(new Background(new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, Insets.EMPTY)));
We overlooked three things here: First, the CornerRadii
parameter, which allows you to round the corners of the fill. Like, if we added in one of these things:
fills[0] = new BackgroundFill(Color.BLUE, new CornerRadii(64), Insets.EMPTY);
Our background would come out looking like this:
Nice rounded corners. We should remember this effect. We can crank the value up for
CornerRadii
, too:
Now we got ourselves a happy little oval. You can put in multiple values, too:
fills[0] = new BackgroundFill(Color.BLUE, new CornerRadii(1024, 1024, 64, 64, false ), Insets.EMPTY);
This comes out like:
The first four parameters are the values for each of the corners and the last parameter is whether to consider the values as percentages. I would imagine you'd want to use percentages in most cases, but we don't (yet) have much use for oval-ly backgrounds.
The second thing we overlooked was the Insets property. Insets say "Draw this fill X far away from its borders." For example:
fills[0] = new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, new Insets(100));
Produces:
Our background fill is now 100 away from the top, left, bottom and right borders. We can specify those individually as top, right, bottom and left:
fills[0] = new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, new Insets(0, 200, 200, 0));
So, we're saying show this fill flush up against the top-left corner but set back from the right and bottom borders 200 pixels, which looks like this:
Well, what if we did this?
fills[0] = new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, new Insets(0, widthProperty().doubleValue()-colWidth(), heightProperty().doubleValue()-rowHeight(), 0));
Would this not define the fill as, basically, the top-left-most square of our grid? Coordinate 0, 0? I think it would, which brings me to the third thing we overlooked in creating our Background:
A background can be composed of multiple fills!
So, in theory, we can make our grid color itself just by using backgrounds! (This multi-background fill seems like it might be useful for parallax effects.) Now, it's not quite so easy as all that, because if we plug in the above code, what do we see?
Gah! We're back to square one!
Well, not really. All that's going on, I suspect, is that our Grid, when initialized has no width or height. We can't base our fill on the Grid's dimensions until it is ensconced in some other object. And in any event, the initialization is a bad place to draw things on our component because we do want our component to resize.
Time to take a look at all that mess.
Feeling Expansive
What we would like for our project, in almost every circumstance, is for it to fill whatever screen space is allowed. We might even want to go full screen (gasp!) at some point. In the case of the grid, the dimensions of the board aren't going to change, so it needs to fill its parent, and repaint itself whenever said parent is resized.
Of course, we might at some point want a window on our grid, where we're not seeing the whole grid at once, but that's a problem for a different day.
The basic approach to resizing is to listen to the stage event. Something like:
stage.widthProperty().addListener((obs, oldVal, newVal) -> {
// code from [StackOverflow](https://stackoverflow.com/questions/38216268/how-to-listen-resize-event-of-stage-in-javafx)
//memo to self: Links embedded in code don't work on Hashnode
});
Hmm. That means the Grid has to know about the stage, which seems suboptimal. Well, what if we did something cheesy, like max out the grid at some super-high value?
Before we do this, though, we gotta go back to the beginning. Our nodes are kind of randomly sized. Let's start by seeing if we can make our "menu", which contains the links to the various games, take up the whole stage.
If you think back, our "frame" for the project was a BorderPane
. We put the buttons on top, and the central area was a panel. Then we made an FXML for the tiles which acts as our menu (only takes up the top left part of the central area). What if we made that central area an AnchorPane
?
By itself, this produces no change. But if you'll recall, we activate nodes with our NodeController object like so:
NodeController.me.activate("tiles-panel", Controller.me.central);
Now, in NodeController
's activate
:
protected void activate(String name, Pane owner) {
Node node = nodeMap.get(name);
. . .
if (owner instanceof javafx.scene.layout.AnchorPane) {
AnchorPane.setTopAnchor(node, 0.0);
AnchorPane.setBottomAnchor(node, 0.0);
AnchorPane.setLeftAnchor(node, 0.0);
AnchorPane.setRightAnchor(node, 0.0);
}
}
So far, we've only ever used Controller.me.central as the owning object for a node, but we've always envisioned NodeController as central area for transitioning objects in and out of the app, regardless of context, if makes sense to put in that test to make sure we have an AnchorPane
.
By setting the anchor of the incoming node to 0.0, we're saying "Hey, make yourself as big as your owning control." And this works. Our tiles menu looks like:
Meanwhile, our tic-tac-toe game does expand, but not necessarily how we'd want it to.
That's a lot of air. But that's at least in part because we're using GridPane
and it isn't expanding. Neither are our Xs and Os. Our main concern for now, however, is our own Grid
control.
It also expands, but we still have our problem that it has no height or width. Let's start to remedy this by removing our background filling code from the constructor to its own method:
public Grid(int columns, int rows) {
_colCount = columns;
_rowCount = rows;
}
public void setBackgrounds() {
BackgroundFill[] fills = new BackgroundFill[_rowCount * _colCount + 1];
fills[0] = new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, new Insets(0, widthProperty().doubleValue()-colWidth(), heightProperty().doubleValue()-rowHeight(), 0));
this.setBackground(new Background(fills));
}
Now. How do we call this? When do we call it? Up till now, everything I'd read on resizing said, "Well, you have to hook into the stage object," but this is only if, for some reason, you care about the specific window size and we only really care about the size of the grid—which we have now anchored to the window size so it changes without us having to worry about it! All we have to do is watch the height and width properties!
widthProperty().addListener(new InvalidationListener() {
@Override
public void invalidated(Observable observable) {
setBackgrounds();
}
});
heightProperty().addListener(new InvalidationListener() {
@Override
public void invalidated(Observable observable) {
setBackgrounds();
}
});
Rather shockingly, this works perfectly though you could be forgiven for forgetting what we had been going for.
There's a grid "square", right at the first column and row, as we tried to create many sections ago! Now, we may want to enforce actual "squareness", as it were, to avoid rectangles (which, if we're putting images in the square, will distort them) but for now, marvel that as you resize the window, the cell shape changes along with it!
At least, it changes within certain constraints. There's probably going to be a lot more to redrawing the grid than just setting the background fills, so let's, for neatness sake, put the function into an object variable:
public class Grid extends StackPane {
private int _colCount;
private int _rowCount;
InvalidationListener redraw = new InvalidationListener() {
@Override
public void invalidated(Observable observable) {
setBackgrounds();
}
};
Actually, as IJ reminds us, we can lambdify that to just:
InvalidationListener redraw = observable -> setBackgrounds();
And then set up the listeners themselves in the constructor like so:
public Grid(int columns, int rows) {
_colCount = columns;
_rowCount = rows;
widthProperty().addListener(redraw);
heightProperty().addListener(redraw);
}
Our Checkered Future
The big idea we had early on was to use multiple background fills to color our grid. Let's put in another cell right next to the top-left one, in the second column and see how that goes:
public void setBackgrounds() {
BackgroundFill[] fills = new BackgroundFill[_rowCount * _colCount + 1];
fills[0] = new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, new Insets(0, widthProperty().doubleValue()-colWidth(), heightProperty().doubleValue()-rowHeight(), 0));
fills[1] = new BackgroundFill(Color.RED, CornerRadii.EMPTY, new Insets(0, widthProperty().doubleValue()-colWidth()*2, heightProperty().doubleValue()-rowHeight(), colWidth()));
this.setBackground(new Background(fills));
}
We just have to alter the x coordinate, by changing the right inset (the second parameter) from one column width to two and by changing the left inset (the last parameter) from 0 to one column width. Ecco!
Not only does it work, our blue square and red square live side-by-side in harmony. (Oh, Lord, why don't we?) When the grid changes, they remain flush up against each other, but the exact same size. (Though we may be off by a pixel here on the edges, now that I think about it.)
OK, then, making a checkerboard pattern should be a slice of pie, no? Indeed, it is not very challenging:
public void setBackgrounds() {
BackgroundFill[] fills = new BackgroundFill[_rowCount * _colCount];
double gridWidth = widthProperty().doubleValue();
double gridHeight = heightProperty().doubleValue();
Color[] check = {Color.RED, Color.BLUE};
for (int i = 0; i < _colCount; i++)
for (int j = 0; j < _rowCount; j++) {
Color color = check[(((i % 2) + j) % 2)];
fills[j + (i * _colCount)] = new BackgroundFill(color, CornerRadii.EMPTY,
new Insets(j * rowHeight(),
gridWidth - (colWidth() + colWidth() * i),
gridHeight - (rowHeight() + rowHeight() * j),
i * colWidth()));
}
this.setBackground(new Background(fills));
}
We create an array of BackgroundFill
s, and then for each cell, we figure out what color we want:
Color color = check[(((i % 2) + j) % 2)];
This is not necessarily obvious, so let me explain: We want an alternating pattern, but you can't just say "Well, odds are red and evens are blue." That works when there's an odd number of rows :
Col 1 |
red |
blue |
red |
But if there's an EVEN number of rows:
Col 1 | Col 2 |
red | red |
blue | blue |
red | red |
blue | blue |
So we check whether we're in an odd or even row (with i%2
) and from there add the column, making sure that the value is always either 0 or 1.
Color color = check[(((i % 2) + j) % 2)];
Et voila!
You can probably see where we can go from this: There's no reason we are limited to alternating patterns of two colors, and we'd certainly want to be able to select the colors, and we need to listen to changes to the colors and to the row and column count, and so on and so on.
How we proceed from this point depends on how we consider our task. For example, if we're just hot to get the job done, we'd just do listeners for the columns and row counts, and skip anything to do with colors because in our planned app, our cells will be uniformly colored (but will have things in them to make them different).
Knowing that, we can start there and see how far we want to go from there.
Quis custodiet ipsos custodes?
We want to make our column and row counts observable, much like the already extant widthProperty
we exploited earlier on. Well, what does that look like?
public final ReadOnlyDoubleProperty widthProperty() {
if (this.width == null) {
this.width = new ReadOnlyDoubleWrapper(this._width) {
protected void invalidated() {
Region.this.widthChanged(this.get());
}
public Object getBean() {
return Region.this;
}
public String getName() {
return "width";
}
};
}
return this.width.getReadOnlyProperty();
}
OK, so, there must be some class called something like ReadWriteIntegerProperty, or maybe just IntegerProperty (that's it!) and we need to create a public final
colCount
function that initializes the property object and works with the underlying values. The getName
probably provides the label for use in, e.g., the SceneBuilder.
I think we can even use SImpleIntegerProperty
. Which I think means we don't really need code like the above for widthProperty()
. We can just do this:
private final SimpleIntegerProperty _colCount;
private final SimpleIntegerProperty _rowCount;
public Grid(int columns, int rows) {
_colCount = new SimpleIntegerProperty(columns, "colCount");
_rowCount = new SimpleIntegerProperty(rows, "rowCount");
widthProperty().addListener(redraw);
heightProperty().addListener(redraw);
}
It feels very cheesy to have a component listen to its own properties for changes, but it's easy enough to make it worth trying to see if there are any issues. Now, this does mean that we have to change all our former direct references from _colCount
and _rowCount
to use the property's get()
function, like so:
public void setBackgrounds() {
BackgroundFill[] fills = new BackgroundFill[_rowCount.get() * _colCount.get()];
But if we do this globally, we'll find that our new code works just like the old one.
Wait, why did we do this?
Oh, right, we wanted to be able to update the grid when the number of columns and rows change. Well, it seems a little cheesy to be eavesdropping on our own properties, but we might just be able to add this to our constructor:
_colCount.addListener(redraw);
_rowCount.addListener(redraw);
The "funny" thing being that we have no way to change the columns and rows. We haven't yet created a controller for our game! Let's create one now in a new source subfolder called dunslip:
With some code just to make sure everything's wired up okay:
package fxgames.dunslip;
import javafx.fxml.FXML;
public class DunslipController {
@FXML
public void initialize() {
System.out.println("DUNSLIP CONTROLLER ACTIVATED");
}
}
And rather than mess around with SceneBulder, let's just add the controller in manually (at the very end of this line, we're adding fx:controller
:
<TabPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="446.0" prefWidth="615.0" tabClosingPolicy="UNAVAILABLE" xmlns="http://javafx.com/javafx/11.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="fxgames.dunslip.DunslipController">
Running this we see, "DUNSLIP CONTROLLER ACTIVATED" so we know we're good to go. Note that it shows up as the program starts, not when you select the actual DunSlip game.
Let's make the grid dimensions randomize when it's clicked. To do that, we're going to (manually) add an fx:id
and an onMouseClicked
to the grid:
<Grid prefHeight="527.0" prefWidth="767.0" colCount="3" rowCount="3" fx:id="grid1" onMouseClicked="#randomize"/>
And in the code, we'll add:
package fxgames.dunslip;
import fxgames.Grid;
import javafx.fxml.FXML;
public class DunslipController {
@FXML
Grid grid1;
@FXML
public void initialize() {
System.out.println("DUNSLIP CONTROLLER ACTIVATED");
}
@FXML
public void randomize() {
System.out.println("RANDOMIZE ACTIVATED");
}
}
If you add the code first, by the way, IntelliJ will help out with code completion for manually editing the FXML, which is nice. Run this and click on the grid, and you'll see:

So far, so good. Now let's actually scramble the columns and rows. Replace the randomize
code with:
@FXML
public void randomize() {
int x = (int) (Math.random()*10+1);
int y = (int) (Math.random()*10+1);
System.out.printf("Setting grid to %d,%d\n",x,y);
grid1.setColCount(x);
grid1.setRowCount(y);
}
And give it a whirl! Uh-oh.
Well, at least it's an obvious problem. If our grid is set to 7x2, index 21 would certainly be out of bounds. Of course, so would 14 through 20, so maybe we have two issues. Just to add to the weirdness, it sometimes produces odd results:
Here is the SetBackgrounds
line "gang awry" (as Robert Burns might say):
fills[j + (i * _colCount.get())] = new BackgroundFill(color, CornerRadii.EMPTY,
new Insets(j * rowHeight(),
gridWidth - (colWidth() + colWidth() * i),
gridHeight - (rowHeight() + rowHeight() * j),
i * colWidth()));
OK, so, this routine is called a lot, it turns out, because every setting of x and y calls it. That's fine and what we wanted. Perplexingly, if we log:
public void setBackgrounds() {
BackgroundFill[] fills = new BackgroundFill[_rowCount.get() * _colCount.get()];
System.out.printf("cols x rows = %d while length of fills is %d\n", _rowCount.get()*_colCount.get(), fills.length );
. . .
We'll see:
DUNSLIP CONTROLLER ACTIVATED
cols x rows = 9 while length of fills is 9
cols x rows = 9 while length of fills is 9
Setting grid to 7,10
cols x rows = 21 while length of fills is 21
cols x rows = 70 while length of fills is 70
Exception in thread "JavaFX Application Thread" java.lang.ArrayIndexOutOfBoundsException: Index 21 out of bounds for length 21
I wonder if this:
grid1.setColCount(x);
grid1.setRowCount(y);
Could be the issue? That is, maybe it's mid-drawing because of the change to the column count, and then the row count gets changed, so that this:
for (int i = 0; i < _colCount.get()); i++)
for (int j = 0; j < _rowCount.get(); j++) {
is going further than it should? (We should probably look at changing both coordinates at once somehow to reduce the times this is called but still, we should be able to make this work.) Well, let's change the routine so that's not possible:
public void setBackgrounds() {
int rows = _rowCount.get();
int cols = _colCount.get();
BackgroundFill[] fills = new BackgroundFill[rows * cols];
double gridWidth = widthProperty().doubleValue();
double gridHeight = heightProperty().doubleValue();
Color[] check = {Color.RED, Color.BLUE};
for (int i = 0; i < cols; i++)
for (int j = 0; j < rows; j++) {
Color color = check[(((i % 2) + j) % 2)];
fills[j + (i * rows)] = new BackgroundFill(color, CornerRadii.EMPTY,
new Insets(j * rowHeight(),
gridWidth - (colWidth() + colWidth() * i),
gridHeight - (rowHeight() + rowHeight() * j),
i * colWidth()));
}
this.setBackground(new Background(fills));
}
Honestly, I kind of thought of doing this up front because using rows
instead of _rowCount.get()
looks so much cleaner. So I'm "happy" with this, except for the fact that it didn't change anything.
Well, look, it's easy to completely eliminate the possibility of conflicting threads by just eliminating the second change:
grid1.setColCount(x);
// grid1.setRowCount(y); //stand by
If this works, then there is something we don't get about how properties and threads work, and we should dread that, relative to just a bug in our code, right? Because we can easily fix our own bugs. Indeed, this changes nothing.
Ooh, I have a bad feeling about this. My dreaded "dyslexia".
If you haven't been reading along and don't get my "dyslexia" gag, it's something that came up in the Tic-Tac-Toe section: Basically, I have a rare form of (not really) dyslexia that has the exclusive effect of causing me to switch my Xes and Ys (columns and rows) while making a grid-based game.
Fortunately, I think this is just a very mild case of it, more on the "not quite thinking the algorithm through" front. And also "setting your default up to be symmetrical, so that you miss juxtaposition errors" like this.
fills[j + (i * cols)] =
The idea here was to create an array for (say) a 3 x 8 grid, of 0 to 23. But if the columns are 8 this is going to blow up as soon as i
hits 3 (because 3x8=24). Easy fix:
fills[j + (i * rows)] =
Ah. I love an easy fix, don't you? Everything works beautifully now. And fast! Heck, we can change the randomize
code to:
public void randomize() {
int x = (int) (Math.random()*200+1);
int y = (int) (Math.random()*200+1);
System.out.printf("Setting grid to %d,%d\n",x,y);
grid1.setColCount(x);
grid1.setRowCount(y);
}
And the Grid will blaze through changes as fast as you can click!
Setting grid to 84,134
Setting grid to 178,88
Setting grid to 191,177
Setting grid to 34,43
Setting grid to 115,187
Red and blue get very purple when the grid boxes are small:
There would almost certainly be an issue when the grid dimensions exceed the screen resolution, but we won't worry about that right now. Let's do one more thing before wrapping up.
Highlights
All right, let's wrap this episode up by giving just a hint of interactivity. When the mouse is over the grid, let's change the color of the cell it's directly over.
Now, we're gonna have to make a bunch of iterations on this grid component, of course. We don't have a good reactive thing going, just for instance, and we really want to make the grid obey CSS, right? (I mean, probably. We should know how to do that, in any event.)
But for now, let's start with a selected coordinate property. Well, let's not call it "selected" since we may want that to mean something else. We'll call it hoverCoord:
private final Coord _hoverCoord = null;
Now we'd like to listen to any changes to the hoverCoord
but we can't because there is no CoordProperty
. But there is an ObjectProperty
. Could it be as simple as:
private final ObjectProperty<Coord> hoverCoord;
? It cannot. Or, not quite, anyway. The deal is, hoverCoord is not initialized, so we can't do:
_hoverCoord.addListener(redraw);
But if we initialize it here, we'll have to come up with some system that means "I'm not really real" for when the mouse leaves the board. We need some hook into when the mouse has "entered" the grid and when it has "exited" it. Which we have, of course:
this.setOnMouseEntered(event -> {
_hoverCoord = new SimpleObjectProperty<Coord>(getCoord(event.getX(), event.getX()));
_hoverCoord.addListener(redraw);
});
this.setOnMouseExited(event -> {
_hoverCoord = null;
});
this.setOnMouseMoved(event -> {
_hoverCoord.set(getCoord(event.getX(), event.getX()));
});
So, we're creating a new _hoverCoord
when the mouse enters the grid, free it when the mouse exits, and change it when the mouse is moved. Let's just catch this in setBackgrounds:
if (this._hoverCoord != null) System.out.printf("%s is at %d, %d\n",_hoverCoord, _hoverCoord.get().x, _hoverCoord.get().y);
If we run this, we'll see interesting things in the log:
ObjectProperty [value: fxgames.Coord@6deb0758] is at 2, 2
ObjectProperty [value: fxgames.Coord@3a4b0123] is at 2, 2
ObjectProperty [value: fxgames.Coord@7d486155] is at 2, 2
ObjectProperty [value: fxgames.Coord@62a3d9ab] is at 2, 2
ObjectProperty [value: fxgames.Coord@57c7d360] is at 2, 0
ObjectProperty [value: fxgames.Coord@356782a2] is at 2, 1
ObjectProperty [value: fxgames.Coord@b9b1f92] is at 2, 2
ObjectProperty [value: fxgames.Coord@7709f1cb] is at 2, 3
ObjectProperty [value: fxgames.Coord@1e3c6099] is at 2, 2
ObjectProperty [value: fxgames.Coord@17c7936e] is at 2, 2
First, the numbers aren't right. Second, the coord is being created and destroyed a bunch, probably here:
this.setOnMouseMoved(event -> {
_hoverCoord.set(getCoord(event.getX(), event.getX()));
});
First, let's fix the typo—provided by "smart lookup"—where we've put in getX twice:
_hoverCoord.set(getCoord(event.getX(), event.getY()));
We're also setting the entire coordinate object, not just its x
and y
values, but let's not worry about that per se, and just check to make sure the coordinate has changed. That will reduce a lot of the noise.
this.setOnMouseMoved(event -> {
Coord c = getCoord(event.getX(), event.getY());
Coord d = _hoverCoord.get();
if(c.x!=d.x || c.y!=d.y) {
_hoverCoord.set(getCoord(event.getX(), event.getY()));
}
});
That reduces a lot of the calls but raises further questions. Like how is it possible for our getCoord to be returning a c.y
of 3? We tested that! (This is a sign we need more tests.) On a 3x3 grid, we should only be able to return 0, 1 or 2!
Well, looking at the getCoord function, it's not surprise at all:
public Coord getCoord(double x, double y) {
return new Coord(boundCol(x % colWidth()), boundRow(y % rowHeight()));
}
We're using a modulo instead of a divisor! How on earth!
public Coord getCoord(double x, double y) {
return new Coord(boundCol(x / colWidth()), boundRow(y / rowHeight()));
}
That fixes everything, except our tests which we'll look at next time. Let's wrap this up by coloring the highlighted cell. In our setBackgrounds
function, we only need to copy this code:
fills[j + (i * rows)] = new BackgroundFill(color, CornerRadii.EMPTY,
new Insets(j * rowHeight(),
gridWidth - (colWidth() + colWidth() * i),
gridHeight - (rowHeight() + rowHeight() * j),
i * colWidth()));
For our highlighted cell. Oh, and expand the fills array so it can contain an extra fill.
BackgroundFill[] fills = new BackgroundFill[rows * cols + 1];
And then after our loop to color the background:
if (_hoverCoord != null) {
fills [rows * cols] = new BackgroundFill(Color.YELLOW, CornerRadii.EMPTY,
new Insets(_hoverCoord.get().y * rowHeight(),
gridWidth - (colWidth() + colWidth() * _hoverCoord.get().x),
gridHeight - (rowHeight() + rowHeight() * _hoverCoord.get().y),
_hoverCoord.get().x * colWidth()));
We're basically putting a yellow inset over an existing red or blue one. Result?
It works! Now, obviously we want to refactor this code, and we're not highlighting the first cell because that event is eaten by the onMouseEntered, and we're not recoloring the grid when the mouse leaves, and also we're still destroying the Coord object every time it changes. And how did we screw up our tests so badly?
But this is a good chunk. We'll clean it up next time.
Source code changes here .