Apply Component FXML attributes after injected initialization
This commit is contained in:
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user