Apply Component FXML attributes after injected initialization

This commit is contained in:
2026-11-12 07:27:31 +01:00
parent 8062b61ed6
commit a092654ded
8 changed files with 173 additions and 2 deletions
+8
View File
@@ -247,6 +247,14 @@ A component's `@ViewModelClass` applies to that component's model, not to the pa
## Component
Component elements accept writable FXML properties, including custom attributes and inherited
layout properties such as `prefWidth`. The container constructs the Component first; its own
FXML and `initialize()` run before the containing document's property values are applied.
JavaFX performs the normal property conversion and rejects unknown properties or invalid values.
Object references such as `model="$address"` retain their identity and are assigned after the
Component's model listener is installed. Static layout constraints such as `GridPane.rowIndex`
remain handled by the containing FXMLLoader.
A component is a class the container can create. It extends a pane, loads `ClassName.fxml` from its own package through `fx:root`, and is the controller of that file. It has no `fx:controller`. It exposes one JavaFX property named `model`.
`AddressForm` edits the home address. `VisitForm` edits one visit in the repeated list. `NoteForm` edits a note. `VisitRow` edits one table row. Each is `@Singleton`, extends `VBox` or `HBox`, and has `modelProperty()`.
@@ -6,7 +6,12 @@ import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.util.Builder;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.AbstractMap;
import java.util.LinkedHashMap;
import java.util.Set;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
@@ -58,8 +63,84 @@ final class Components {
if (!LoadSession.claimFirst(type)) {
component = context.createComponent(type.asSubclass(Object.class));
}
Object created = component;
return () -> load(created, context);
return new ComponentBuilder(component, context);
}
/**
* Collects instance properties while FXML constructs an injected Component.
* Properties are applied after its own document and model listener are ready.
*/
private static final class ComponentBuilder extends AbstractMap<String, Object> implements Builder<Object> {
/** The injected instance. */
private final Object component;
/** The context used to load its document. */
private final ApplicationContext context;
/** Values already resolved by the containing FXMLLoader. */
private final Map<String, Object> values = new LinkedHashMap<>();
/**
* Creates a builder for an injected instance.
* @param component the instance
* @param context its dependency context
*/
private ComponentBuilder(Object component, ApplicationContext context) {
this.component = component;
this.context = context;
}
/**
* Records a property for application after Component initialization.
* @param key the property name
* @param value the resolved value
* @return the previous value
*/
@Override
public Object put(String key, Object value) {
return values.put(key, value);
}
/**
* Returns the recorded properties.
* @return the property entries
*/
@Override
public Set<Entry<String, Object>> entrySet() {
return values.entrySet();
}
/**
* Loads the Component and applies properties with standard FXML coercion.
* @return the initialized Component
*/
@Override
public Object build() {
Object result = load(component, context);
if (!values.isEmpty()) {
// A second fx:root load uses the public FXMLLoader API for bean setters,
// enum/numeric coercion and diagnostics, without depending on BeanAdapter
// internals or serializing values (which may be Model object references).
FXMLLoader properties = new FXMLLoader();
properties.setRoot(result);
properties.setClassLoader(result.getClass().getClassLoader());
StringBuilder document = new StringBuilder("<fx:root xmlns:fx=\"http://javafx.com/fxml/1\" type=\"")
.append(result.getClass().getName()).append('"');
int index = 0;
for (Entry<String, Object> entry : values.entrySet()) {
String variable = "componentValue" + index++;
properties.getNamespace().put(variable, entry.getValue());
document.append(' ').append(entry.getKey()).append("=\"$")
.append(variable).append('"');
}
document.append("/>");
try {
properties.load(new ByteArrayInputStream(document.toString().getBytes(StandardCharsets.UTF_8)));
} catch (IOException failure) {
throw new IllegalStateException("Could not apply Component properties to "
+ result.getClass().getName(), failure);
}
}
return result;
}
}
/**
@@ -145,6 +145,49 @@ class EditAddressInAComponentTest {
});
}
/**
* Attributes retain FXML conversion and identity while injection precedes setters.
*/
@Test
void componentAttributesApplyAfterInitialization() throws Exception {
onFx(() -> {
var context = new ApplicationContext("de.neitzel.fx.view.component");
var loaded = ViewLoader.load(getClass().getResource("address-component-attributes.fxml"),
new Person("Ada"), context);
var first = (AddressForm) loaded.getRoot().lookup("#first");
var second = (AddressForm) loaded.getRoot().lookup("#second");
assertEquals("Home & work", first.getCaption());
assertEquals(190, first.getPrefWidth());
assertEquals(360, first.getMaxWidth());
assertEquals(false, first.isVisible());
assertEquals(javafx.geometry.Pos.CENTER_RIGHT, first.getAlignment());
assertEquals(1, javafx.scene.layout.GridPane.getColumnIndex(first));
assertSame(first.getModel(), second.getModel());
assertEquals("Second", second.getCaption());
assertEquals(280, second.getPrefWidth());
assertSame(first, context.getComponent(AddressForm.class));
TextField field = (TextField) first.lookup("#streetField");
assertEquals("Main", field.getText());
field.setText("Oak");
assertEquals("Oak", ((Address) first.getModel()).getStreet());
});
}
/**
* Invalid names and malformed values fail rather than disappearing in the builder.
*/
@Test
void invalidComponentAttributesFailTheLoad() throws Exception {
onFx(() -> {
for (String kind : java.util.List.of("unknown", "number")) {
var context = new ApplicationContext("de.neitzel.fx.view.component");
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,
() -> ViewLoader.load(getClass().getResource("address-component-invalid-" + kind + ".fxml"),
new Person("Ada"), context));
}
});
}
/**
* Runs one check on the JavaFX application thread.
*
@@ -67,6 +67,22 @@ public class AddressForm extends VBox implements HasViewModel {
streetLabel.setText(captions.prefix());
}
/**
* Returns the custom caption after initialization.
* @return the caption
*/
public String getCaption() {
return streetLabel.getText();
}
/**
* Sets a custom FXML attribute; the label must already be injected.
* @param caption the caption
*/
public void setCaption(String caption) {
streetLabel.setText(caption);
}
/**
* Returns the model property a parent view sets with a direct binding.
*
@@ -0,0 +1,9 @@
package de.neitzel.fx.view.fixture;
/** Address that FXML can declare to test object-valued Component attributes. */
public class SampleAddress extends Address {
/** Creates an initially empty address. */
public SampleAddress() {
super("");
}
}
@@ -0,0 +1,8 @@
<?import javafx.scene.layout.GridPane?>
<?import de.neitzel.fx.view.component.AddressForm?>
<?import de.neitzel.fx.view.fixture.SampleAddress?>
<GridPane xmlns:fx="http://javafx.com/fxml/1">
<fx:define><SampleAddress fx:id="sample" street="Main"/></fx:define>
<AddressForm fx:id="first" caption="Home &amp; work" prefWidth="190" maxWidth="360" visible="false" alignment="CENTER_RIGHT" model="$sample" GridPane.columnIndex="1"/>
<AddressForm fx:id="second" caption="Second" prefWidth="280" model="$sample" GridPane.rowIndex="1"/>
</GridPane>
@@ -0,0 +1,3 @@
<?import javafx.scene.layout.StackPane?>
<?import de.neitzel.fx.view.component.AddressForm?>
<StackPane xmlns:fx="http://javafx.com/fxml/1"><AddressForm prefWidth="not-a-number"/></StackPane>
@@ -0,0 +1,3 @@
<?import javafx.scene.layout.StackPane?>
<?import de.neitzel.fx.view.component.AddressForm?>
<StackPane xmlns:fx="http://javafx.com/fxml/1"><AddressForm notAProperty="bad"/></StackPane>