JavaFX provides a powerful framework for creating custom controls that extend beyond the built-in components. Custom controls allow you to create reusable, maintainable, and sophisticated UI elements tailored to your application's specific needs.
Understanding Custom Control Approaches
There are three main approaches to creating custom controls in JavaFX:
- Custom Node - Extending existing nodes
- Custom Control - Extending Control class with separate skin
- Composite Control - Combining existing nodes
Basic Custom Node Example
Example 1: Simple Custom Button
import javafx.scene.control.Button;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
public class CircularButton extends Button {
public CircularButton(String text) {
super(text);
initialize();
}
public CircularButton() {
super();
initialize();
}
private void initialize() {
// Set circular shape
setShape(new Circle(25));
setMinSize(50, 50);
setMaxSize(50, 50);
// Custom style
setStyle("-fx-background-color: #4CAF50; " +
"-fx-text-fill: white; " +
"-fx-font-weight: bold; " +
"-fx-background-radius: 25;");
// Add hover effects
setupHoverEffects();
}
private void setupHoverEffects() {
setOnMouseEntered(e -> {
setStyle("-fx-background-color: #45a049; " +
"-fx-text-fill: white; " +
"-fx-font-weight: bold; " +
"-fx-background-radius: 25;");
});
setOnMouseExited(e -> {
setStyle("-fx-background-color: #4CAF50; " +
"-fx-text-fill: white; " +
"-fx-font-weight: bold; " +
"-fx-background-radius: 25;");
});
}
}
Example 2: Custom Progress Indicator
import javafx.scene.control.ProgressBar;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Text;
public class LabeledProgressBar extends StackPane {
private ProgressBar progressBar;
private Text text;
public LabeledProgressBar() {
initialize();
}
private void initialize() {
progressBar = new ProgressBar();
progressBar.setMaxWidth(Double.MAX_VALUE);
text = new Text();
text.setStyle("-fx-fill: white; -fx-font-weight: bold;");
getChildren().addAll(progressBar, text);
// Update text when progress changes
progressBar.progressProperty().addListener((obs, oldVal, newVal) -> {
updateText();
});
updateText();
}
public void setProgress(double progress) {
progressBar.setProgress(progress);
}
public double getProgress() {
return progressBar.getProgress();
}
private void updateText() {
double progress = progressBar.getProgress();
if (progress < 0) {
text.setText("Indeterminate");
} else {
int percentage = (int) (progress * 100);
text.setText(percentage + "%");
}
}
// CSS styleable properties
public void setBarColor(String color) {
progressBar.setStyle("-fx-accent: " + color + ";");
}
}
Advanced Custom Control with Skin
Example 3: Rating Control
import javafx.beans.property.*;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
public class RatingControl extends Control {
private final IntegerProperty rating = new SimpleIntegerProperty(0);
private final IntegerProperty maxRating = new SimpleIntegerProperty(5);
private final BooleanProperty editable = new SimpleBooleanProperty(true);
public RatingControl() {
getStyleClass().add("rating-control");
}
@Override
protected Skin<?> createDefaultSkin() {
return new RatingControlSkin(this);
}
// Properties
public int getRating() { return rating.get(); }
public void setRating(int rating) { this.rating.set(rating); }
public IntegerProperty ratingProperty() { return rating; }
public int getMaxRating() { return maxRating.get(); }
public void setMaxRating(int maxRating) { this.maxRating.set(maxRating); }
public IntegerProperty maxRatingProperty() { return maxRating; }
public boolean isEditable() { return editable.get(); }
public void setEditable(boolean editable) { this.editable.set(editable); }
public BooleanProperty editableProperty() { return editable; }
}
Example 4: Rating Control Skin
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.shape.SVGPath;
public class RatingControlSkin extends SkinBase<RatingControl> {
private HBox starsContainer;
public RatingControlSkin(RatingControl control) {
super(control);
initialize();
}
private void initialize() {
starsContainer = new HBox(5);
starsContainer.getStyleClass().add("stars-container");
updateStars();
// Listen for property changes
getSkinnable().ratingProperty().addListener((obs, oldVal, newVal) -> updateStars());
getSkinnable().maxRatingProperty().addListener((obs, oldVal, newVal) -> updateStars());
getChildren().add(starsContainer);
}
private void updateStars() {
starsContainer.getChildren().clear();
int rating = getSkinnable().getRating();
int maxRating = getSkinnable().getMaxRating();
for (int i = 1; i <= maxRating; i++) {
SVGPath star = createStar(i);
starsContainer.getChildren().add(star);
}
}
private SVGPath createStar(int starNumber) {
SVGPath star = new SVGPath();
// Star SVG path
String starPath = "M12,17.27L18.18,21l-1.64-7.03L22,9.24l-7.19-0.61L12,2L9.19,8.63L2,9.24l5.46,4.73L5.82,21z";
star.setContent(starPath);
// Style based on rating
if (starNumber <= getSkinnable().getRating()) {
star.setStyle("-fx-fill: gold; -fx-stroke: orange;");
} else {
star.setStyle("-fx-fill: lightgray; -fx-stroke: gray;");
}
// Make interactive if editable
if (getSkinnable().isEditable()) {
star.setOnMouseClicked(e -> {
getSkinnable().setRating(starNumber);
});
star.setOnMouseEntered(e -> {
star.setStyle("-fx-fill: #ffd700; -fx-stroke: orange;");
});
star.setOnMouseExited(e -> {
updateStars(); // Reset to current rating
});
}
return star;
}
@Override
protected double computePrefWidth(double height) {
return starsContainer.prefWidth(height);
}
@Override
protected double computePrefHeight(double width) {
return starsContainer.prefHeight(width);
}
}
Composite Custom Controls
Example 5: Login Form Control
import javafx.scene.control.*;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.VBox;
public class LoginForm extends VBox {
private TextField usernameField;
private PasswordField passwordField;
private CheckBox rememberMe;
private Button loginButton;
private Hyperlink forgotPassword;
private final Runnable onLoginAction;
public LoginForm(Runnable onLoginAction) {
this.onLoginAction = onLoginAction;
initialize();
}
private void initialize() {
setSpacing(15);
setStyle("-fx-padding: 20; -fx-background-color: #f5f5f5; -fx-border-color: #ddd; -fx-border-radius: 5;");
// Title
Label title = new Label("Login");
title.setStyle("-fx-font-size: 24px; -fx-font-weight: bold; -fx-text-fill: #333;");
// Form fields
GridPane formGrid = new GridPane();
formGrid.setVgap(10);
formGrid.setHgap(10);
usernameField = new TextField();
usernameField.setPromptText("Username");
usernameField.setPrefWidth(200);
passwordField = new PasswordField();
passwordField.setPromptText("Password");
rememberMe = new CheckBox("Remember me");
loginButton = new Button("Login");
forgotPassword = new Hyperlink("Forgot password?");
// Add to grid
formGrid.add(new Label("Username:"), 0, 0);
formGrid.add(usernameField, 1, 0);
formGrid.add(new Label("Password:"), 0, 1);
formGrid.add(passwordField, 1, 1);
// Setup login button
loginButton.setStyle("-fx-background-color: #4CAF50; -fx-text-fill: white; -fx-font-weight: bold;");
loginButton.setPrefWidth(200);
loginButton.setOnAction(e -> handleLogin());
// Enable login button only when fields are filled
usernameField.textProperty().addListener((obs, oldVal, newVal) -> validateForm());
passwordField.textProperty().addListener((obs, oldVal, newVal) -> validateForm());
// Add all components
getChildren().addAll(title, formGrid, rememberMe, loginButton, forgotPassword);
validateForm();
}
private void handleLogin() {
if (isValid()) {
if (onLoginAction != null) {
onLoginAction.run();
}
}
}
private void validateForm() {
loginButton.setDisable(!isValid());
}
private boolean isValid() {
return usernameField.getText() != null &&
!usernameField.getText().trim().isEmpty() &&
passwordField.getText() != null &&
!passwordField.getText().trim().isEmpty();
}
// Public API
public String getUsername() {
return usernameField.getText();
}
public String getPassword() {
return passwordField.getText();
}
public boolean isRememberMe() {
return rememberMe.isSelected();
}
public void setOnForgotPassword(Runnable action) {
forgotPassword.setOnAction(e -> action.run());
}
public void clear() {
usernameField.clear();
passwordField.clear();
rememberMe.setSelected(false);
}
}
Example 6: Custom Toggle Switch
import javafx.animation.TranslateTransition;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Circle;
import javafx.util.Duration;
public class ToggleSwitch extends StackPane {
private final BooleanProperty switchedOn = new SimpleBooleanProperty(false);
private final TranslateTransition translateAnimation = new TranslateTransition(Duration.millis(250));
private final Rectangle background;
private final Circle trigger;
private final Label onLabel, offLabel;
public ToggleSwitch() {
initialize();
}
private void initialize() {
background = new Rectangle(60, 30);
background.setArcWidth(30);
background.setArcHeight(30);
background.setFill(Color.LIGHTGRAY);
trigger = new Circle(15);
trigger.setFill(Color.WHITE);
trigger.setStroke(Color.LIGHTGRAY);
onLabel = new Label("ON");
onLabel.setTextFill(Color.WHITE);
onLabel.setStyle("-fx-font-weight: bold;");
offLabel = new Label("OFF");
offLabel.setTextFill(Color.GRAY);
offLabel.setStyle("-fx-font-weight: bold;");
StackPane labels = new StackPane(offLabel, onLabel);
labels.setPrefSize(60, 30);
setPrefSize(60, 30);
getChildren().addAll(background, labels, trigger);
setupAnimations();
setupInteractions();
updateStyle();
switchedOn.addListener((obs, oldVal, newVal) -> {
updateStyle();
if (newVal) {
translateAnimation.setToX(30);
onLabel.toFront();
} else {
translateAnimation.setToX(0);
offLabel.toFront();
}
translateAnimation.play();
});
}
private void setupAnimations() {
translateAnimation.setNode(trigger);
}
private void setupInteractions() {
setOnMouseClicked(e -> setSwitchedOn(!isSwitchedOn()));
trigger.setOnMouseClicked(e -> setSwitchedOn(!isSwitchedOn()));
}
private void updateStyle() {
if (isSwitchedOn()) {
background.setFill(Color.LIMEGREEN);
onLabel.setVisible(true);
offLabel.setVisible(false);
} else {
background.setFill(Color.LIGHTGRAY);
onLabel.setVisible(false);
offLabel.setVisible(true);
}
}
// Properties
public boolean isSwitchedOn() {
return switchedOn.get();
}
public void setSwitchedOn(boolean switchedOn) {
this.switchedOn.set(switchedOn);
}
public BooleanProperty switchedOnProperty() {
return switchedOn;
}
}
CSS Styling for Custom Controls
Example 7: CSS Stylable Custom Control
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
public class ModernButton extends Control {
private String buttonText;
public ModernButton(String text) {
this.buttonText = text;
getStyleClass().add("modern-button");
}
@Override
protected Skin<?> createDefaultSkin() {
return new ModernButtonSkin(this);
}
public String getButtonText() {
return buttonText;
}
}
// ModernButtonSkin.java
import javafx.scene.control.SkinBase;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Text;
class ModernButtonSkin extends SkinBase<ModernButton> {
private StackPane container;
private Text text;
public ModernButtonSkin(ModernButton control) {
super(control);
initialize();
}
private void initialize() {
container = new StackPane();
container.getStyleClass().add("container");
text = new Text(getSkinnable().getButtonText());
text.getStyleClass().add("text");
container.getChildren().add(text);
getChildren().add(container);
// Apply CSS
container.styleProperty().bind(getSkinnable().styleProperty());
}
@Override
protected double computePrefWidth(double height) {
return text.prefWidth(height) + 40; // Padding
}
@Override
protected double computePrefHeight(double width) {
return text.prefHeight(width) + 20; // Padding
}
}
CSS File (modern-controls.css)
/* Modern Button Styles */
.modern-button {
-fx-background-color: linear-gradient(to bottom, #667eea 0%, #764ba2 100%);
-fx-background-radius: 25;
-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.2), 10, 0, 0, 4);
}
.modern-button .container {
-fx-padding: 10 20;
}
.modern-button .text {
-fx-fill: white;
-fx-font-family: "Arial";
-fx-font-size: 14px;
-fx-font-weight: bold;
}
.modern-button:hover {
-fx-background-color: linear-gradient(to bottom, #764ba2 0%, #667eea 100%);
-fx-scale-x: 1.05;
-fx-scale-y: 1.05;
}
.modern-button:pressed {
-fx-background-color: linear-gradient(to bottom, #5a6fd8 0%, #6a419c 100%);
-fx-scale-x: 0.95;
-fx-scale-y: 0.95;
}
/* Rating Control Styles */
.rating-control {
-fx-padding: 5;
}
.rating-control .stars-container {
-fx-alignment: center;
}
/* Toggle Switch Styles */
.toggle-switch {
-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.1), 5, 0, 0, 2);
}
Usage Example
Example 8: Main Application Using Custom Controls
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class CustomControlsDemo extends Application {
@Override
public void start(Stage primaryStage) {
// Create custom controls
CircularButton circularBtn = new CircularButton("Click Me!");
LabeledProgressBar progressBar = new LabeledProgressBar();
RatingControl ratingControl = new RatingControl();
ToggleSwitch toggleSwitch = new ToggleSwitch();
LoginForm loginForm = new LoginForm(this::handleLogin);
// Setup progress bar
progressBar.setProgress(0.75);
progressBar.setBarColor("#2196F3");
// Setup rating control
ratingControl.setRating(4);
ratingControl.setMaxRating(5);
// Setup toggle switch
toggleSwitch.setSwitchedOn(true);
toggleSwitch.switchedOnProperty().addListener((obs, oldVal, newVal) -> {
System.out.println("Toggle switched: " + newVal);
});
// Setup circular button
circularBtn.setOnAction(e -> {
double newProgress = Math.min(progressBar.getProgress() + 0.1, 1.0);
progressBar.setProgress(newProgress);
});
// Layout
VBox root = new VBox(20);
root.setPadding(new Insets(20));
root.setAlignment(Pos.TOP_CENTER);
root.getChildren().addAll(
circularBtn,
progressBar,
ratingControl,
toggleSwitch,
loginForm
);
// Load CSS
Scene scene = new Scene(root, 400, 600);
scene.getStylesheets().add("modern-controls.css");
primaryStage.setTitle("Custom JavaFX Controls Demo");
primaryStage.setScene(scene);
primaryStage.show();
}
private void handleLogin() {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("Login Successful");
alert.setHeaderText(null);
alert.setContentText("Login action triggered!");
alert.showAndWait();
}
public static void main(String[] args) {
launch(args);
}
}
Best Practices for Custom Controls
- Separate Skin from Control: Follow the skin/control separation pattern
- Use CSS Styling: Make controls customizable through CSS
- Implement Properties: Use JavaFX properties for bindable values
- Handle Resizing: Override compute methods for proper sizing
- Provide Default Styles: Include sensible default styling
- Make Accessible: Support screen readers and keyboard navigation
- Document Public API: Clearly document how to use your control
Performance Considerations
- Use efficient node hierarchies
- Implement lazy initialization where possible
- Avoid unnecessary property listeners
- Use appropriate layout managers
- Consider using Canvas for complex drawings
Conclusion
Custom controls in JavaFX provide powerful capabilities for creating unique, reusable UI components. Key benefits include:
- Reusability: Write once, use everywhere
- Maintainability: Centralized logic and styling
- Consistency: Uniform look and behavior
- Flexibility: Tailored to specific application needs
- Professional Appearance: Polished, custom UI elements
Whether you're extending existing controls, creating composite controls, or building fully custom controls with skins, JavaFX provides the tools needed to create professional, maintainable user interfaces.