JavaFX Fundamentals & the Scene Graph

Project: A Basic JavaFX App

18 min Lesson 10 of 12

Project: A Basic JavaFX App

This final lesson of the tutorial pulls together every concept covered so far — the Application lifecycle, the scene graph, layouts, controls, shapes, colors, fonts, event handling, and thread safety — into a single, working desktop application. Instead of isolated snippets, you will build a Unit Converter that converts kilometres to miles and Celsius to Fahrenheit, with a clean two-section UI, keyboard shortcuts, and a live result label that updates as the user types.

By the end you will have a complete, runnable program that demonstrates the patterns you will use in every real JavaFX project.

What the App Does

  • Two conversion panels side by side inside a HBox.
  • Each panel has a labelled TextField for input and a styled result Label.
  • Results update live as the user types, using a ChangeListener on the text property.
  • Invalid or empty input shows a polite "—" rather than crashing.
  • A Reset button clears both fields; pressing Enter in either field triggers the same action.
  • The primary Stage has a fixed minimum size and a custom title.

Project Structure

Everything lives in a single class to keep the focus on JavaFX. In a real project you would separate the UI-building code into a controller (or use FXML), but a single-file app is the right starting point for understanding the full wiring.

src/ └── main/ └── java/ └── com/example/converter/ └── ConverterApp.java

Add the JavaFX SDK modules to your Maven pom.xml (or module-path if running from the command line):

<dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> <version>21</version> </dependency>

Full Source Code

package com.example.converter; import javafx.application.Application; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TextField; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.text.Font; import javafx.scene.text.FontWeight; import javafx.stage.Stage; public class ConverterApp extends Application { // --- distance panel fields --- private TextField kmField; private Label milesResult; // --- temperature panel fields --- private TextField celsiusField; private Label fahrenheitResult; @Override public void start(Stage primaryStage) { // ── Distance panel ────────────────────────────────────── VBox distancePanel = buildPanel( "Distance", "Kilometres", kmField = new TextField(), milesResult = new Label("—"), () -> convertKmToMiles() ); // ── Temperature panel ──────────────────────────────────── VBox tempPanel = buildPanel( "Temperature", "Celsius", celsiusField = new TextField(), fahrenheitResult = new Label("—"), () -> convertCelsiusToFahrenheit() ); // ── Wire live listeners ────────────────────────────────── kmField.textProperty().addListener( (obs, oldVal, newVal) -> convertKmToMiles() ); celsiusField.textProperty().addListener( (obs, oldVal, newVal) -> convertCelsiusToFahrenheit() ); // ── Reset button ───────────────────────────────────────── Button resetBtn = new Button("Reset"); resetBtn.setFont(Font.font("System", FontWeight.BOLD, 13)); resetBtn.setPrefWidth(120); resetBtn.setOnAction(e -> reset()); // ── Root layout ────────────────────────────────────────── HBox panels = new HBox(30, distancePanel, tempPanel); panels.setAlignment(Pos.TOP_CENTER); panels.setPadding(new Insets(20)); VBox root = new VBox(20, panels, resetBtn); root.setAlignment(Pos.TOP_CENTER); root.setPadding(new Insets(20)); root.setStyle("-fx-background-color: #f4f6f9;"); // ── Scene & Stage ──────────────────────────────────────── Scene scene = new Scene(root, 500, 280); primaryStage.setTitle("Unit Converter"); primaryStage.setMinWidth(400); primaryStage.setMinHeight(240); primaryStage.setScene(scene); primaryStage.show(); } /** Builds a labelled conversion panel as a VBox. */ private VBox buildPanel(String heading, String inputLabel, TextField field, Label result, Runnable onEnter) { Label title = new Label(heading); title.setFont(Font.font("System", FontWeight.BOLD, 16)); title.setTextFill(Color.web("#2c3e50")); Label inputLbl = new Label(inputLabel + ":"); inputLbl.setTextFill(Color.web("#555555")); field.setPromptText("Enter value"); field.setPrefWidth(180); field.setOnAction(e -> onEnter.run()); // Enter key triggers conversion result.setFont(Font.font("System", FontWeight.BOLD, 22)); result.setTextFill(Color.web("#27ae60")); VBox box = new VBox(10, title, inputLbl, field, result); box.setAlignment(Pos.TOP_LEFT); box.setPadding(new Insets(16)); box.setStyle( "-fx-background-color: white;" + "-fx-border-color: #d0d7de;" + "-fx-border-radius: 8;" + "-fx-background-radius: 8;" ); box.setPrefWidth(200); return box; } // ── Conversion logic ───────────────────────────────────────── private void convertKmToMiles() { try { double km = Double.parseDouble(kmField.getText().trim()); double miles = km * 0.621371; milesResult.setText(String.format("%.3f mi", miles)); milesResult.setTextFill(Color.web("#27ae60")); } catch (NumberFormatException ex) { milesResult.setText("—"); milesResult.setTextFill(Color.web("#999999")); } } private void convertCelsiusToFahrenheit() { try { double c = Double.parseDouble(celsiusField.getText().trim()); double f = c * 9.0 / 5.0 + 32; fahrenheitResult.setText(String.format("%.2f °F", f)); fahrenheitResult.setTextFill(Color.web("#27ae60")); } catch (NumberFormatException ex) { fahrenheitResult.setText("—"); fahrenheitResult.setTextFill(Color.web("#999999")); } } private void reset() { kmField.clear(); celsiusField.clear(); milesResult.setText("—"); milesResult.setTextFill(Color.web("#999999")); fahrenheitResult.setText("—"); fahrenheitResult.setTextFill(Color.web("#999999")); kmField.requestFocus(); } public static void main(String[] args) { launch(args); } }

Walking Through the Key Decisions

Helper Method for Panel Construction

The buildPanel() method accepts a Runnable so that pressing Enter in either field calls the matching conversion method. This avoids code duplication while keeping each panel's logic self-contained. The Runnable is a lambda: () -> convertKmToMiles(). Notice that the field and result Label are passed in as parameters so the calling code (in start()) retains references to them for live updates and for the reset action.

Live Updates via ChangeListener

Attaching a ChangeListener to field.textProperty() means the result recalculates on every keystroke. The listener receives three arguments — the observable, the old value, and the new value — but this code only cares about the side effect (calling the conversion), so all three can be ignored.

Why not use setOnAction for live updates? setOnAction on a TextField fires only when the user presses Enter. A ChangeListener on the text property fires on every character change — which is what gives the "live" feel. Both are wired here: Enter fires the same conversion for keyboard-workflow users.

Graceful Error Handling

The catch (NumberFormatException ex) block does not show an error dialog or print a stack trace. Instead it resets the result label to "—" and changes its colour to grey. This is the right UX for a live-updating field — the user is mid-typing and has not made an error yet; they just have an incomplete number.

Thread Safety

All the code in start() and in the event handlers runs on the JavaFX Application Thread. There are no background operations here, so no Platform.runLater() is needed. If you later extend this app to fetch an exchange rate from a web API, you would do the network call on a background thread and update the label inside Platform.runLater() — exactly as covered in the Threading lesson.

Inline CSS vs. a Stylesheet

The styling is done with setStyle() calls rather than an external CSS file. This is acceptable for a small standalone project and makes the code self-contained. For anything larger, prefer a styles.css loaded with scene.getStylesheets().add(...) — it separates concerns and enables theming.

Use setMinWidth / setMinHeight on the Stage to prevent the user from resizing the window so small that controls overlap. JavaFX will respect the minimum size during interactive resizing but will still let you set the initial size freely.

Extending the Project

Here are natural next steps that each introduce one new JavaFX concept:

  • Add a ComboBox for unit selection — introduces observable value bindings and the ChoiceBox/ComboBox controls.
  • Load an external stylesheet — moves style rules to converter.css, teaches the JavaFX CSS system.
  • Add conversion history with a ListView — introduces ObservableList and FXCollections.
  • Fetch a live currency rate — forces you to use a background Thread or Task and Platform.runLater().

Summary

This project is small by design — every line serves a teaching purpose. You have seen how the Application entry point, the scene graph, layout containers, controls, properties, listeners, and event handlers all fit together into a working whole. The patterns you used here — building the scene in start(), wiring listeners to observable properties, separating conversion logic from UI wiring, and handling bad input gracefully — are the same patterns you will apply in applications of any size. From here, every new JavaFX topic (FXML, bindings, animations, custom controls) is an extension of this foundation.