Let a Direct binding follow a dotted Path into a nested Model.

One ViewModel per Model instance keeps two Views and a cycle aligned, and a missing nested Model leaves both sides unchanged.
This commit is contained in:
2026-10-30 17:40:06 +01:00
parent 99886efa8b
commit 9474a7acfe
15 changed files with 962 additions and 15 deletions
@@ -4,8 +4,10 @@ package de.neitzel.fx.view;
* A Direct binding declared in FXML.
* <p>
* The element names the control by {@code fx:id}, the control property, the Path, and the
* direction. A Path of one name is that Attribute of this View's ViewModel. Direction values
* are {@code view}, {@code model}, and {@code both}.
* direction. A Path of one name is that Attribute of this View's ViewModel. Further names step
* into the ViewModel of a Complex attribute, and the direction applies to the last name.
* When that last name is complex, the control receives the Model instance behind the ViewModel.
* Direction values are {@code view}, {@code model}, and {@code both}.
*/
public class DirectBinding {
@@ -26,11 +26,20 @@ final class DirectBindingApplier {
if (control == null) {
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
}
Resolved resolved = resolve(viewModel, binding.getPath());
if (resolved == null) {
return;
}
Property<?> controlProperty = controlProperty(control, binding.getProperty());
Property<?> attribute = viewModel.property(binding.getPath());
if (attribute == null) {
throw new IllegalArgumentException(
"No read-write attribute for path \"" + binding.getPath() + "\"");
Property<?> attribute = resolved.attribute;
if (resolved.complex) {
switch (binding.getDirection()) {
case "both" -> alignBothComplex(controlProperty, attribute);
case "view" -> alignTowardViewComplex(controlProperty, attribute);
case "model" -> alignTowardModelComplex(controlProperty, attribute);
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
}
return;
}
switch (binding.getDirection()) {
case "both" -> alignBoth(controlProperty, attribute);
@@ -40,6 +49,61 @@ final class DirectBindingApplier {
}
}
/**
* The Attribute a Path names, and whether that Attribute is complex.
*/
private static final class Resolved {
private final Property<?> attribute;
private final boolean complex;
private Resolved(Property<?> attribute, boolean complex) {
this.attribute = attribute;
this.complex = complex;
}
}
/**
* Follows a Path to the Attribute it names.
* <p>
* One name is an Attribute of {@code viewModel}. Each earlier name steps into the ViewModel
* held by that Complex attribute. A null Complex attribute along the Path leaves the binding
* unapplied.
*
* @param viewModel the ViewModel for this View
* @param path the Path
* @return the Attribute for the last name, or {@code null} when a Complex attribute
* along the Path is null
*/
private static Resolved resolve(ViewModel viewModel, String path) {
String[] names = path.split("\\.", -1);
ViewModel current = viewModel;
for (int index = 0; index < names.length - 1; index++) {
String name = names[index];
Property<?> step = current.property(name);
if (step == null || !current.isComplex(name)) {
throw missing(path);
}
Object nested = step.getValue();
if (nested == null) {
return null;
}
if (!(nested instanceof ViewModel child)) {
throw missing(path);
}
current = child;
}
String last = names[names.length - 1];
Property<?> attribute = current.property(last);
if (attribute == null) {
throw missing(path);
}
return new Resolved(attribute, current.isComplex(last));
}
private static IllegalArgumentException missing(String path) {
return new IllegalArgumentException("No read-write attribute for path \"" + path + "\"");
}
private static Property<?> controlProperty(Object control, String propertyName) {
String methodName = propertyName + "Property";
try {
@@ -57,6 +121,53 @@ final class DirectBindingApplier {
}
}
@SuppressWarnings("unchecked")
private static void alignBothComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) attribute;
control.setValue(modelBehind(source.getValue()));
source.addListener((observable, oldValue, newValue) -> {
Object model = modelBehind(newValue);
if (control.getValue() != model) {
control.setValue(model);
}
});
control.addListener((observable, oldValue, newValue) -> {
if (modelBehind(source.getValue()) == newValue) {
return;
}
source.setValue(newValue == null ? null : ViewModels.of(newValue));
});
}
@SuppressWarnings("unchecked")
private static void alignTowardViewComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) attribute;
control.setValue(modelBehind(source.getValue()));
source.addListener((observable, oldValue, newValue) -> control.setValue(modelBehind(newValue)));
}
@SuppressWarnings("unchecked")
private static void alignTowardModelComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) attribute;
control.setValue(modelBehind(source.getValue()));
control.addListener((observable, oldValue, newValue) -> {
if (modelBehind(source.getValue()) == newValue) {
return;
}
source.setValue(newValue == null ? null : ViewModels.of(newValue));
});
}
private static Object modelBehind(Object value) {
if (value instanceof ViewModel viewModel) {
return viewModel.model();
}
return null;
}
@SuppressWarnings("unchecked")
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
@@ -18,13 +18,17 @@ public final class ViewLoader {
/**
* Loads a View for one Model instance.
* <p>
* Builds a ViewModel for {@code model}, loads {@code fxml}, and applies Direct bindings
* declared in {@code fx:define}. Direction {@code both} keeps the control and the Attribute
* Reuses the ViewModel already built for {@code model}, or builds one, then loads {@code fxml}
* and applies Direct bindings declared in {@code fx:define}. Direction {@code both} keeps the
* control and the Attribute
* aligned, so editing the control writes the Attribute immediately. Direction {@code view}
* shows the Attribute and follows later property changes without writing the Attribute back.
* Direction {@code model} shows the current value, writes the Attribute when the control
* changes, and does not push later property changes to the control. A path that is not a
* read-write Attribute fails the load. The context is used when the document names a Controller.
* changes, and does not push later property changes to the control. A Path of one name is an
* Attribute of this View's ViewModel. Further names step into a nested ViewModel, and the
* direction applies to the last name. When a Complex attribute along the Path is null, the
* control and the Model keep their current values. A path that is not a read-write Attribute
* fails the load. The context is used when the document names a Controller.
*
* @param fxml the FXML document
* @param model the Model instance
@@ -33,7 +37,7 @@ public final class ViewLoader {
* @throws IllegalArgumentException when a Direct binding names a path that is not a read-write Attribute
*/
public static LoadedView load(URL fxml, Object model, ApplicationContext context) {
ViewModel viewModel = new ViewModel(model);
ViewModel viewModel = ViewModels.of(model);
LoadSession session = LoadSession.open();
try {
FXMLLoader loader = new FXMLLoader(fxml);
@@ -20,9 +20,14 @@ import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The object a View binds to, built at runtime for one Model instance.
@@ -31,14 +36,21 @@ import java.util.Map;
* property under the Attribute name. The property class follows the Attribute's declared type,
* including when the current value is null. String, integer, long, float, double, and boolean
* attributes use the matching JavaFX property. Any other declared type is an object property.
* A Complex attribute, a read-write Attribute whose declared type itself has a read-write
* Attribute, stores the ViewModel for the nested Model instance, or null when the Attribute
* is null. Strings, numbers, booleans, enums, {@link Date}, {@link Calendar}, and {@code java.time}
* values stay simple even when that type has setters. A collection is not a Complex attribute.
* A cycle, or another Attribute that reaches the same instance, reuses that ViewModel.
* The property is set from the Attribute when the ViewModel is created. A null number or
* boolean leaves the primitive property at its default. After that, a change of the property
* writes the Attribute. A call to the entity's setter does not update the property.
* writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
* A call to the entity's setter does not update the property.
*/
public class ViewModel {
private final Object model;
private final Map<String, Property<?>> properties = new HashMap<>();
private final Set<String> complexAttributes = new HashSet<>();
/**
* Builds a ViewModel for one Model instance.
@@ -47,6 +59,7 @@ public class ViewModel {
*/
ViewModel(Object model) {
this.model = model;
ViewModels.register(model, this);
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
Method getter = descriptor.getReadMethod();
Method setter = descriptor.getWriteMethod();
@@ -54,9 +67,19 @@ public class ViewModel {
continue;
}
String name = descriptor.getName();
Property<?> property = createProperty(descriptor.getPropertyType(), read(getter, name));
watch(property, setter, name);
properties.put(name, property);
Class<?> type = descriptor.getPropertyType();
Object current = read(getter, name);
if (isComplex(type)) {
Property<?> property = new SimpleObjectProperty<>(
current == null ? null : ViewModels.of(current));
watchComplex(property, setter, name);
properties.put(name, property);
complexAttributes.add(name);
} else {
Property<?> property = createProperty(type, current);
watch(property, setter, name);
properties.put(name, property);
}
}
}
@@ -70,6 +93,25 @@ public class ViewModel {
return properties.get(name);
}
/**
* Returns the Model instance this ViewModel was built for.
*
* @return the Model instance
*/
Object model() {
return model;
}
/**
* Reports whether an Attribute is complex.
*
* @param name the Attribute name
* @return {@code true} when that Attribute holds a nested ViewModel
*/
boolean isComplex(String name) {
return complexAttributes.contains(name);
}
private static List<PropertyDescriptor> descriptors(Class<?> type) {
try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
@@ -79,6 +121,38 @@ public class ViewModel {
}
}
private static boolean isComplex(Class<?> type) {
if (simpleValue(type)) {
return false;
}
for (PropertyDescriptor descriptor : descriptors(type)) {
if (descriptor.getReadMethod() != null
&& descriptor.getWriteMethod() != null
&& descriptor.getPropertyType() != null) {
return true;
}
}
return false;
}
private static boolean simpleValue(Class<?> type) {
if (type.isPrimitive() || type.isEnum() || type.isArray()
|| type == String.class || type == Boolean.class) {
return true;
}
if (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type)) {
return true;
}
if (Number.class.isAssignableFrom(type)) {
return true;
}
if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
return true;
}
Package packaged = type.getPackage();
return packaged != null && packaged.getName().startsWith("java.time");
}
private static Property<?> createProperty(Class<?> type, Object value) {
if (type == String.class) {
return new SimpleStringProperty((String) value);
@@ -153,6 +227,13 @@ public class ViewModel {
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
}
private void watchComplex(Property<?> property, Method setter, String name) {
property.addListener((observable, oldValue, newValue) -> {
Object written = newValue instanceof ViewModel viewModel ? viewModel.model() : newValue;
write(setter, name, written);
});
}
private void write(Method setter, String name, Object value) {
try {
setter.invoke(model, value);
@@ -0,0 +1,41 @@
package de.neitzel.fx.view;
import java.util.IdentityHashMap;
import java.util.Map;
/**
* The ViewModels already built, one for each Model instance.
* <p>
* Instances are compared by identity, so two Models that are equal still get two ViewModels.
*/
final class ViewModels {
private static final Map<Object, ViewModel> BY_INSTANCE = new IdentityHashMap<>();
private ViewModels() {
}
/**
* Returns the ViewModel for a Model instance, building it when none exists yet.
*
* @param model the Model instance
* @return the ViewModel for that instance
*/
static ViewModel of(Object model) {
ViewModel existing = BY_INSTANCE.get(model);
if (existing != null) {
return existing;
}
return new ViewModel(model);
}
/**
* Records a ViewModel before its Attributes are walked, so a cycle reuses it.
*
* @param model the Model instance
* @param viewModel the ViewModel under construction
*/
static void register(Object model, ViewModel viewModel) {
BY_INSTANCE.put(model, viewModel);
}
}