JavaFX with Gradle, Eclipse, Scene Builder and OpenJDK 11: Java Coded Components

By Adam McQuistan in Java  07/31/2019 Comment

 JavaFX Development with Gradle, Eclipse, SceneBuilder and OpenJDK 11: Java Coded Components

Introduction

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.

Series Contents

A Closer Look at the Hello World App

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.

hello world javafx

Number Generator Application Design

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.

Controllers Class Diagram

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.

Data Model Class Diagram

Controlling Application Flow with FrontController

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(...).

Gradle Resources

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.

Random Number Generator View

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);
}

JavaFX GridPane with Visible Grid Lines

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;
}

Numbers List View

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;
}

Resources to Learn More About Java and JavaFX

Conclusion

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.

 

Share with friends and colleagues

[[ likes ]] likes

Navigation

Community favorites for Java

theCodingInterface