I'm working on a JavaFX + Spring Boot application using the MVVM pattern. We use ControlsFX ValidationSupport to validate fields in a login form. Our goal is to:
Show red error icons ("decorations") immediately when the page loads, if fields are empty or invalid
Keep the OK button disabled until all validation rules pass
Example:
We have a dialog to create a new user with these fields:
We register Validators like this:
@Component
public class ValidationHelper {
public void registerUserRegistrationValidations(ValidationSupport validationSupport, TextField userName,
PasswordField password, PasswordField repeatPassword) {
registerFocusLostValidation(userName, getEnteredUserNameDataLengthValidator(), validationSupport);
registerFocusLostValidation(password, getEnteredPasswdDataLengthValidator(), validationSupport);
registerFocusLostValidation(repeatPassword, getEnteredPasswdDataLengthValidator(), validationSupport);
registerFocusLostValidation(repeatPassword, getEnteredPasswordsEqualValidator(repeatPassword), validationSupport);
}
private Validator<Object> getEnteredUserNameDataLengthValidator() {
return Validator.createPredicateValidator(
userName -> userName != null && ((String) userName).length() > 2,
"user name too short");
}
private Validator<Object> getEnteredPasswdDataLengthValidator() {
return Validator.createPredicateValidator(
pin -> pin != null && ((String) pin).length() > 2,
"Password too short");
}
private Validator<Object> getEnteredPasswordsEqualValidator(PasswordField passwdField) {
return Validator.createPredicateValidator(
password -> password != null && password.equals(passwdField.getText()),
"Passwords do not match");
}
private <T> void registerFocusLostValidation(Control control, Validator<T> validator, ValidationSupport validationSupport) {
validationSupport.registerValidator(control, false, validator);
}
}
We also do this to bind a global flag:
BooleanBinding isInvalid = Bindings.createBooleanBinding(
() -> !validationSupport.getValidationResult().getErrors().isEmpty(),
validationSupport.validationResultProperty()
);
validationState.formInvalidProperty().bind(isInvalid);
Then in the footer controller:
okButton.disableProperty().bind(validationState.formInvalidProperty());
The Problem
This mostly works, but only after the user starts typing.
Initially:
We want the validation to appear as soon as the form is displayed, without user interaction. Attempts
Any ideas on how to make the validation trigger immediately and ensure the OK button behaves correctly from the start?
Tested with ControlsFX version 11.1.1 (or whatever you're using) and JavaFX 22.
Minimal Reproducible Example
ControlsFxApp.java
package controlsFx;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import java.io.IOException;
import java.net.URL;
@SpringBootApplication
public class ControlsFxApp extends Application {
private static final String RESOURCE = "sample.fxml";
private ConfigurableApplicationContext springContext;
@Override
public void init() {
springContext = new SpringApplicationBuilder(ControlsFxApp.class).run();
}
@Override
public void start(Stage primaryStage) throws Exception {
Parent root = load(RESOURCE);
Scene scene = new Scene(root);
primaryStage.setScene(scene);
primaryStage.show();
}
public Parent load(String fxmlPath) throws IOException {
URL location = getClass().getResource(fxmlPath);
FXMLLoader fxmlLoader = new FXMLLoader(location);
fxmlLoader.setControllerFactory(springContext::getBean);
return fxmlLoader.load();
}
}
FooterController.java
package controlsFx;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import org.springframework.stereotype.Controller;
@Controller
public class FooterController {
private final ValidationState validationState;
public Button register;
public FooterController(ValidationState validationState) {
this.validationState = validationState;
}
@FXML
private void initialize() {
register.setOnAction(event -> {
System.out.println("Validation requested for the current step...");
});
register.disableProperty().bind(validationState.formInvalidProperty());
}
}
RegistrationController.java
package controlsFx;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.fxml.FXML;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.GraphicValidationDecoration;
import org.springframework.stereotype.Controller;
@Controller
public class RegistrationController {
private final ValidationHelper validationHelper;
private final ValidationSupport validationSupport = new ValidationSupport();
private final ValidationState validationState;
public TextField userName;
public PasswordField password;
public PasswordField repeatPassword;
public RegistrationController(ValidationHelper validationHelper, ValidationState validationState) {
this.validationHelper = validationHelper;
this.validationState = validationState;
}
@FXML
public void initialize() {
validationSupport.setValidationDecorator(new GraphicValidationDecoration());
validationHelper.registerUserRegistrationValidations(validationSupport, userName, password, repeatPassword);
Platform.runLater(() -> {
validationSupport.revalidate();
BooleanBinding isInvalid = Bindings.createBooleanBinding(
() -> !validationSupport.getValidationResult().getErrors().isEmpty(),
validationSupport.validationResultProperty()
);
validationState.formInvalidProperty().bind(isInvalid);
});
}
}
ValidationHelper.java
package controlsFx;
import javafx.scene.control.Control;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.springframework.stereotype.Component;
@Component
public class ValidationHelper {
public void registerUserRegistrationValidations(ValidationSupport validationSupport, TextField userName,
PasswordField password, PasswordField repeatPassword) {
registerFocusLostValidation(userName, getEnteredUserNameDataLengthValidator(), validationSupport);
registerFocusLostValidation(password, getEnteredPasswdDataLengthValidator(), validationSupport);
registerFocusLostValidation(repeatPassword, getEnteredPasswdDataLengthValidator(), validationSupport);
registerFocusLostValidation(repeatPassword, getEnteredPasswordsEqualValidator(repeatPassword), validationSupport);
}
private Validator<Object> getEnteredUserNameDataLengthValidator() {
return Validator.createPredicateValidator(
userName -> userName != null && ((String) userName).length() > 2,
"user name too short");
}
private Validator<Object> getEnteredPasswdDataLengthValidator() {
return Validator.createPredicateValidator(
pin -> pin != null && ((String) pin).length() > 2,
"Password too short");
}
private Validator<Object> getEnteredPasswordsEqualValidator(PasswordField passwdField) {
return Validator.createPredicateValidator(
password -> password != null && password.equals(passwdField.getText()),
"Passwords do not match");
}
private <T> void registerFocusLostValidation(Control control, Validator<T> validator, ValidationSupport validationSupport) {
validationSupport.registerValidator(control, false, validator);
}
}
ValidationState.java
package controlsFx;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.springframework.stereotype.Component;
@Component
public class ValidationState {
private final BooleanProperty formInvalid = new SimpleBooleanProperty(true);
public BooleanProperty formInvalidProperty() {
return formInvalid;
}
}
center.fxml
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<AnchorPane xmlns:fx="http://javafx.com/fxml"
xmlns="http://javafx.com/javafx"
fx:controller="controlsFx.RegistrationController"
prefHeight="400.0" prefWidth="600.0">
<VBox AnchorPane.bottomAnchor="30" AnchorPane.leftAnchor="30" AnchorPane.rightAnchor="30"
AnchorPane.topAnchor="30">
<Label>User Name:</Label>
<TextField fx:id="userName" id="userName"/>
<Label>Password:</Label>
<PasswordField fx:id="password" id="password"/>
<Label>Repeat Password:</Label>
<PasswordField fx:id="repeatPassword" id="password"/>
</VBox>
</AnchorPane>
footer.xml
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.AnchorPane?>
<AnchorPane xmlns:fx="http://javafx.com/fxml"
xmlns="http://javafx.com/javafx" prefHeight="400.0" prefWidth="600.0"
fx:controller="controlsFx.FooterController">
<Button fx:id="register" layoutX="33.0" layoutY="187.0" prefHeight="25.0" prefWidth="534.0" text="Register"/>
</AnchorPane>
sample.fxml
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.text.Font?>
<BorderPane xmlns:fx="http://javafx.com/fxml" xmlns="http://javafx.com/javafx" prefWidth="200" prefHeight="200"
fx:id="borderPaneId">
<top>
<AnchorPane BorderPane.alignment="CENTER">
<Label text="Registration">
<font>
<Font size="24.0"/>
</font>
</Label>
<BorderPane.margin>
<Insets/>
</BorderPane.margin>
</AnchorPane>
</top>
<center>
<fx:include source="center.fxml"/>
</center>
<bottom>
<fx:include source="footer.fxml"/>
</bottom>
</BorderPane>
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>controlsFx</groupId>
<artifactId>validationdemo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>17</java.version>
<javafx.version>22</javafx.version>
</properties>
<dependencies>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-fxml</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.controlsfx</groupId>
<artifactId>controlsfx</artifactId>
<version>11.1.2</version>
</dependency>
</dependencies>
</project>
Answering my own question: Migrated to ValidatorFX – issue resolved
After some research and feedback (see ControlsFX issue #1588), I migrated from ControlsFX ValidationSupport to ValidatorFX, which solved the issue completely.
What I needed:
Validation errors shown immediately on load
OK button stays disabled until all inputs are valid
No manual runLater(...), revalidate() or formInvalidProperty().bind(...) gymnastics
The solution: use ValidatorFX
package controlsFx;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import net.synedra.validatorfx.Validator;
import org.springframework.stereotype.Component;
@Component
public class ValidationHelper {
public void registerUserRegistrationValidations(Validator validator, TextField userNameField,
PasswordField passwordField, PasswordField repeatPasswordField) {
String password = "password";
String userName = "userName";
String repeatPassword1 = "repeatPassword";
validator.createCheck()
.withMethod(c -> {
String name = c.get(userName);
if (name == null || name.length() <= 2) {
c.error("Error");
}
})
.dependsOn(userName, userNameField.textProperty())
.decorates(userNameField)
.immediate();
validator.createCheck()
.withMethod(c -> {
String pwd = c.get(password);
if (pwd == null || pwd.length() <= 2) {
c.error("Error");
}
})
.dependsOn(password, passwordField.textProperty())
.decorates(passwordField)
.immediate();
validator.createCheck()
.withMethod(c -> {
String pwd = c.get(password);
String repeat = c.get(repeatPassword1);
if (repeat == null || !repeat.equals(pwd)) {
c.error("Error");
}
})
.dependsOn(password, passwordField.textProperty())
.dependsOn(repeatPassword1, repeatPasswordField.textProperty())
.decorates(repeatPasswordField)
.immediate();
}
}
2. RegistrationController.java
package controlsFx;
import javafx.fxml.FXML;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import net.synedra.validatorfx.Validator;
import org.springframework.stereotype.Controller;
@Controller
public class RegistrationController {
private final ValidationHelper validationHelper;
private final Validator validator = new Validator();
private final ValidationState validationState;
public TextField userName;
public PasswordField password;
public PasswordField repeatPassword;
public RegistrationController(ValidationHelper validationHelper, ValidationState validationState) {
this.validationHelper = validationHelper;
this.validationState = validationState;
}
@FXML
public void initialize() {
validationHelper.registerUserRegistrationValidations(validator, userName, password, repeatPassword);
validationState.formInvalidProperty().bind(validator.containsErrorsProperty());
}
}
3. Add this to your pom.xml
<dependency>
<groupId>net.synedra</groupId>
<artifactId>validatorfx</artifactId>
<version>0.6.1</version>
</dependency>
And remove:
<dependency>
<groupId>org.controlsfx</groupId>
<artifactId>controlsfx</artifactId>
</dependency>