Featured Post

Applying Email Validation to a JavaFX TextField Using Binding

This example uses the same controller as in a previous post but adds a use case to support email validation.  A Commons Validator object is ...

Saturday, April 30, 2016

A JavaFX Wizard App

Complex forms are often arranged into a wizard format where the user gathers values by iterating through a sequence of screens.  This blog post shows how to implement such a pattern using JavaFX.  Each step is an individual .fxml / controller pair that contributes to a shared JavaFX object.


The source code for this example can be found on GitHub.

This is the second post in a three-part series on building a JavaFX wizard app.  The other posts are




This video shows the wizard in operation.


Three screens -- steps one, two, and three -- are followed by a confirmation that displays the information gathered by the wizard for final review by the user.  When the Next Button is pressed on the confirmation screen, the operation is confirmed by presenting the last screen in the wizard.

At any point, the user can reverse the iteration and edit previous values.  Additionally, the user can cancel the wizard at any time.  This will clear the form.

At the end of the wizard, the user can restart the sequence with an empty set of values.

Design


This UML diagram shows five .fxml / controller pairs: Step1, Step2, Step3, Confirmation, Completed.  The navigation is managed by WizardController.

UML Class Diagram of Wizard Application
WizardMain is the main entry point in the application.  Using Google Guice, a Guice AbstractModule is created (WizardModule).   This module contains a globally-accessible object "WizardData".  Guice is the mechanism used to make this object available throughout the program without requiring excessive coupling among the controller classes.

WizardController's .fxml file is an empty shell with an HBox for the content.  Depending on the position in the wizard, the root element of the different steps will be swapped out.

WizardController


Most of the non-boilerplate code in the app is in WizardController.  These are the fields in the class.

@FXML
VBox contentPanel;

@FXML
HBox hboxIndicators;

@FXML
Button btnNext, btnBack, btnCancel;

@Inject
Injector injector;

@Inject
WizardData model;

private final List<Parent> steps = new ArrayList< >();

private final IntegerProperty currentStep = new SimpleIntegerProperty(-1);


WizardController uses a set of Circles to track the wizard progress.  For information about this mechanism and the hboxIndicator field, visit this blog post.

The Injector object is from Google Guice.  This is going to enable me to build a JavaFX Controller Factory so that any JavaFX controllers created by FXML will have their dependencies injected.  For this app, that's limited to the WizardData object.

"steps" is a list of the Parents loaded in this class' @FXML initialize() method.  "currentStep" keeps track of the wizard progress and serves as an index to retrieve a particular step.

initialize()


This is the initialize() method of the WizardController.

@FXML
public void initialize() throws Exception {

 buildSteps();
 initButtons();
 buildIndicatorCircles();
 setInitialContent();
}

buildSteps() uses FXMLLoader to create the Parent objects that will be swapped in and out as content for WizardController.  It's using the expanded constructor so that the objects created will have their Google Guice dependencies (the WizardData object) injected.

private void buildSteps() throws java.io.IOException {

 final JavaFXBuilderFactory bf = new JavaFXBuilderFactory();

 final Callback<Class<?>, Object> cb = (clazz) -> injector.getInstance(clazz);

 Parent step1 = FXMLLoader.load( WizardController.class.getResource("/wizard-fxml/Step1.fxml"), null, bf, cb );
 Parent step2 = FXMLLoader.load( WizardController.class.getResource("/wizard-fxml/Step2.fxml"), null, bf, cb );
 Parent step3 = FXMLLoader.load( WizardController.class.getResource("/wizard-fxml/Step3.fxml"), null, bf, cb );
 Parent confirm = FXMLLoader.load( WizardController.class.getResource("/wizard-fxml/Confirm.fxml"), null, bf, cb );
 Parent completed = FXMLLoader.load( WizardController.class.getResource("/wizard-fxml/Completed.fxml"), null, bf, cb);

 steps.addAll( Arrays.asList(
   step1, step2, step3, confirm, completed
    ));
}

initButtons() uses JavaFX binding to configure the Buttons based on the currentStep.  btnBack and btnNext will be disabled if they are at the lower or upper bounds of the steps.  The binding will prevent the user from attempting to access something before the first step or after the last step.  btnCancel uses a different label at the last step.  At the last step, the operation is finished and "Cancel" is no longer appropriate.

private void initButtons() {
 btnBack.disableProperty().bind( currentStep.lessThanOrEqualTo(0) );
 btnNext.disableProperty().bind( currentStep.greaterThanOrEqualTo(steps.size()-1) );

 btnCancel.textProperty().bind(
  new When(
    currentStep.lessThan(steps.size()-1)
  )
    .then("Cancel")
    .otherwise("Start Over")
 );
}

buildIndicators() is covered in this post.  setInitialContent() prepares the initial screen.

private void setInitialContent() {
 currentStep.set( 0 );  // first element
 contentPanel.getChildren().add( steps.get( currentStep.get() ));
}

Wizard Operation


Navigation is implemented using two Buttons of WizardController: Next and Back.  Next moves through the sequence of steps and manages the tracking field "currentStep".  As currentStep changes, new screens area swapped in, the Circles indicator is updated, and the Buttons themselves may be changed via the binding mentioned in the previous section.

The next() and back() methods are also the places to put business logic.  For example, after a particular step executes, you might validate the WizardData object or save the WizardData object using a service call.

cancel() will manipulate the WizardData object and reset the wizard.

@FXML
public void next() {

 if( currentStep.get() < (steps.size()-1) ) {
  contentPanel.getChildren().remove( steps.get(currentStep.get()) );
  currentStep.set( currentStep.get() + 1 );
  contentPanel.getChildren().add( steps.get(currentStep.get()) );
 }
}

@FXML
public void back() {

 if( currentStep.get() > 0 ) {
  contentPanel.getChildren().remove( steps.get(currentStep.get()) );
  currentStep.set( currentStep.get() - 1 );
  contentPanel.getChildren().add( steps.get(currentStep.get()) );
 }
}

@FXML
public void cancel() {

 contentPanel.getChildren().remove( steps.get(currentStep.get()) );
 currentStep.set( 0 );  // first screen
 contentPanel.getChildren().add( steps.get(currentStep.get()) );

 model.reset();
}

The Steps


All of the steps, including the confirmation step, contain only a few fields and a binding expression put in the @FXML intialize() method.  The code for Step1 is presented below.

public class Step1Controller {

    @FXML
    TextField tfField1, tfField2, tfField3;

    @Inject
    WizardData model;

    @FXML
    public void initialize() {
        tfField1.textProperty().bindBidirectional( model.field1Property() );
        tfField2.textProperty().bindBidirectional( model.field2Property() );
        tfField3.textProperty().bindBidirectional( model.field3Property() );
    }
}

When the user updates tfField1, the first field in the first step, the model is also updated.  This update is retained as the user navigates through the wizard.  At any point in the operation, the full model can be examined or manipulated.  This helps to decrease coupling.

WizardData


public class WizardData {

    private final StringProperty field1 = new SimpleStringProperty();
    private final StringProperty field2 = new SimpleStringProperty();
    private final StringProperty field3 = new SimpleStringProperty();
    private final StringProperty field4 = new SimpleStringProperty();
    private final StringProperty field5 = new SimpleStringProperty();
    private final StringProperty field6 = new SimpleStringProperty();
    private final StringProperty field7 = new SimpleStringProperty();
//...

The WizardData class contains JavaFX properties that enable binding.  This class is used as a singleton as implemented by Google Guice.  The Google Guice module says that any @Inject annotated WizardData fields will receive the shared object.

public class WizardModule extends AbstractModule {
    @Override
    protected void configure() {
        WizardData model = new WizardData();
        bind(WizardData.class).toInstance(model);
    }
}

Enhancements


next() and back() provide an entry point for validations and other business logic.  An enhancement to this app might delegate this to the relevant step.  For example, next() might call a controller's validate() method and display an error message and prevent the navigation if a field is missing.  The controllers -- which are very plain in this implementation -- might also be marked-up with a pre and post navigation handler.

This blog post showed you how to build your own JavaFX wizard with a little help from Google Guice.  CDI doesn't have the same adoption in desktop Java as it does on the server-side, but the 668k and few lines of code to enable Guice in a JavaFX app seems like a simple enough addition to justify a consistent pattern.

No comments:

Post a Comment