In this second article I will be building out the Random Number generator app described in the opening blog post to give a gentle introduction of the JavaFX framework. For this article I will be forgoing the use of FXML views and the Scene Builder design tool to give the reader a taste of how a Java code only approach is done. In the next post I will refactor using Scene Builder and FXML view files to contrast this approach.
The code for the Random Number demo app is hosted on GitHub for following along and experimentation.
To begin I dig into how the JavaFX framework functions from a high level using the simple HelloFX.java sample program from the openjfx GitHub repo. Below is the JavaFX program replicated in the RandomNumberApp.java class as presented in the previous article.
The defining feature that makes this a JavaFX application is the fact that RandomNumberApp class extends the javafx.applicaiton.Application
abstract class and provides an implementation of its Application#start
method.
// RandomNumberApp.java
package com.thecodinginterface.randomnumber;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class RandomNumberApp extends Application {
@Override
public void start(Stage stage) {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
Label l = new Label("Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".");
Scene scene = new Scene(new StackPane(l), 640, 480);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
}
The RandomNumberApp#main
method serves as the entry point that starts up the JavaFX runtime by calling the Application's launch method. During the JavaFX start up sequence the RandomNumberApp gets constructed and the start method is called and passed an instance of the javafx.stage.Stage
class. The Stage instance serves as the graphical foundation of a JavaFX application and represents the OS specific parent window.
Inside the RandomNumberApp#start
method the Java and JavaFX versions are retrieved via the System class and used to compose a greeting message which is passed to a JavaFX control in the form of a Label class. In JavaFX applications the graphical display is implemented as a tree data structure with the nodes representing graphical elements.
The root node of the tree of graphical elements is a special JavaFX class, javafx.scene.Scene
, which serves as the base of what is known as the Scene Graph planted in the aforementioned Stage object. In the above example the Scene Graph consists of just three elements with the root being the StackPane layout node serving as the parent to the Label leaf node.
Now that I've described the basics of how a graphical display is constructed in JavaFX I dive into explaining the design I will be using. In building the Random Number app I will utilize the Model-View-Controller (MVC) design pattern which fits nicely into the overall architecture of the JavaFX framework.
Specificly, I will use a flavor of the MVC pattern which utilizes whats known as a Front Controller that coordinates the swapping of main view contents among other controllers. From the user's prespective this swapping of primary view components is perceived as changing pages. Below is a class diagram for the controllers to be implemented off of.
The two primary use cases within the application are: (i) to generate a random number between a user specified lower and upper bounds and, (ii) view the list of random numbers a user has generated. To maintain state in the application I will utilize a RandomNumber
POJO class to holds the bounds, random number, and the date it was created.
The instances of RandomNumber will be managed by a Data Access Object (DAO) interface named RandomNumberDAO
. Right now RandomNumberDAO
will be implemented as an in memory list of objects within an implementation class named LocalRandomNumberDAO
, perhaps later that could be SQLiteRandomNumberDAO
or FileRandomNumberDAO
but, I'll leave those to a later post. Additionally, I will create a special class known as a View Model which utilizes JavaFX properties field types to display the data in RandomNumber objects. Below is the class diagram for the remaining classes.
To begin I will add a new package in the project by right clicking on the project's com.thecodinginterface.randomnumber package, selecting New > Package and adding a subpackage named controllers. Then I similarly add the new Java class controllers inside the controllers package named FrontController.java, BaseController.java, NumberGeneratorController.java and, NumbersViewController.java.
For the Front Controller design I have chosen to implement the class as a singleton and, from the class diagram you can see that the FrontController holds a reference to the Stage that was passed to the RandomNumberApp#start
method during the launch process. FrontController also contains the single parent layout class node, javafx.scene.layout.BorderPane
, which will be the root layout node for the Scene object. BorderPane
is a very flexible and popular choice among JavaFX applications and is composed of 5 sections capable of holding child nodes. Below is a depiction highlighting the organization of the BorderPane
class.
-------------------------------
| Top |
|-----------------------------|
| | | |
| | | |
| Left | Center | Right |
| | | |
| | | |
|-----------------------------|
| Bottom |
-------------------------------
Below is the implementation for the FrontController class.
// FrontController.java
package com.thecodinginterface.randomnumber.controllers;
import java.net.URL;
import com.thecodinginterface.randomnumber.RandomNumberApp;
import com.thecodinginterface.randomnumber.repository.RandomNumberDAO;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;
public class FrontController {
public static final double APP_WIDTH = 500;
public static final double APP_HEIGHT = 350;
private static final FrontController INSTANCE = new FrontController();
private RandomNumberDAO randomNumberDAO;
private Stage primaryStage;
private BorderPane rootBorderPane;
private FrontController() {
rootBorderPane = new BorderPane();
rootBorderPane.setTop(makeNavBar());
}
public static FrontController getInstance() {
return INSTANCE;
}
private HBox makeNavBar() {
var generateNumbersBtn = new ToggleButton("Generate Number");
var viewNumbersBtn = new ToggleButton("View Numbers");
var toggleGroup = new ToggleGroup();
generateNumbersBtn.setToggleGroup(toggleGroup);
viewNumbersBtn.setToggleGroup(toggleGroup);
generateNumbersBtn.setSelected(true);
generateNumbersBtn.getStyleClass().add("navbar-btn");
viewNumbersBtn.getStyleClass().add("navbar-btn");
generateNumbersBtn.setOnAction(evt -> showNumberGeneratorView());
viewNumbersBtn.setOnAction(evt -> showNumbersListView());
var imageView = new ImageView(new Image(
RandomNumberApp.class.getResourceAsStream("images/TCI-Logo.png")));
imageView.setPreserveRatio(true);
imageView.setFitWidth(58);
var logoLbl = new Label("", imageView);
logoLbl.getStyleClass().add("navbar-logo");
var hbox = new HBox(logoLbl, generateNumbersBtn, viewNumbersBtn);
hbox.getStyleClass().add("navbar");
return hbox;
}
public void setStage(Stage primaryStage) {
this.primaryStage = primaryStage;
var scene = new Scene(rootBorderPane, APP_WIDTH, APP_HEIGHT);
URL url = RandomNumberApp.class.getResource("styles/styles.css");
scene.getStylesheets().add(url.toExternalForm());
this.primaryStage.setScene(scene);
}
public void setRandomNumberDAO(RandomNumberDAO randomNumberDAO) {
this.randomNumberDAO = randomNumberDAO;
}
public RandomNumberDAO getRandomNumberDAO() {
return randomNumberDAO;
}
public void showNumberGeneratorView() {
updateContent(new NumberGeneratorController());
}
public void showNumbersListView() {
updateContent(new NumbersViewController());
}
private void updateContent(BaseController ctrl) {
ctrl.setFrontController(this);
rootBorderPane.setCenter(ctrl.getContentPane());
}
public void showStage() {
if (!primaryStage.isShowing()) {
primaryStage.show();
}
}
}
I'd like to begin by focusing on the FrontController#setStage(Stage)
method since this is basically going to serve as the entry point of where the RandomNumberApp
class hands over control of the app to the FrontController.
public void setStage(Stage primaryStage) {
this.primaryStage = primaryStage;
var scene = new Scene(rootBorderPane, APP_WIDTH, APP_HEIGHT);
URL url = RandomNumberApp.class.getResource("styles/styles.css");
scene.getStylesheets().add(url.toExternalForm());
this.primaryStage.setScene(scene);
}
The class member Stage reference is set followed by creation of a Scene object which is given the root node named BorderPane
with the app dimensions. Next a URL
instance is created by referencing a CSS style sheet resource (yes, CSS for a desktop app, pretty cool right?!) and attached to the Scene
instance that was just created. This styles/styles.css file lives in src/main/resources directory along with a images/TCI-Logo.png image as shown below. Note that both of these are within the root package com.thecodinginterface.randomnumber which enables resources to be built using RandomNumberApp.class.getResource(...)
.
Now let me focus on the FrontController#makeNavBar
method which is used in the private constructor to return a HBox
node representing a navbar like component assigning it to the top section of the BorderPane
. The first thing you see is the creation of two ToggleButton
instances representing navigable controls for transitioning between the views. The ToggleButtons are associated with a ToggleGroup
which enforces that only one can be in a selected state at a time which by default will be the random number generating view controlled by the generateNumbersBtn
ToggleButton.
private HBox makeNavBar() {
var generateNumbersBtn = new ToggleButton("Generate Number");
var viewNumbersBtn = new ToggleButton("View Numbers");
var toggleGroup = new ToggleGroup();
generateNumbersBtn.setToggleGroup(toggleGroup);
viewNumbersBtn.setToggleGroup(toggleGroup);
generateNumbersBtn.setSelected(true);
generateNumbersBtn.getStyleClass().add("navbar-btn");
viewNumbersBtn.getStyleClass().add("navbar-btn");
generateNumbersBtn.setOnAction(evt -> showNumberGeneratorView());
viewNumbersBtn.setOnAction(evt -> showNumbersListView());
var imageView = new ImageView(new Image(
RandomNumberApp.class.getResourceAsStream("images/TCI-Logo.png")));
imageView.setPreserveRatio(true);
imageView.setFitWidth(58);
var logoLbl = new Label("", imageView);
logoLbl.getStyleClass().add("navbar-logo");
var hbox = new HBox(logoLbl, generateNumbersBtn, viewNumbersBtn);
hbox.getStyleClass().add("navbar");
return hbox;
}
Next I add a couple of CSS class style definitions of navbar-btn to the toggle buttons. JavaFX CSS styling is a subset of what is used in the browser and all begin with the -fx prefix. I will not be going into detail here on the JavaFX style system so, here is a link to the JavaFX CSS docs. In the mean time below are the .navbar-btn CSS class definitions.
/* flat button with off-white / gray text and dark blue background */
.navbar-btn {
-fx-background-radius: 0;
-fx-background-color: #426ab7;
-fx-padding: 10 20 10 20;
-fx-text-fill: #c7d1d8;
-fx-font-size: 14px;
}
/* hover transitions to white text */
.navbar-btn:hover {
-fx-text-fill: white;
}
/*
* selected pseudo class is specific to toggle button,
* the border style rules put a solid white under line in
*/
.navbar-btn:selected {
-fx-border-width: 0 0 4 0;
-fx-border-color: white;
-fx-text-fill: white;
}
After adding the CSS classes I attach click ActionEvent
handlers via ToggleButton#setOnAction(ActionEvent)
using lambda functions to call either showNumberGeneratorView()
or showNumbersListView()
methods leading to changing of the main view components.
Next I move on to creating an ImageView
wrapped in a Label to present The Coding Interface's logo Image. An ImageView is a specialized node for interacting with and displaying images in JavaFX. Here I utilize the ImageView#setPreserveRatio(boolean)
method to maintain image aspect ratio before calling ImageView#setFitWidth(double)
to tell it to resize the image to 58 pixels wide. The ImageView instance is wrapped in a Label
instance containing an empty string and the ImageView graphic. Another CSS class for the logo is used to give it some padding as shown below.
/* pad the logo label (top, right, bottom, left) */
.navbar-logo {
-fx-padding: 2 20 2 10;
}
Lastly, the nodes are all added to a HBox, which lays out its child nodes horizontally, then a style is added to give it some padding, a nice blue background and, align its content centered vertically and left horizontally. This is then returned to the caller of the makeNavBar
method.
/*
* add blue background, give no border, and align
* content center (vertically) and left (horizontally)
*/
.navbar {
-fx-background-color: #426ab7;
-fx-padding: 0 10 0 10;
-fx-border-width: 0;
-fx-alignment: center-left;
}
Moving on I'll focus on the methods that are used to instantiate the two main view controllers, NumberGeneratorController
and NumbersViewController
, and swap out the BorderPane's center component with new UI elements. To start take a look at the FrontController#updateContent(BaseController)
method which accepts the instantiated instance of BaseController
then calls its BaseController#getContentPane
method setting the returned AnchorPane
as the center component.
public void showNumberGeneratorView() {
updateContent(new NumberGeneratorController());
}
public void showNumbersListView() {
updateContent(new NumbersViewController());
}
private void updateContent(BaseController ctrl) {
ctrl.setFrontController(this);
rootBorderPane.setCenter(ctrl.getContentPane());
}
Don't worry if that last bit seemed pretty vague, I will explain what is going on in those two view controllers soon. For right now I just provide them as stubs that return empty AnchorPanes as shown below.
NumberGeneratorController.java
package com.thecodinginterface.randomnumber.controllers;
import javafx.scene.layout.AnchorPane;
public class NumberGeneratorController extends BaseController {
@Override
AnchorPane getContentPane() {
return new AnchorPane();
}
}
NumbersViewController.java
package com.thecodinginterface.randomnumber.controllers;
import javafx.scene.layout.AnchorPane;
public class NumbersViewController extends BaseController {
@Override
AnchorPane getContentPane() {
return new AnchorPane();
}
}
Then over in RandomNumberApp.java I put the FrontController
into action by getting it's singleton instance, setting the Stage
, telling it to load the NumberGeneratorController
controller's AnchorPane
content and show the stage as seen below.
package com.thecodinginterface.randomnumber;
import com.thecodinginterface.randomnumber.controllers.FrontController;
import javafx.application.Application;
import javafx.stage.Stage;
public class RandomNumberApp extends Application {
@Override
public void start(Stage stage) {
FrontController frontController = FrontController.getInstance();
frontController.setStage(stage);
frontController.showNumberGeneratorView();
frontController.showStage();
}
public static void main(String[] args) {
launch();
}
}
Here is the result of running the app.
The random number generating view is a simple form where a user can enter a set of min and max values and click a button to get a random number which is displayed and saved to a list that will be displayed in the numbers list view screen. To start I will provide the implementations of the RandomNumber
class, along with RandomNumberDAO
interface, LocalRandomNumberDAO
class and a simple utility class that I'll use to generate the numbers. Since this is just regular ole Java, not specific to JavaFX, I'll be pretty sparse on their details.
Inside of a new package I've namded repository I have placed the RandomNumberDAO.java interface code,
package com.thecodinginterface.randomnumber.repository;
import java.util.List;
import com.thecodinginterface.randomnumber.models.RandomNumber;
public interface RandomNumberDAO {
boolean save(RandomNumber number);
void loadNumbers();
List<RandomNumber> getNumbers();
}
and LocalRandomNumberDAO.java class code.
package com.thecodinginterface.randomnumber.repository;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import com.thecodinginterface.randomnumber.models.RandomNumber;
public class LocalRandomNumberDAO implements RandomNumberDAO {
private List<RandomNumber> numbers = new ArrayList<>();
@Override
public boolean save(RandomNumber number) {
return numbers.add(number);
}
@Override
public void loadNumbers() {
}
@Override
public List<RandomNumber> getNumbers() {
return Collections.unmodifiableList(numbers);
}
}
In another new package named models I've place the RandomNumber.java source.
package com.thecodinginterface.randomnumber.models;
import java.time.LocalDate;
public class RandomNumber {
private LocalDate createdAt;
private int number;
private int lowerBounds;
private int upperBounds;
public RandomNumber() {}
public RandomNumber(LocalDate createdAt, int number, int lowerBounds, int upperBounds) {
this.createdAt = createdAt;
this.number = number;
this.lowerBounds = lowerBounds;
this.upperBounds = upperBounds;
}
public void setCreatedAt(LocalDate createdAt) {
this.createdAt = createdAt;
}
public LocalDate getCreatedAt() {
return createdAt;
}
public void setNumber(int number) {
this.number = number;
}
public int getNumber() {
return number;
}
public void setLowerBounds(int lowerBounds) {
this.lowerBounds = lowerBounds;
}
public int getLowerBounds() {
return lowerBounds;
}
public void setUpperBounds(int upperBounds) {
this.upperBounds = upperBounds;
}
public int getUpperBounds() {
return upperBounds;
}
}
Over in another package named utils resides the RandomNumberUtils.java source.
package com.thecodinginterface.randomnumber.utils;
import java.util.Random;
public class RandomNumberUtils {
private static Random random = new Random();
public static int boundedRandomNumber(int lowerBounds, int upperBounds) {
return random.nextInt((upperBounds - lowerBounds) + 1) + lowerBounds;
}
}
Finally, I can update the RandomNumberApp#main
method to instantiate the LocalRandomNumberDAO
and give it to the Front Controller like so.
package com.thecodinginterface.randomnumber;
import com.thecodinginterface.randomnumber.controllers.FrontController;
import com.thecodinginterface.randomnumber.repository.LocalRandomNumberDAO;
import com.thecodinginterface.randomnumber.repository.RandomNumberDAO;
import javafx.application.Application;
import javafx.stage.Stage;
public class RandomNumberApp extends Application {
@Override
public void start(Stage stage) {
FrontController frontController = FrontController.getInstance();
frontController.setStage(stage);
RandomNumberDAO randomNumberDAO = new LocalRandomNumberDAO();
randomNumberDAO.loadNumbers();
frontController.setRandomNumberDAO(randomNumberDAO);
frontController.showNumberGeneratorView();
frontController.showStage();
}
public static void main(String[] args) {
launch();
}
}
Ok, I can now get down to the business of making the random number view which means building out the RandomNumberGeneratorController
. Below is the completed source code for it.
package com.thecodinginterface.randomnumber.controllers;
import java.time.LocalDate;
import com.thecodinginterface.randomnumber.models.RandomNumber;
import com.thecodinginterface.randomnumber.utils.RandomNumberUtils;
import javafx.geometry.HPos;
import javafx.geometry.Pos;
import javafx.scene.Group;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
public class NumberGeneratorController extends BaseController {
private static final String NUMBER_PLACEHOLDER = "###";
private TextField minValTextField = new TextField();
private TextField maxValTextField = new TextField();
private Label resultLbl = new Label(NUMBER_PLACEHOLDER);
@Override
AnchorPane getContentPane() {
var gridPane = new GridPane();
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.addRow(0, new Label("Min Value"), minValTextField);
gridPane.addRow(1, new Label("Max Value"), maxValTextField);
var resultBtn = new Button("Generate Number");
var clearBtn = new Button("Clear");
var buttonBar = new ButtonBar();
buttonBar.getButtons().addAll(resultBtn, clearBtn);
gridPane.add(buttonBar, 0, 2, 2, 1);
GridPane.setHalignment(buttonBar, HPos.CENTER);
resultBtn.disableProperty().bind(
minValTextField.textProperty().isEmpty().or(
maxValTextField.textProperty().isEmpty()
)
);
resultBtn.setOnAction(evt -> {
int lowerBounds = 0;
int upperBounds = 1;
try {
lowerBounds = Integer.valueOf(minValTextField.getText());
upperBounds = Integer.valueOf(maxValTextField.getText());
} catch (NumberFormatException e) {
e.printStackTrace();
}
int number = RandomNumberUtils.boundedRandomNumber(
lowerBounds,
upperBounds
);
var randomNumber = new RandomNumber(
LocalDate.now(),
number,
lowerBounds,
upperBounds
);
if (frontController.getRandomNumberDAO().save(randomNumber)) {
resultLbl.setText(String.valueOf(number));
}
});
clearBtn.setOnAction(evt -> {
minValTextField.setText(null);
maxValTextField.setText(null);
resultLbl.setText(NUMBER_PLACEHOLDER);
});
var vbox = new VBox();
vbox.setAlignment(Pos.CENTER);
var hbox = new HBox(resultLbl);
hbox.setAlignment(Pos.CENTER);
vbox.getChildren().addAll(hbox, gridPane);
resultLbl.getStyleClass().add("result-label");
var group = new Group(vbox);
var stackPane = new StackPane(group);
StackPane.setAlignment(group, Pos.CENTER);
var anchorPane = new AnchorPane(stackPane);
AnchorPane.setTopAnchor(stackPane, 10.0);
AnchorPane.setBottomAnchor(stackPane, 10.0);
AnchorPane.setLeftAnchor(stackPane, 10.0);
AnchorPane.setRightAnchor(stackPane, 10.0);
return anchorPane;
}
}
To start unpacking what is going on here in NumberGeneratorController
lets just start at the top and work our way down. Two TextField
instance members are created which will serve as inputs for the min and max bounds then there is the result Label
field for displaying the random number. The resultLbl instance is first set to the constant class field NUMBER_PLACEHOLDER
of ###.
private static final String NUMBER_PLACEHOLDER = "###";
private TextField minValTextField = new TextField();
private TextField maxValTextField = new TextField();
private Label resultLbl = new Label(NUMBER_PLACEHOLDER);
Jumping down into getContentPane()
method I have instantiated a GridPane
which is super useful for laying out forms. Below that I specify that the spacing between rows (setVgap) and columns (setHgap) in the GridPane
should be 10 pixels. After that I add the first row of controls for the min value using GridPane#addRow(int rowIndex, Node... children)
then do the same for the row of max controls.
var gridPane = new GridPane();
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.addRow(0, new Label("Min Value"), minValTextField);
gridPane.addRow(1, new Label("Max Value"), maxValTextField);
After adding the min / max control rows I create a couple of buttons for generating the number or clearing the inputs then add them to a ButtonBar
which serves to nicely group a set of buttons. The ButtonBar is then added to the GridPane
below the other controls but, this time using the GridPane#add(Node child, int rowIndex, int colIndex, int colspan, int rowspan)
method which translates to saying, put the ButtonBar
in the first column, third row, spanning 2 columns. I then use the static method GridPane#setHalignment(Node, HPos)
to center the ButtonBar
within the two column row.
var resultBtn = new Button("Generate Number");
var clearBtn = new Button("Clear");
var buttonBar = new ButtonBar();
buttonBar.getButtons().addAll(resultBtn, clearBtn);
gridPane.add(buttonBar, 0, 2, 2, 1);
GridPane.setHalignment(buttonBar, HPos.CENTER);
In case you were wondering what happened to using JavaFX CSS for the layout adjustments like alignment along with horizontal and vertical spacing these can still be specified in CSS. I'm just showing how you can set them in your Java code also. Here is a link to the GridPane JavaFX CSS rules that you can use to do the same.
A useful feature for experimenting with GridPane
layout while developing is to pass true
to the GridPane#setGridLinesVisible(boolean)
method to make clear what is happening. For example, changing the getContentPane()
to this results in the following.
@Override
AnchorPane getContentPane() {
var gridPane = new GridPane();
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.addRow(0, new Label("Min Value"), minValTextField);
gridPane.addRow(1, new Label("Max Value"), maxValTextField);
var resultBtn = new Button("Generate Number");
var clearBtn = new Button("Clear");
var buttonBar = new ButtonBar();
buttonBar.getButtons().addAll(resultBtn, clearBtn);
gridPane.add(buttonBar, 0, 2, 2, 1);
GridPane.setHalignment(buttonBar, HPos.CENTER);
// for temporary viewing
gridPane.setGridLinesVisible(true);
gridPane.setPadding(new Insets(10, 10, 10, 10));
return new AnchorPane(gridPane);
}
Back to the original implementation now. The next section of code after populating the GridPane
focusses on validating that the Generate Number button cannot be pressed until both the min and max text fields have some input in them. More validation is probably advisable like making sure they are numbers but, this gets the point across that you can programmatically disable a button by binding the Button's javafx.beans.property.ReadOnlyBooleanProperty
disabled property to other javafx.bean.Observable
implementing properties.
resultBtn.disableProperty().bind(
minValTextField.textProperty().isEmpty().or(
maxValTextField.textProperty().isEmpty()
)
);
Following that bit of code I set a couple of ActionEvent
handlers to the buttons to handle the action of clicking them. Both are implemented using lambdas with the resultBtn (aka, Generate Number) button handing parsing the values out of the inputs, converting them to ints then using them to create a random number with those bounds before creating an instance of RandomNumber, saving it using LocalRandomNumberDAO
and, updating the resultLbl label. The clearBtn handler simply clears the controls.
resultBtn.setOnAction(evt -> {
int lowerBounds = 0;
int upperBounds = 1;
try {
lowerBounds = Integer.valueOf(minValTextField.getText());
upperBounds = Integer.valueOf(maxValTextField.getText());
} catch (NumberFormatException e) {
e.printStackTrace();
}
int number = RandomNumberUtils.boundedRandomNumber(
lowerBounds,
upperBounds
);
var randomNumber = new RandomNumber(
LocalDate.now(),
number,
lowerBounds,
upperBounds
);
if (frontController.getRandomNumberDAO().save(randomNumber)) {
resultLbl.setText(String.valueOf(number));
}
});
clearBtn.setOnAction(evt -> {
minValTextField.setText(null);
maxValTextField.setText(null);
resultLbl.setText(NUMBER_PLACEHOLDER);
});
The remainder of the getContentPane()
method deals with wrapping the GridPane
and result Label
in appropriate layout containers then enclosing them in an AnchorPane
and returning it. The resultLbl is given a custom JavaFX CSS class shown below.
/* center align the text, make it big and bold */
.result-label {
-fx-text-alignment: center;
-fx-font-size: 48px;
-fx-font-weight: bold;
-fx-padding: 0 10 30 10;
}
The Label
is placed in an HBox
that is then added to a VBox
instance which is used for laying out nodes in a vertial top to bottom fashion. The alignment of both are set to Pos.CENTER
like so.
var vbox = new VBox();
vbox.setAlignment(Pos.CENTER);
var hbox = new HBox(resultLbl);
hbox.setAlignment(Pos.CENTER);
vbox.getChildren().addAll(hbox, gridPane);
resultLbl.getStyleClass().add("result-label");
The VBox
is added to a Group
node which is then placed in a StackPane
and centered. The StackPane
is then anchored 10 pixels from the sides of the final AnchorPane
which has the effect of making the scene nodes resize as the window does.
var group = new Group(vbox);
var stackPane = new StackPane(group);
StackPane.setAlignment(group, Pos.CENTER);
var anchorPane = new AnchorPane(stackPane);
AnchorPane.setTopAnchor(stackPane, 10.0);
AnchorPane.setBottomAnchor(stackPane, 10.0);
AnchorPane.setLeftAnchor(stackPane, 10.0);
AnchorPane.setRightAnchor(stackPane, 10.0);
return anchorPane;
The final Generate Number view is shown below which includes some more custom JavaFX CSS for the buttons to give them an appealing flat responsive look.
.button {
-fx-background-color: #03a8f8;
-fx-background-radius: 0;
-fx-padding: 10 20 10 20;
-fx-text-fill: white;
-fx-font-size: 12px;
}
.button:hover {
-fx-background-color: #426ab7;
}
.button:pressed {
-fx-text-fill: black;
}
The Number List view is just a presentation view (no input controls) which utilizes the generic JavaFX TableView to display a table of numbers that have been generated.
package com.thecodinginterface.randomnumber.controllers;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
import com.thecodinginterface.randomnumber.viewmodels.RandomNumberViewModel;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
public class NumbersViewController extends BaseController {
@Override
AnchorPane getContentPane() {
List<RandomNumberViewModel> numbers = frontController.getRandomNumberDAO()
.getNumbers()
.stream()
.map(RandomNumberViewModel::new)
.collect(Collectors.toList());
var table = new TableView<RandomNumberViewModel>(
FXCollections.observableArrayList(numbers));
table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
var numberColumn = new TableColumn<RandomNumberViewModel, Integer>("Number");
var lowerBoundsColumn = new TableColumn<RandomNumberViewModel, Integer>("Min Value");
var upperBoundsColumn = new TableColumn<RandomNumberViewModel, Integer>("Max Value");
var createdAtColumn = new TableColumn<RandomNumberViewModel, LocalDate>("Created");
numberColumn.setCellValueFactory(cell ->
cell.getValue().numberProperty().asObject());
lowerBoundsColumn.setCellValueFactory(cell ->
cell.getValue().lowerBoundsProperty().asObject());
upperBoundsColumn.setCellValueFactory(cell ->
cell.getValue().upperBoundsProperty().asObject());
createdAtColumn.setCellValueFactory(cell ->
cell.getValue().createdAtProperty());
createdAtColumn.setCellFactory(column -> {
return new TableCell<RandomNumberViewModel, LocalDate>() {
@Override
public void updateItem(final LocalDate item, boolean empty) {
if (item == null || empty) {
setText(null);
} else {
setText(RandomNumberViewModel.formatDate(item));
}
}
};
});
table.getColumns().add(numberColumn);
table.getColumns().add(lowerBoundsColumn);
table.getColumns().add(upperBoundsColumn);
table.getColumns().add(createdAtColumn);
var vbox = new VBox();
vbox.setPadding(new Insets(10, 5, 10, 5));
vbox.setAlignment(Pos.CENTER);
vbox.getChildren().add(table);
var stackPane = new StackPane(vbox);
StackPane.setAlignment(vbox, Pos.CENTER);
var anchorPane = new AnchorPane(stackPane);
AnchorPane.setTopAnchor(stackPane, 10.0);
AnchorPane.setBottomAnchor(stackPane, 10.0);
AnchorPane.setLeftAnchor(stackPane, 10.0);
AnchorPane.setRightAnchor(stackPane, 10.0);
return anchorPane;
}
}
The first thing that the NumbersViewController#getContentPane method does is fetch all the RandomNumber instances from the RandomNumberDao and maps them to RandomNumberViewModel view models which are more amenable to working with a TableView. Next the TableView, typed to the RandomNumberViewModel class, is instantiated and passed an instance of javafx.collections.ObservableList which is easily built using the factory method FXCollections#observableArrayList(Collection). The use of ObservableList as the collection allows for reactivity between changes in the underlying colleciton and automatic view updates in the table.
List<RandomNumberViewModel> numbers = frontController.getRandomNumberDAO()
.getNumbers()
.stream()
.map(RandomNumberViewModel::new)
.collect(Collectors.toList());
var table = new TableView<RandomNumberViewModel>(
FXCollections.observableArrayList(numbers));
The next few lines configure the table columns that are displayed beginning with telling the TableView to automatically set the column sizes to be equal and fill up the full width of the table.
table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
TableColumn<S, T>
instances are then created by matching the type of the TableView
(RandomNumberViewModel) and that of the column data being displayed along with a string argument for the constructors representing the column heading. After the column objects are instantiated they are configured with cell value factories which require javafx.util.Callback
parameters that I've implemented as lambdas returning the properties that are to be displayed in each cell of the column.
var numberColumn = new TableColumn<RandomNumberViewModel, Integer>("Number");
var lowerBoundsColumn = new TableColumn<RandomNumberViewModel, Integer>("Min Value");
var upperBoundsColumn = new TableColumn<RandomNumberViewModel, Integer>("Max Value");
var createdAtColumn = new TableColumn<RandomNumberViewModel, LocalDate>("Created");
numberColumn.setCellValueFactory(cell ->
cell.getValue().numberProperty().asObject());
lowerBoundsColumn.setCellValueFactory(cell ->
cell.getValue().lowerBoundsProperty().asObject());
upperBoundsColumn.setCellValueFactory(cell ->
cell.getValue().upperBoundsProperty().asObject());
createdAtColumn.setCellValueFactory(cell ->
cell.getValue().createdAtProperty());
The very last Created column of type LocalDate
requires a little special treatment to display the date object as a readable string. This is accomplished by defining a custom TableCell
factory as an anonymous class which utilizes the RandomNumberViewModel#formatDate
static method to format the LocalDate
instance to a string.
createdAtColumn.setCellFactory(column -> {
return new TableCell<RandomNumberViewModel, LocalDate>() {
@Override
public void updateItem(final LocalDate item, boolean empty) {
if (item == null || empty) {
setText(null);
} else {
setText(RandomNumberViewModel.formatDate(item));
}
}
};
});
At this point all the columns have been created and equiped with the ability to fish out the data needed from the view model class for presentation so, they can be added to the TableView
like so.
table.getColumns().add(numberColumn);
table.getColumns().add(lowerBoundsColumn);
table.getColumns().add(upperBoundsColumn);
table.getColumns().add(createdAtColumn);
The remainder is again just wrapping the TableView
control in appropriate layout nodes, anchoring them to the AnchorPane
edges before returning it.
Below is the final product which includes a few additional CSS rules to give the column headers a blue background with white text labels.
.table-view .column-header,
.table-view .column-header .filler,
.table-view .column-header .label,
.table-view .column-header-background .filler {
-fx-background-color: #03a8f8;
-fx-text-fill: white;
}
For this second article in a series on building desktop applications using Java (OpenJDK 11) along with JavaFX I have focussed on explaining the basics of how a JavaFX application works and demonstrating how to build a demo Random Number generating app utilizing Java coded JavaFX components. In the following article I will be refactoring this article's app to demonstrate how to use FXML view files in conjunction with Gluon's awesome SceneBuilder.
As always, thanks for reading and don't be shy about commenting or critiquing below.