Merge pull request #1 from kneitzel/feature/componentsAndMVVM

Feature/components and mvvm
This commit is contained in:
Konrad Neitzel 2025-04-02 18:37:38 +02:00 committed by GitHub
commit f083679997
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 976 additions and 69 deletions

29
README.md Normal file
View File

@ -0,0 +1,29 @@
# JavaFX Maven Project
Example Maven Project for a JavaFX Application.
**Update**: Added profile fatjar
**Update**: Added reporting to create a site (html documentation of project)
**Update**: Java 21 is now fully supported
This projects includes multiple plugins:
- Build of an App-Image using JPackage (Profile: image)
- Build of an fat jar (Profile: fatjar)
- Use of Maven Wrapper
- Static code analysis with PMD and Spotbugs
- Check of dependency updates during build
- JavaFX plugin to start application
**Requirements**
To use this Template, all you need is a local Java Installation.
My current advice is to use a long term supported (LTS) version of either Java 17 or Java 21.
**[Documentation in English](documentation/en/_Index.md)**
**[Dokumentation in Deutsch](documentation/de/_Index.md)**
**Important: ChatGPT was utilized to generate the documentation based on
predefined content specifications, as it represents the fastest way to produce
comprehensive documentation.**

View File

@ -5,26 +5,26 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>de.neitzel</groupId> <groupId>de.neitzel.lib</groupId>
<artifactId>injectfx</artifactId> <artifactId>neitzellib</artifactId>
<version>1.0-SNAPSHOT</version> <version>1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>injectfx-example</artifactId> <artifactId>fx-example</artifactId>
<properties> <properties>
<link.name>${project.artifactId}</link.name> <link.name>${project.artifactId}</link.name>
<launcher>${project.artifactId}</launcher> <launcher>${project.artifactId}</launcher>
<appName>${project.artifactId}</appName> <appName>${project.artifactId}</appName>
<main.class>de.neitzel.injectfx.example.Main</main.class> <main.class>de.neitzel.fx.injectfx.example.Main</main.class>
<jar.filename>${project.artifactId}-${project.version}</jar.filename> <jar.filename>${project.artifactId}-${project.version}</jar.filename>
</properties> </properties>
<dependencies> <dependencies>
<!-- InjectFX dependency --> <!-- FX dependency -->
<dependency> <dependency>
<groupId>de.neitzel</groupId> <groupId>de.neitzel.lib</groupId>
<artifactId>injectfx-lib</artifactId> <artifactId>fx</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@ -0,0 +1,11 @@
package de.neitzel.fx.component.example;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Address {
private String street;
private String city;
}

View File

@ -0,0 +1,36 @@
package de.neitzel.fx.component.example;
import de.neitzel.fx.component.ComponentLoader;
import javafx.application.Application;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class ExampleApp extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
// Beispielmodel initialisieren
Address address = new Address();
address.setStreet("Sample Street 1");
address.setCity("Sample City");
Person person = new Person();
person.setName("Max Mustermann");
person.setAddress(address);
// ComponentLoader verwenden
ComponentLoader loader = new ComponentLoader();
Parent root = loader.load(person, "/person.fxml");
// Scene erstellen und anzeigen
Scene scene = new Scene(root);
primaryStage.setTitle("ComponentLoader Example");
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch();
}
}

View File

@ -0,0 +1,15 @@
package de.neitzel.fx.component.example;
/**
* Another Main class as workaround when the JavaFX Application ist started without
* taking care os Classloader Requirements of JavaFX. (Important when starting from inside NetBeans!)
*/
public class Main {
/**
* Additional main methode to start Application.
* @param args Commandline Arguments.
*/
public static void main(String[] args) {
ExampleApp.main(args);
}
}

View File

@ -0,0 +1,11 @@
package de.neitzel.fx.component.example;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Person {
private String name;
private Address address;
}

View File

@ -1,4 +1,4 @@
package de.neitzel.injectfx.example; package de.neitzel.fx.injectfx.example;
import javafx.application.Application; import javafx.application.Application;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
@ -6,8 +6,6 @@ import javafx.scene.Parent;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.stage.Stage; import javafx.stage.Stage;
import java.io.IOException;
public class JavaFXApp extends Application { public class JavaFXApp extends Application {
@Override @Override

View File

@ -1,4 +1,4 @@
package de.neitzel.injectfx.example; package de.neitzel.fx.injectfx.example;
/** /**
* Another Main class as workaround when the JavaFX Application ist started without * Another Main class as workaround when the JavaFX Application ist started without

View File

@ -1,4 +1,4 @@
package de.neitzel.injectfx.example; package de.neitzel.fx.injectfx.example;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.control.*?>
<AnchorPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
xmlns:nfx="http://example.com/nfx"
fx:controller="de.neitzel.fx.component.ComponentController"
prefWidth="300" prefHeight="100">
<children>
<TextField layoutX="10" layoutY="10" prefWidth="280"
nfx:target="street" nfx:direction="bidirectional"/>
<TextField layoutX="10" layoutY="50" prefWidth="280"
nfx:target="city" nfx:direction="bidirectional"/>
</children>
</AnchorPane>

View File

@ -3,7 +3,7 @@
<?import javafx.scene.control.*?> <?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<AnchorPane prefHeight="127.0" prefWidth="209.0" xmlns="http://javafx.com/javafx/17.0.2-ea" xmlns:fx="http://javafx.com/fxml/1" fx:controller="de.neitzel.injectfx.example.MainWindow"> <AnchorPane prefHeight="127.0" prefWidth="209.0" xmlns="http://javafx.com/javafx/17.0.2-ea" xmlns:fx="http://javafx.com/fxml/1" fx:controller="de.neitzel.fx.injectfx.example.MainWindow">
<children> <children>
<Button fx:id="button" layoutX="44.0" layoutY="70.0" mnemonicParsing="false" onAction="#onButtonClick" text="Click Me" /> <Button fx:id="button" layoutX="44.0" layoutY="70.0" mnemonicParsing="false" onAction="#onButtonClick" text="Click Me" />
<TextField fx:id="textField" layoutX="14.0" layoutY="24.0" /> <TextField fx:id="textField" layoutX="14.0" layoutY="24.0" />

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.control.*?>
<AnchorPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
xmlns:nfx="http://example.com/nfx"
fx:controller="de.neitzel.fx.component.ComponentController"
prefWidth="300" prefHeight="180">
<children>
<TextField layoutX="10" layoutY="10" prefWidth="280"
nfx:target="name" nfx:direction="bidirectional"/>
<Pane layoutX="10" layoutY="50" prefWidth="280" prefHeight="110"
nfx:target="address" nfx:direction="bidirectional"
nfx:source="/address.fxml"/>
</children>
</AnchorPane>

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

29
fx/fx.adoc Normal file
View File

@ -0,0 +1,29 @@
== Komponenten
=== injectfx
`injectfx` adressiert das Problem, dass man bei bestimmten Controllern gerne Konstruktorparameter übergeben möchte. Möchte man die Controller-Klasse direkt im Code verwenden, ist das relativ einfach. Wenn man jedoch Code, Controller und FXML strikt voneinander trennen möchte, wird die Umsetzung deutlich komplexer.
Ein möglicher Ansatz besteht in einer universellen `ControllerFactory` (InjectingControllerFactory), der man potenzielle Parameter übergeben kann. Zur Instanzierung prüft die Factory die vorhandenen Konstruktoren und gleicht sie mit den verfügbaren Parametern ab, um anschließend den passenden Konstruktor aufzurufen.
Dieses Prinzip lässt sich noch erweitern:
- Ein universeller `FXMLLoader` kann den Code vereinfachen, da keine separate InjectingControllerFactory mehr erstellt und zugewiesen werden muss.
- Anstatt Parameter manuell bereitzustellen, könnten Klassen durch Markierungen als potenzielle Parameter erkannt werden vergleichbar mit bekannten Injection-Frameworks wie z.B. Spring Boot.
=== component
Ähnlich wie in anderen UI-Frameworks (z.B. React) versuche ich, ein komponentenbasiertes Modell zu etablieren, um datengetriebene Oberflächen schnell und modular zusammenstellen zu können.
Die Grundidee ist, mit möglichst geringem Aufwand Kombinationen aus Datenmodell und FXML zu erzeugen. So lässt sich z.B. eine FXML-Datei definieren, die eine Adresse darstellt inklusive Felder und der zugehörigen Bindings. Möchte man nun einen Datensatz mit einer Adresse anzeigen, kann für das Adressfeld eine Pane erzeugt werden, die sowohl das Binding auf die Adresse als auch den Verweis auf die FXML-Datei enthält.
Dabei wird das MVVM-Pattern verwendet: Ausgehend vom Model wird ein passendes ViewModel automatisch generiert.
Die Bindings werden allerdings zunehmend komplex, sobald nicht ausschließlich einfache Typen wie `StringProperty` verwendet werden.
=== mvvm
Ein Ansatz, das MVVM-Pattern in JavaFX möglichst einfach zugänglich zu machen.
Die zentralen Herausforderungen, die hier gelöst werden sollen, sind:
- die automatische Erstellung eines Controllers, der das Binding zwischen View und ViewModel übernimmt
- Unterstützung bei der Erzeugung von ViewModel-Elementen auf Basis vorhandener Model-Klassen

52
fx/pom.xml Normal file
View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<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>
<parent>
<groupId>de.neitzel.lib</groupId>
<artifactId>neitzellib</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>fx</artifactId>
<properties>
<!-- Application Properties -->
<link.name>${project.artifactId}</link.name>
<launcher>${project.artifactId}</launcher>
<appName>${project.artifactId}</appName>
<main.class>example.de.neitzel.fx.injectfx.Main</main.class>
<jar.filename>${project.artifactId}-${project.version}</jar.filename>
</properties>
<dependencies>
<dependency>
<groupId>de.neitzel.lib</groupId>
<artifactId>inject</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
<version>${reflections.version}</version>
</dependency>
</dependencies>
<build>
<finalName>${jar.filename}</finalName>
<plugins>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,108 @@
package de.neitzel.fx.component;
import javafx.beans.property.*;
import java.lang.reflect.*;
import java.util.*;
public class AutoViewModel<T> {
private final T model;
private final Map<String, Property<?>> properties = new HashMap<>();
public AutoViewModel(T model) {
this.model = model;
initProperties();
}
private void initProperties() {
for (Method getter : model.getClass().getMethods()) {
if (isGetter(getter)) {
String fieldName = getFieldName(getter);
Object value = invokeGetter(getter);
Property<?> prop = toProperty(value);
properties.put(fieldName, prop);
// Bind ViewModel Model
prop.addListener((obs, oldVal, newVal) -> {
Method setter = findSetterFor(model.getClass(), fieldName, newVal != null ? newVal.getClass() : null);
if (setter != null) {
try {
setter.invoke(model, newVal);
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
}
}
public Property<?> getProperty(String name) {
return properties.get(name);
}
public T getModel() {
return model;
}
// ========== Hilfsmethoden ==========
private boolean isGetter(Method method) {
return Modifier.isPublic(method.getModifiers())
&& method.getParameterCount() == 0
&& !method.getReturnType().equals(void.class)
&& (method.getName().startsWith("get") || method.getName().startsWith("is"));
}
private String getFieldName(Method method) {
String name = method.getName();
if (name.startsWith("get")) {
return decapitalize(name.substring(3));
} else if (name.startsWith("is")) {
return decapitalize(name.substring(2));
}
return name;
}
private String decapitalize(String str) {
if (str == null || str.isEmpty()) return str;
return str.substring(0, 1).toLowerCase() + str.substring(1);
}
private Object invokeGetter(Method method) {
try {
return method.invoke(model);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
private Method findSetterFor(Class<?> clazz, String fieldName, Class<?> valueType) {
String setterName = "set" + capitalize(fieldName);
for (Method m : clazz.getMethods()) {
if (m.getName().equals(setterName) && m.getParameterCount() == 1) {
if (valueType == null || m.getParameterTypes()[0].isAssignableFrom(valueType)) {
return m;
}
}
}
return null;
}
private String capitalize(String str) {
if (str == null || str.isEmpty()) return str;
return str.substring(0, 1).toUpperCase() + str.substring(1);
}
private Property<?> toProperty(Object value) {
if (value instanceof String s) return new SimpleStringProperty(s);
if (value instanceof Integer i) return new SimpleIntegerProperty(i);
if (value instanceof Boolean b) return new SimpleBooleanProperty(b);
if (value instanceof Double d) return new SimpleDoubleProperty(d);
if (value instanceof Float f) return new SimpleFloatProperty(f);
if (value instanceof Long l) return new SimpleLongProperty(l);
return new SimpleObjectProperty<>(value);
}
}

View File

@ -0,0 +1,23 @@
package de.neitzel.fx.component;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Generic controller used by the ComponentLoader to bind FXML views
* to automatically generated ViewModels based on POJO models.
* <p>
* This controller provides access to the {@link AutoViewModel}
* which contains JavaFX properties derived from the model.
*/
@RequiredArgsConstructor
public class ComponentController {
/**
* The automatically generated ViewModel that holds JavaFX properties
* for all model fields. It is used to create bindings between
* the view and the model.
*/
@Getter
private final AutoViewModel<?> viewModel;
}

View File

@ -0,0 +1,188 @@
package de.neitzel.fx.component;
import javafx.beans.property.Property;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.layout.Pane;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* ComponentLoader is responsible for loading JavaFX FXML components and binding
* them to automatically generated ViewModels based on simple POJO models.
* <p>
* It parses custom NFX attributes in FXML to bind UI elements to properties in the ViewModel,
* and supports recursive loading of subcomponents.
*/
public class ComponentLoader {
private Map<String, Map<String, String>> nfxBindingMap = new HashMap<>();
/**
* Loads an FXML file and binds its elements to a generated ViewModel
* based on the given POJO model.
*
* @param model the data model (POJO) to bind to the UI
* @param fxmlPath the path to the FXML file
* @return the root JavaFX node loaded from FXML
*/
public Parent load(Object model, String fxmlPath) {
try {
AutoViewModel<?> viewModel = new AutoViewModel<>(model);
String cleanedUri = preprocessNfxAttributes(fxmlPath);
FXMLLoader loader = new FXMLLoader(new URL(cleanedUri));
loader.setControllerFactory(type -> new ComponentController(viewModel));
Parent root = loader.load();
processNfxBindings(root, viewModel, loader);
return root;
} catch (IOException e) {
throw new RuntimeException("unable to load fxml: " + fxmlPath, e);
}
}
/**
* Processes all UI elements for NFX binding attributes and applies
* the appropriate bindings to the ViewModel.
*
* @param root the root node of the loaded FXML hierarchy
* @param viewModel the generated ViewModel to bind against
*/
private void processNfxBindings(Parent root, AutoViewModel<?> viewModel, FXMLLoader loader) {
walkNodes(root, node -> {
var nfx = lookupNfxAttributes(node, loader);
String target = nfx.get("nfx:target");
String direction = nfx.get("nfx:direction");
String source = nfx.get("nfx:source");
if (target != null && direction != null) {
Property<?> vmProp = viewModel.getProperty(target);
bindNodeToProperty(node, vmProp, direction);
}
if (source != null) {
Object subModel = ((Property<?>) viewModel.getProperty(target)).getValue();
Parent subComponent = load(subModel, source);
if (node instanceof Pane pane) {
pane.getChildren().setAll(subComponent);
}
}
});
}
/**
* Recursively walks all nodes in the scene graph starting from the root,
* applying the given consumer to each node.
*
* @param root the starting node
* @param consumer the consumer to apply to each node
*/
private void walkNodes(Parent root, java.util.function.Consumer<javafx.scene.Node> consumer) {
consumer.accept(root);
if (root instanceof Pane pane) {
for (javafx.scene.Node child : pane.getChildren()) {
if (child instanceof Parent p) {
walkNodes(p, consumer);
} else {
consumer.accept(child);
}
}
}
}
/**
* Extracts custom NFX attributes from a node's properties map.
* These attributes are expected to be in the format "nfx:..." and hold string values.
*
* @param node the node to inspect
* @return a map of NFX attribute names to values
*/
private Map<String, String> extractNfxAttributes(javafx.scene.Node node) {
Map<String, String> result = new HashMap<>();
node.getProperties().forEach((k, v) -> {
if (k instanceof String key && key.startsWith("nfx:") && v instanceof String value) {
result.put(key, value);
}
});
return result;
}
/**
* Binds a JavaFX UI control to a ViewModel property according to the specified direction.
*
* @param node the UI node (e.g., TextField)
* @param vmProp the ViewModel property to bind to
* @param direction the direction of the binding (e.g., "bidirectional", "read")
*/
private void bindNodeToProperty(javafx.scene.Node node, Property<?> vmProp, String direction) {
if (node instanceof javafx.scene.control.TextField tf && vmProp instanceof javafx.beans.property.StringProperty sp) {
if ("bidirectional".equalsIgnoreCase(direction)) {
tf.textProperty().bindBidirectional(sp);
} else if ("read".equalsIgnoreCase(direction)) {
tf.textProperty().bind(sp);
}
}
// Additional control types (e.g., CheckBox, ComboBox) can be added here
}
private String preprocessNfxAttributes(String fxmlPath) {
try {
nfxBindingMap.clear();
var factory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
var builder = factory.newDocumentBuilder();
var doc = builder.parse(getClass().getResourceAsStream(fxmlPath));
var all = doc.getElementsByTagName("*");
int autoId = 0;
for (int i = 0; i < all.getLength(); i++) {
var el = (org.w3c.dom.Element) all.item(i);
Map<String, String> nfxAttrs = new HashMap<>();
var attrs = el.getAttributes();
List<String> toRemove = new ArrayList<>();
for (int j = 0; j < attrs.getLength(); j++) {
var attr = (org.w3c.dom.Attr) attrs.item(j);
if (attr.getName().startsWith("nfx:")) {
nfxAttrs.put(attr.getName(), attr.getValue());
toRemove.add(attr.getName());
}
}
if (!nfxAttrs.isEmpty()) {
String fxid = el.getAttribute("fx:id");
if (fxid == null || fxid.isBlank()) {
fxid = "auto_id_" + (++autoId);
el.setAttribute("fx:id", fxid);
}
nfxBindingMap.put(fxid, nfxAttrs);
toRemove.forEach(el::removeAttribute);
}
}
// Speichere das bereinigte Dokument als temporäre Datei
File tempFile = File.createTempFile("cleaned_fxml", ".fxml");
tempFile.deleteOnExit();
var transformer = javax.xml.transform.TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(javax.xml.transform.OutputKeys.INDENT, "yes");
transformer.transform(new javax.xml.transform.dom.DOMSource(doc),
new javax.xml.transform.stream.StreamResult(tempFile));
return tempFile.toURI().toString();
} catch (Exception e) {
throw new RuntimeException("Preprocessing failed for: " + fxmlPath, e);
}
}
private Map<String, String> lookupNfxAttributes(javafx.scene.Node node, FXMLLoader loader) {
String fxid = loader.getNamespace().entrySet().stream()
.filter(e -> e.getValue() == node)
.map(Map.Entry::getKey)
.findFirst().orElse(null);
if (fxid == null) return Map.of();
return nfxBindingMap.getOrDefault(fxid, Map.of());
}
}

View File

@ -1,4 +1,6 @@
package de.neitzel.injectfx; package de.neitzel.fx.injectfx;
import de.neitzel.inject.InjectableComponentScanner;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.util.*; import java.util.*;
@ -18,7 +20,7 @@ public class FXMLComponentInstances {
/** /**
* Constructs an FXMLComponentInstances manager and initializes all component instances. * Constructs an FXMLComponentInstances manager and initializes all component instances.
* *
* @param injectableComponents The InjectableComponents instance containing resolved component types. * @param injectableScanner The InjectableComponents instance containing resolved component types.
*/ */
public FXMLComponentInstances(InjectableComponentScanner injectableScanner) { public FXMLComponentInstances(InjectableComponentScanner injectableScanner) {
this.injectableScanner = injectableScanner; this.injectableScanner = injectableScanner;

View File

@ -1,4 +1,4 @@
package de.neitzel.injectfx; package de.neitzel.fx.injectfx;
import javafx.util.Callback; import javafx.util.Callback;

View File

@ -1,5 +1,6 @@
package de.neitzel.injectfx; package de.neitzel.fx.injectfx;
import de.neitzel.inject.InjectableComponentScanner;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;

View File

@ -0,0 +1,50 @@
package de.neitzel.fx.mvvm;
/**
* Enum representing the direction of data binding between a JavaFX control and a ViewModel property.
* - READ: one-way binding from ViewModel to Control.
* - WRITE: one-way binding from Control to ViewModel.
* - BIDIRECTIONAL: two-way binding between Control and ViewModel.
*/
public enum BindDirection {
/**
* One-way binding from the ViewModel to the JavaFX control.
* The control reflects changes in the ViewModel but does not update it.
*/
READ,
/**
* One-way binding from the JavaFX control to the ViewModel.
* The ViewModel is updated when the control changes, but not vice versa.
*/
WRITE,
/**
* Two-way binding between the JavaFX control and the ViewModel.
* Changes in either are reflected in the other.
*/
BIDIRECTIONAL;
/**
* Parses a string to determine the corresponding BindDirection.
* Defaults to BIDIRECTIONAL if the input is null or empty.
*
* @param value the string representation of the direction
* @return the corresponding BindDirection enum constant
* @throws IllegalArgumentException if the string does not match any valid direction
*/
public static BindDirection fromString(String value) {
if (value == null || value.isEmpty()) {
return BIDIRECTIONAL;
}
for (BindDirection direction : BindDirection.values()) {
if (direction.name().equalsIgnoreCase(value)) {
return direction;
}
}
throw new IllegalArgumentException("Not a valid direction: " + value);
}
}

View File

@ -0,0 +1,116 @@
package de.neitzel.fx.mvvm;
import javafx.beans.property.Property;
import javafx.beans.property.StringProperty;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.TextInputControl;
import lombok.RequiredArgsConstructor;
import java.io.InputStream;
@RequiredArgsConstructor
/**
* Custom FXMLLoader that binds JavaFX controls to a GenericViewModel using metadata from FXML.
* It supports automatic binding setup based on properties defined in the FXML's node properties.
*
* @param <T> the type of the model used in the ViewModel
*/
public class BindingAwareFXMLLoader<T> {
/**
* The model instance used to construct the GenericViewModel.
*/
private final T model;
/**
* Loads an FXML file and performs automatic binding setup using the GenericViewModel.
*
* @param fxml the input stream of the FXML file
* @return the root node of the loaded scene graph
* @throws Exception if loading the FXML or binding fails
*/
public Parent load(InputStream fxml) throws Exception {
FXMLLoader loader = new FXMLLoader();
loader.setControllerFactory(param -> {
GenericViewController<T> controller = new GenericViewController<>();
controller.setModel(model);
return controller;
});
Parent root = loader.load(fxml);
GenericViewController<T> controller = loader.getController();
GenericViewModel<T> viewModel = controller.getViewModel();
// Traverse Nodes and evaluate binding info
bindNodesRecursively(root, viewModel);
return root;
}
/**
* Recursively traverses the scene graph and binds controls to properties
* in the ViewModel based on custom metadata in the node properties map.
*
* @param node the current node to inspect
* @param viewModel the ViewModel holding the properties
*/
private void bindNodesRecursively(Node node, GenericViewModel<T> viewModel) {
if (node instanceof Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
bindNodesRecursively(child, viewModel);
}
}
Object userData = node.getProperties().get("bind:property");
if (userData instanceof String propertyName) {
BindDirection direction = getDirection(node);
if (node instanceof TextInputControl control) {
Property<String> prop = viewModel.property(StringProperty.class, propertyName);
bind(control.textProperty(), prop, direction);
} else if (node instanceof javafx.scene.control.Label label) {
Property<String> prop = viewModel.property(StringProperty.class, propertyName);
bind(label.textProperty(), prop, direction);
} else if (node instanceof javafx.scene.control.CheckBox checkBox) {
Property<Boolean> prop = viewModel.property(javafx.beans.property.BooleanProperty.class, propertyName);
bind(checkBox.selectedProperty(), prop, direction);
} else if (node instanceof javafx.scene.control.Slider slider) {
Property<Number> prop = viewModel.property(javafx.beans.property.DoubleProperty.class, propertyName);
bind(slider.valueProperty(), prop, direction);
} else if (node instanceof javafx.scene.control.DatePicker datePicker) {
@SuppressWarnings("unchecked")
Property<java.time.LocalDate> prop = (Property<java.time.LocalDate>) viewModel.property(javafx.beans.property.ObjectProperty.class, propertyName);
bind(datePicker.valueProperty(), prop, direction);
}
}
}
/**
* Binds two JavaFX properties according to the specified binding direction.
*
* @param controlProperty the property of the control (e.g. TextField.textProperty)
* @param modelProperty the property from the ViewModel
* @param direction the direction of the binding
* @param <V> the value type of the property
*/
private <V> void bind(Property<V> controlProperty, Property<V> modelProperty, BindDirection direction) {
switch (direction) {
case BIDIRECTIONAL -> controlProperty.bindBidirectional(modelProperty);
case READ -> controlProperty.bind(modelProperty);
case WRITE -> modelProperty.bind(controlProperty);
}
}
/**
* Retrieves the binding direction for a node based on its "bind:direction" property.
*
* @param node the node whose direction property is read
* @return the BindDirection specified or a default
*/
private BindDirection getDirection(final Node node) {
return BindDirection.fromString((String) node.getProperties().get("bind:direction"));
}
}

View File

@ -0,0 +1,56 @@
package de.neitzel.fx.mvvm;
import javafx.fxml.Initializable;
import java.net.URL;
import java.util.ResourceBundle;
/**
* A generic JavaFX controller that holds a GenericViewModel for the given model type.
* Intended for use with a custom FXMLLoader that performs automatic property bindings.
*
* @param <T> the type of the model
*/
public class GenericViewController<T> implements Initializable {
/**
* The GenericViewModel instance wrapping the underlying model.
*/
private GenericViewModel<T> viewModel;
/**
* Sets the model for this controller by wrapping it in a GenericViewModel.
*
* @param model the model to be used by the ViewModel
*/
public void setModel(T model) {
this.viewModel = new GenericViewModel<>(model);
}
/**
* Retrieves the original model instance from the ViewModel.
*
* @return the model instance
*/
public T getModel() {
return viewModel.getModel();
}
/**
* Standard JavaFX initialize method. No-op, as actual binding is done externally via FXMLLoader extension.
*
* @param location the location used to resolve relative paths for the root object, or null if unknown
* @param resources the resources used to localize the root object, or null if not localized
*/
@Override
public void initialize(URL location, ResourceBundle resources) {
// Real binding happens in FXMLLoader extension
}
/**
* Returns the GenericViewModel used by this controller.
*
* @return the GenericViewModel instance
*/
public GenericViewModel<T> getViewModel() {
return viewModel;
}
}

View File

@ -0,0 +1,141 @@
package de.neitzel.fx.mvvm;
import javafx.beans.property.*;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.*;
import java.util.*;
/**
* A generic ViewModel for JavaFX using reflection to automatically create properties for model attributes.
* Supports binding between JavaFX controls and the model via JavaFX properties.
*
* @param <T> the type of the underlying model
*/
public class GenericViewModel<T> {
/**
* The original model instance.
*/
private final T model;
/**
* A map holding JavaFX properties corresponding to the model's fields.
*/
private final Map<String, Property<?>> properties = new HashMap<>();
/**
* Constructs a GenericViewModel for the given model instance.
*
* @param model the model to wrap
*/
public GenericViewModel(T model) {
this.model = model;
initProperties();
}
/**
* Initializes JavaFX properties for all readable and writable model fields using reflection.
*/
private void initProperties() {
Class<?> clazz = model.getClass();
for (PropertyDescriptor pd : getPropertyDescriptors(clazz)) {
Method getter = pd.getReadMethod();
Method setter = pd.getWriteMethod();
String name = pd.getName();
if (getter != null && setter != null) {
Class<?> propertyType = pd.getPropertyType();
try {
Object value = getter.invoke(model);
Property<?> property = createJavaFXProperty(propertyType, value);
properties.put(name, property);
} catch (Exception e) {
throw new RuntimeException("Failed to initialize property: " + name, e);
}
}
}
}
/**
* Retrieves all readable/writable property descriptors of the given class.
*
* @param clazz the class to introspect
* @return a list of property descriptors
*/
private List<PropertyDescriptor> getPropertyDescriptors(Class<?> clazz) {
try {
BeanInfo info = Introspector.getBeanInfo(clazz, Object.class);
return Arrays.asList(info.getPropertyDescriptors());
} catch (IntrospectionException e) {
throw new RuntimeException("Cannot introspect model class: " + clazz, e);
}
}
/**
* Creates an appropriate JavaFX Property instance for the given type and initial value.
*
* @param type the type of the property (e.g., String.class, int.class)
* @param initialValue the initial value of the property
* @return a new JavaFX Property instance
*/
private Property<?> createJavaFXProperty(Class<?> type, Object initialValue) {
if (type == String.class) return new SimpleStringProperty((String) initialValue);
if (type == int.class || type == Integer.class) return new SimpleIntegerProperty((Integer) initialValue);
if (type == double.class || type == Double.class) return new SimpleDoubleProperty((Double) initialValue);
if (type == boolean.class || type == Boolean.class) return new SimpleBooleanProperty((Boolean) initialValue);
if (type == long.class || type == Long.class) return new SimpleLongProperty((Long) initialValue);
// Fallback for unsupported types:
return new SimpleObjectProperty<>(initialValue);
}
/**
* Retrieves the JavaFX property associated with the given name and type.
*
* @param propertyClass the expected property type (e.g., StringProperty.class)
* @param name the name of the model field
* @param <P> the type of the Property
* @return the corresponding JavaFX property
* @throws IllegalArgumentException if the property doesn't exist or the type mismatches
*/
@SuppressWarnings("unchecked")
public <P extends Property<?>> P property(Class<P> propertyClass, String name) {
Property<?> prop = properties.get(name);
if (prop == null) {
throw new IllegalArgumentException("No property found for name: " + name);
}
if (!propertyClass.isInstance(prop)) {
throw new IllegalArgumentException("Property type mismatch: expected " + propertyClass.getSimpleName());
}
return (P) prop;
}
/**
* Writes the current values of all properties back into the model using their respective setters.
*/
public void save() {
for (PropertyDescriptor pd : getPropertyDescriptors(model.getClass())) {
String name = pd.getName();
Method setter = pd.getWriteMethod();
if (setter != null && properties.containsKey(name)) {
try {
Object value = properties.get(name).getValue();
setter.invoke(model, value);
} catch (Exception e) {
throw new RuntimeException("Failed to save property: " + name, e);
}
}
}
}
/**
* Returns the wrapped model instance.
*
* @return the model
*/
public T getModel() {
return model;
}
}

View File

@ -1,10 +1,11 @@
package de.neitzel.injectfx; package de.neitzel.fx.injectfx;
import de.neitzel.injectfx.testcomponents.test1ok.SuperClass; import de.neitzel.fx.injectfx.testcomponents.test1ok.SuperClass;
import de.neitzel.injectfx.testcomponents.test1ok.TestComponent1_1; import de.neitzel.fx.injectfx.testcomponents.test1ok.TestComponent1_1;
import de.neitzel.injectfx.testcomponents.test1ok.TestInterface1_1; import de.neitzel.fx.injectfx.testcomponents.test1ok.TestInterface1_1;
import de.neitzel.injectfx.testcomponents.test1ok.TestInterface1_2; import de.neitzel.fx.injectfx.testcomponents.test1ok.TestInterface1_2;
import de.neitzel.injectfx.testcomponents.test1ok.sub.TestComponent1_2; import de.neitzel.fx.injectfx.testcomponents.test1ok.sub.TestComponent1_2;
import de.neitzel.inject.InjectableComponentScanner;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;

View File

@ -0,0 +1,4 @@
package de.neitzel.fx.injectfx.testcomponents.test1ok;
public class SuperClass {
}

View File

@ -1,8 +1,8 @@
package de.neitzel.injectfx.testcomponents.test1ok; package de.neitzel.fx.injectfx.testcomponents.test1ok;
import de.neitzel.injectfx.annotation.FXMLComponent; import de.neitzel.inject.annotation.Component;
@FXMLComponent @Component
public class TestComponent1_1 extends SuperClass implements TestInterface1_2 { public class TestComponent1_1 extends SuperClass implements TestInterface1_2 {
public TestComponent1_1() { public TestComponent1_1() {
} }

View File

@ -0,0 +1,4 @@
package de.neitzel.fx.injectfx.testcomponents.test1ok;
public interface TestInterface1_1 {
}

View File

@ -0,0 +1,4 @@
package de.neitzel.fx.injectfx.testcomponents.test1ok;
public interface TestInterface1_2 {
}

View File

@ -0,0 +1,12 @@
package de.neitzel.fx.injectfx.testcomponents.test1ok.sub;
import de.neitzel.fx.injectfx.testcomponents.test1ok.SuperClass;
import de.neitzel.fx.injectfx.testcomponents.test1ok.TestInterface1_1;
import de.neitzel.fx.injectfx.testcomponents.test1ok.TestInterface1_2;
import de.neitzel.inject.annotation.Component;
@Component
public class TestComponent1_2 extends SuperClass implements TestInterface1_1, TestInterface1_2 {
public TestComponent1_2() {
}
}

View File

@ -1,11 +1,11 @@
package de.neitzel.injectfx.testcomponents.test2fail; package de.neitzel.fx.injectfx.testcomponents.test2fail;
import de.neitzel.injectfx.annotation.FXMLComponent; import de.neitzel.inject.annotation.Component;
/** /**
* TestComponent1 that should fail. * TestComponent1 that should fail.
*/ */
@FXMLComponent @Component
public class TestComponent2_1 { public class TestComponent2_1 {
public TestComponent2_1(String test) { public TestComponent2_1(String test) {
} }

View File

@ -5,19 +5,18 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>de.neitzel</groupId> <groupId>de.neitzel.lib</groupId>
<artifactId>injectfx</artifactId> <artifactId>neitzellib</artifactId>
<version>1.0-SNAPSHOT</version> <version>1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>injectfx-lib</artifactId> <artifactId>inject</artifactId>
<properties> <properties>
<!-- Application Properties --> <!-- Application Properties -->
<link.name>${project.artifactId}</link.name> <link.name>${project.artifactId}</link.name>
<launcher>${project.artifactId}</launcher> <launcher>${project.artifactId}</launcher>
<appName>${project.artifactId}</appName> <appName>${project.artifactId}</appName>
<main.class>de.neitzel.injectfx.example.Main</main.class>
<jar.filename>${project.artifactId}-${project.version}</jar.filename> <jar.filename>${project.artifactId}-${project.version}</jar.filename>
</properties> </properties>

View File

@ -1,6 +1,6 @@
package de.neitzel.injectfx; package de.neitzel.inject;
import de.neitzel.injectfx.annotation.FXMLComponent; import de.neitzel.inject.annotation.Component;
import org.reflections.Reflections; import org.reflections.Reflections;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
@ -8,7 +8,7 @@ import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* InjectableComponents scans a package for classes annotated with {@link FXMLComponent}. * InjectableComponents scans a package for classes annotated with {@link Component}.
* It determines which components can be instantiated and manages type mappings for dependency injection. * It determines which components can be instantiated and manages type mappings for dependency injection.
*/ */
public class InjectableComponentScanner { public class InjectableComponentScanner {
@ -46,7 +46,7 @@ public class InjectableComponentScanner {
*/ */
private void scanForComponents(String basePackage) { private void scanForComponents(String basePackage) {
Reflections reflections = new Reflections(basePackage); Reflections reflections = new Reflections(basePackage);
fxmlComponents.addAll(reflections.getTypesAnnotatedWith(FXMLComponent.class)); fxmlComponents.addAll(reflections.getTypesAnnotatedWith(Component.class));
} }
/** /**

View File

@ -1,4 +1,4 @@
package de.neitzel.injectfx.annotation; package de.neitzel.inject.annotation;
import java.lang.annotation.ElementType; import java.lang.annotation.ElementType;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
@ -7,5 +7,5 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE) @Target(ElementType.TYPE)
public @interface FXMLComponent { public @interface Component {
} }

View File

@ -1,4 +1,4 @@
package de.neitzel.injectfx.annotation; package de.neitzel.inject.annotation;
import java.lang.annotation.ElementType; import java.lang.annotation.ElementType;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
@ -7,6 +7,6 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE) @Target(ElementType.TYPE)
public @interface FXMLConfig { public @interface Config {
String value() default ""; String value() default "";
} }

View File

@ -1,4 +0,0 @@
package de.neitzel.injectfx.testcomponents.test1ok;
public class SuperClass {
}

View File

@ -1,4 +0,0 @@
package de.neitzel.injectfx.testcomponents.test1ok;
public interface TestInterface1_1 {
}

View File

@ -1,4 +0,0 @@
package de.neitzel.injectfx.testcomponents.test1ok;
public interface TestInterface1_2 {
}

View File

@ -1,12 +0,0 @@
package de.neitzel.injectfx.testcomponents.test1ok.sub;
import de.neitzel.injectfx.annotation.FXMLComponent;
import de.neitzel.injectfx.testcomponents.test1ok.SuperClass;
import de.neitzel.injectfx.testcomponents.test1ok.TestInterface1_1;
import de.neitzel.injectfx.testcomponents.test1ok.TestInterface1_2;
@FXMLComponent
public class TestComponent1_2 extends SuperClass implements TestInterface1_1, TestInterface1_2 {
public TestComponent1_2() {
}
}

14
pom.xml
View File

@ -4,15 +4,16 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 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> <modelVersion>4.0.0</modelVersion>
<groupId>de.neitzel</groupId> <groupId>de.neitzel.lib</groupId>
<artifactId>injectfx</artifactId> <artifactId>neitzellib</artifactId>
<version>1.0-SNAPSHOT</version> <version>1.0-SNAPSHOT</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<modules> <modules>
<module>injectfx-lib</module> <module>inject</module>
<module>injectfx-example</module> <module>fx</module>
<module>fx-example</module>
</modules> </modules>
<properties> <properties>
@ -61,6 +62,11 @@
<dependencies> <dependencies>
<!-- JavaFX dependencies --> <!-- JavaFX dependencies -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>