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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user