Let a Controller name the ViewModel class and receive it before bindings.
A second View reuses that one instance, and fails the load when it names a different class. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,12 +42,13 @@ public class BindingList {
|
||||
* Applies every binding element in this list.
|
||||
*
|
||||
* @param namespace objects addressed by {@code fx:id}
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param viewModel the ViewModel for this View, or a replacement that exposes JavaFX properties
|
||||
* by name
|
||||
* @param controller the Controller for this View, or {@code null} when the document names none
|
||||
* @param root the scene-graph root of this View
|
||||
* @param context the container that creates a named Converter
|
||||
*/
|
||||
void apply(Map<String, Object> namespace, ViewModel viewModel, Object controller, Parent root,
|
||||
void apply(Map<String, Object> namespace, Object viewModel, Object controller, Parent root,
|
||||
ApplicationContext context) {
|
||||
for (Object element : elements) {
|
||||
if (element instanceof DirectBinding directBinding) {
|
||||
|
||||
@@ -18,7 +18,8 @@ import java.util.Map;
|
||||
* <p>
|
||||
* The container creates the instance. The document uses {@code fx:root} with that instance as
|
||||
* the root and as the Controller. Bindings declared in the document wait until the model
|
||||
* property holds a non-null Model. A Component that implements {@link HasViewModel} is handed
|
||||
* property holds a non-null Model. {@link ViewModelClass} on the Component names the ViewModel
|
||||
* class constructed for that Model. A Component that implements {@link HasViewModel} is handed
|
||||
* that ViewModel after the property is set and before those bindings run.
|
||||
*/
|
||||
final class Components {
|
||||
@@ -31,7 +32,9 @@ final class Components {
|
||||
* <p>
|
||||
* An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
|
||||
* throw {@link IllegalArgumentException}. This method returns null so FXML constructs that
|
||||
* type itself. A failure while creating a known Component is not caught.
|
||||
* type itself. A Component that extends a JavaFX class is also left to FXML when the document
|
||||
* names that JavaFX class: the container would otherwise hand back the Component. A failure
|
||||
* while creating a known Component is not caught.
|
||||
*
|
||||
* @param type the type FXML is about to construct
|
||||
* @param context the container that creates Components
|
||||
@@ -44,6 +47,9 @@ final class Components {
|
||||
} catch (IllegalArgumentException notAComponent) {
|
||||
return null;
|
||||
}
|
||||
if (component.getClass() != type) {
|
||||
return null;
|
||||
}
|
||||
return () -> load(component, context);
|
||||
}
|
||||
|
||||
@@ -79,9 +85,9 @@ final class Components {
|
||||
return;
|
||||
}
|
||||
applied[0] = true;
|
||||
ViewModel viewModel = ViewModels.of(newValue);
|
||||
if (component instanceof HasViewModel hasViewModel) {
|
||||
hasViewModel.setViewModel(viewModel);
|
||||
Object viewModel = ViewModels.of(newValue, ViewModels.namedOn(component.getClass()));
|
||||
if (component instanceof HasViewModel hasViewModel && viewModel instanceof ViewModel bound) {
|
||||
hasViewModel.setViewModel(bound);
|
||||
}
|
||||
Parent root = (Parent) component;
|
||||
for (BindingList list : lists) {
|
||||
|
||||
@@ -35,7 +35,7 @@ final class ComputedBindingApplier {
|
||||
*/
|
||||
static void apply(
|
||||
Map<String, Object> namespace,
|
||||
ViewModel viewModel,
|
||||
Object viewModel,
|
||||
Object controller,
|
||||
Parent root,
|
||||
ComputedBinding binding) {
|
||||
@@ -47,7 +47,9 @@ final class ComputedBindingApplier {
|
||||
Runnable refresh = refresh(controller, binding.getMethod(), target);
|
||||
refresh.run();
|
||||
watchControls(root, target, refresh);
|
||||
viewModel.addAttributeListener((observable, oldValue, newValue) -> refresh.run());
|
||||
if (viewModel instanceof ViewModel model) {
|
||||
model.addAttributeListener((observable, oldValue, newValue) -> refresh.run());
|
||||
}
|
||||
}
|
||||
|
||||
private static Runnable refresh(Object controller, String methodValue, Property<Object> target) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import javafx.collections.ObservableList;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -24,12 +25,13 @@ final class DirectBindingApplier {
|
||||
* @param binding the declaration
|
||||
* @param context the container that creates a named Converter
|
||||
*/
|
||||
static void apply(Map<String, Object> namespace, ViewModel viewModel, DirectBinding binding,
|
||||
static void apply(Map<String, Object> namespace, Object viewModel, DirectBinding binding,
|
||||
ApplicationContext context) {
|
||||
Object control = namespace.get(binding.getControl());
|
||||
if (control == null) {
|
||||
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
||||
}
|
||||
selectComponentViewModel(control, viewModel, binding);
|
||||
Resolved resolved = resolve(viewModel, binding.getPath());
|
||||
if (resolved == null) {
|
||||
return;
|
||||
@@ -107,13 +109,16 @@ final class DirectBindingApplier {
|
||||
* @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) {
|
||||
private static Resolved resolve(Object viewModel, String path) {
|
||||
String[] names = path.split("\\.", -1);
|
||||
ViewModel current = viewModel;
|
||||
Object current = viewModel;
|
||||
for (int index = 0; index < names.length - 1; index++) {
|
||||
if (!(current instanceof ViewModel stepModel)) {
|
||||
throw missing(path);
|
||||
}
|
||||
String name = names[index];
|
||||
Property<?> step = current.property(name);
|
||||
if (step == null || !current.isComplex(name)) {
|
||||
Property<?> step = stepModel.property(name);
|
||||
if (step == null || !stepModel.isComplex(name)) {
|
||||
throw missing(path);
|
||||
}
|
||||
Object nested = step.getValue();
|
||||
@@ -126,11 +131,74 @@ final class DirectBindingApplier {
|
||||
current = child;
|
||||
}
|
||||
String last = names[names.length - 1];
|
||||
Property<?> attribute = current.property(last);
|
||||
Property<?> attribute = propertyOf(current, last);
|
||||
if (attribute == null) {
|
||||
throw missing(path);
|
||||
}
|
||||
return new Resolved(attribute, current.isComplex(last), current, last);
|
||||
boolean complex = current instanceof ViewModel stepped && stepped.isComplex(last);
|
||||
ViewModel owner = current instanceof ViewModel model ? model : null;
|
||||
return new Resolved(attribute, complex, owner, last);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ViewModel named on a Component before the Path is read.
|
||||
* <p>
|
||||
* The binding sets the Component's model property. Reading the Path would otherwise build a
|
||||
* generic ViewModel first and fix that class.
|
||||
*
|
||||
* @param control the control named by the binding
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param binding the declaration
|
||||
*/
|
||||
private static void selectComponentViewModel(Object control, Object viewModel, DirectBinding binding) {
|
||||
if (!"model".equals(binding.getProperty())) {
|
||||
return;
|
||||
}
|
||||
Class<?> requested = ViewModels.namedOn(control.getClass());
|
||||
if (requested == null) {
|
||||
return;
|
||||
}
|
||||
Object instance = instanceAt(viewModel, binding.getPath());
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
ViewModels.of(instance, requested);
|
||||
}
|
||||
|
||||
private static Object instanceAt(Object viewModel, String path) {
|
||||
if (!(viewModel instanceof ViewModel root)) {
|
||||
return null;
|
||||
}
|
||||
Object current = root.model();
|
||||
for (String name : path.split("\\.", -1)) {
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
current = readAttribute(current, name);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static Object readAttribute(Object bean, String name) {
|
||||
String suffix = Character.toUpperCase(name.charAt(0)) + name.substring(1);
|
||||
for (String prefix : List.of("get", "is")) {
|
||||
try {
|
||||
Method method = bean.getClass().getMethod(prefix + suffix);
|
||||
return method.invoke(bean);
|
||||
} catch (NoSuchMethodException notThisPrefix) {
|
||||
continue;
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read attribute " + name, failure);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("No read-write attribute for path \"" + name + "\"");
|
||||
}
|
||||
|
||||
private static Property<?> propertyOf(Object target, String name) {
|
||||
if (target instanceof ViewModel viewModel) {
|
||||
return viewModel.property(name);
|
||||
}
|
||||
return NamedProperties.get(target, name);
|
||||
}
|
||||
|
||||
private static IllegalArgumentException missing(String path) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NamedNodeMap;
|
||||
import org.w3c.dom.Node;
|
||||
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* Reads the Controller class named by an FXML document.
|
||||
*/
|
||||
final class FxmlControllers {
|
||||
|
||||
private static final String FXML_NAMESPACE = "http://javafx.com/fxml";
|
||||
|
||||
private FxmlControllers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Controller class named on the document root, or {@code null} when the document
|
||||
* names none.
|
||||
*
|
||||
* @param fxml the FXML document
|
||||
* @return the Controller class, or {@code null}
|
||||
*/
|
||||
static Class<?> controllerClass(URL fxml) {
|
||||
String name = controllerName(fxml);
|
||||
if (name == null || name.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Class.forName(name);
|
||||
} catch (ClassNotFoundException failure) {
|
||||
throw new IllegalArgumentException("No controller " + name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String controllerName(URL fxml) {
|
||||
try (InputStream input = fxml.openStream()) {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
Element root = factory.newDocumentBuilder().parse(input).getDocumentElement();
|
||||
String named = root.getAttributeNS(FXML_NAMESPACE, "controller");
|
||||
if (named != null && !named.isEmpty()) {
|
||||
return named;
|
||||
}
|
||||
NamedNodeMap attributes = root.getAttributes();
|
||||
for (int index = 0; index < attributes.getLength(); index++) {
|
||||
Node attribute = attributes.item(index);
|
||||
if ("controller".equals(attribute.getLocalName())
|
||||
|| "fx:controller".equals(attribute.getNodeName())) {
|
||||
return attribute.getNodeValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (IOException | ParserConfigurationException | SAXException failure) {
|
||||
throw new IllegalStateException("Could not read controller from " + fxml, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,16 @@ import javafx.scene.Parent;
|
||||
public final class LoadedView {
|
||||
|
||||
private final Parent root;
|
||||
private final ViewModel viewModel;
|
||||
private final Object viewModel;
|
||||
|
||||
/**
|
||||
* Creates the result of one load.
|
||||
*
|
||||
* @param root the scene-graph root
|
||||
* @param viewModel the ViewModel built for the Model instance
|
||||
* @param viewModel the ViewModel built for the Model instance, or a replacement that exposes
|
||||
* JavaFX properties by name
|
||||
*/
|
||||
LoadedView(Parent root, ViewModel viewModel) {
|
||||
LoadedView(Parent root, Object viewModel) {
|
||||
this.root = root;
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
@@ -33,11 +34,18 @@ public final class LoadedView {
|
||||
|
||||
/**
|
||||
* Returns the ViewModel the View is bound to.
|
||||
* <p>
|
||||
* A generic ViewModel, and a subclass of it, are returned as that ViewModel. A replacement
|
||||
* that does not extend {@link ViewModel} is addressed through {@link #property(String)}.
|
||||
*
|
||||
* @return the ViewModel
|
||||
* @throws IllegalStateException when the View is bound to a replacement
|
||||
*/
|
||||
public ViewModel getViewModel() {
|
||||
return viewModel;
|
||||
if (viewModel instanceof ViewModel bound) {
|
||||
return bound;
|
||||
}
|
||||
throw new IllegalStateException("View is bound to " + viewModel.getClass().getName());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,11 +54,15 @@ public final class LoadedView {
|
||||
* The property's class follows the Attribute's declared type. The name {@code latestMessage}
|
||||
* is a string property holding the text of the latest Message.
|
||||
*
|
||||
* @param name the Attribute name, or {@code latestMessage}
|
||||
* @return the property, or {@code null} when that name is not a read-write Attribute
|
||||
* and is not {@code latestMessage}
|
||||
* @param name the Attribute name, {@code latestMessage}, or a JavaFX property declared on
|
||||
* the ViewModel
|
||||
* @return the property, or {@code null} when that name is not a read-write Attribute,
|
||||
* not {@code latestMessage}, and not a JavaFX property declared on the ViewModel
|
||||
*/
|
||||
public Property<?> property(String name) {
|
||||
return viewModel.property(name);
|
||||
if (viewModel instanceof ViewModel bound) {
|
||||
return bound.property(name);
|
||||
}
|
||||
return NamedProperties.get(viewModel, name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Reads a JavaFX property declared on an object by name.
|
||||
* <p>
|
||||
* A name {@code badge} is the no-argument method {@code badgeProperty}.
|
||||
*/
|
||||
final class NamedProperties {
|
||||
|
||||
private NamedProperties() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX property named on {@code bean}.
|
||||
*
|
||||
* @param bean the object that declares the property
|
||||
* @param name the property name
|
||||
* @return the property, or {@code null} when {@code bean} declares none of that name
|
||||
*/
|
||||
static Property<?> get(Object bean, String name) {
|
||||
String methodName = name + "Property";
|
||||
try {
|
||||
Method method = bean.getClass().getMethod(methodName);
|
||||
if (!Property.class.isAssignableFrom(method.getReturnType())) {
|
||||
return null;
|
||||
}
|
||||
Object value = method.invoke(bean);
|
||||
if (value instanceof Property<?> property) {
|
||||
return property;
|
||||
}
|
||||
return null;
|
||||
} catch (NoSuchMethodException notDeclared) {
|
||||
return null;
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read property " + name, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* Controller used when a document names none.
|
||||
* <p>
|
||||
* A View with only Direct bindings still loads. The library hands this controller the ViewModel
|
||||
* after construction and before bindings, the same way it hands a Controller the developer wrote.
|
||||
*/
|
||||
final class SuppliedController implements HasViewModel {
|
||||
|
||||
private ViewModel viewModel;
|
||||
|
||||
/**
|
||||
* Receives the ViewModel for this View.
|
||||
*
|
||||
* @param viewModel the ViewModel built for the Model instance
|
||||
*/
|
||||
@Override
|
||||
public void setViewModel(ViewModel viewModel) {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel handed to this controller.
|
||||
*
|
||||
* @return the ViewModel, or {@code null} when none has been handed over
|
||||
*/
|
||||
ViewModel viewModel() {
|
||||
return viewModel;
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,16 @@ public final class ViewLoader {
|
||||
* control and the Model keep their current values. A collection of simple values is one shared
|
||||
* list: the Direct binding sets the control property to that
|
||||
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
|
||||
* A path that is not a read-write Attribute fails the load. The context creates a Controller,
|
||||
* and a Converter when a Direct binding names one. A converter that cannot produce a value
|
||||
* A path that is not a read-write Attribute fails the load. The context creates a Controller
|
||||
* the document names, and injects that Controller's services. When the document names no
|
||||
* Controller, the library supplies one. A Controller that implements {@link HasViewModel}
|
||||
* receives the ViewModel after construction and before bindings. {@link ViewModelClass} on that
|
||||
* Controller names the ViewModel class. The library constructs it with the Model instance
|
||||
* before load. With no annotation, the library builds the generic ViewModel. The first View of a
|
||||
* Model instance fixes that class. A later View that names a different class fails the load,
|
||||
* and the earlier View stays as it was. A later View that names none reuses the ViewModel.
|
||||
* The context creates a
|
||||
* Converter when a Direct binding names one. A converter that cannot produce a value
|
||||
* keeps the previous value and records one error Message on the ViewModel that owns the last
|
||||
* Attribute, with that Attribute's name as the source. A parent carries a copy with the source
|
||||
* extended. It does not fail the load.
|
||||
@@ -40,7 +48,8 @@ public final class ViewLoader {
|
||||
* and injects its services. The Component document {@code ClassName.fxml}, in the same package,
|
||||
* is loaded with the Component as {@code fx:root} and as the Controller. That document's
|
||||
* bindings are not applied during this load. They run when the Component's model property
|
||||
* becomes a non-null Model. A Component that implements {@link HasViewModel} receives that
|
||||
* becomes a non-null Model. {@link ViewModelClass} on the Component names the ViewModel class
|
||||
* for that Model. A Component that implements {@link HasViewModel} receives that
|
||||
* ViewModel after the property is set and before those bindings run.
|
||||
* Loading does not open a popup or fill a status bar. A Direct binding whose path is
|
||||
* {@code latestMessage} shows the latest Message text. {@link MessagePopup#showNewMessages}
|
||||
@@ -53,7 +62,8 @@ 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 = ViewModels.of(model);
|
||||
Class<?> viewModelClass = ViewModels.namedOn(FxmlControllers.controllerClass(fxml));
|
||||
Object viewModel = ViewModels.of(model, viewModelClass);
|
||||
LoadSession session = LoadSession.open();
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(fxml);
|
||||
@@ -63,8 +73,15 @@ public final class ViewLoader {
|
||||
if (!(loaded instanceof Parent root)) {
|
||||
throw new IllegalArgumentException("View root must be a parent");
|
||||
}
|
||||
Object controller = loader.getController();
|
||||
if (controller == null) {
|
||||
controller = new SuppliedController();
|
||||
}
|
||||
if (controller instanceof HasViewModel hasViewModel && viewModel instanceof ViewModel bound) {
|
||||
hasViewModel.setViewModel(bound);
|
||||
}
|
||||
for (BindingList bindingList : session.bindingLists()) {
|
||||
bindingList.apply(loader.getNamespace(), viewModel, loader.getController(), root, context);
|
||||
bindingList.apply(loader.getNamespace(), viewModel, controller, root, context);
|
||||
}
|
||||
return new LoadedView(root, viewModel);
|
||||
} catch (IOException failure) {
|
||||
|
||||
@@ -91,10 +91,13 @@ public class ViewModel {
|
||||
|
||||
/**
|
||||
* Builds a ViewModel for one Model instance.
|
||||
* <p>
|
||||
* A subclass in another package calls this constructor and may add JavaFX properties under
|
||||
* new names. A Binding element addresses those names the same way it addresses an Attribute.
|
||||
*
|
||||
* @param model the Model instance
|
||||
*/
|
||||
ViewModel(Object model) {
|
||||
protected ViewModel(Object model) {
|
||||
this.model = model;
|
||||
ViewModels.register(model, this);
|
||||
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
|
||||
@@ -107,9 +110,9 @@ public class ViewModel {
|
||||
Class<?> type = descriptor.getPropertyType();
|
||||
Object current = read(getter, name);
|
||||
if (isComplex(type)) {
|
||||
Property<?> property = new SimpleObjectProperty<>(
|
||||
current == null ? null : ViewModels.of(current));
|
||||
LazyComplex property = new LazyComplex(current);
|
||||
watchComplex(property, setter, name);
|
||||
property.attached();
|
||||
properties.put(name, property);
|
||||
complexAttributes.add(name);
|
||||
} else {
|
||||
@@ -130,15 +133,20 @@ public class ViewModel {
|
||||
* Model attributes. It is a string property. A Direct binding can read it. An in-place
|
||||
* update of that Message refreshes the text and does not replace the Message.
|
||||
*
|
||||
* @param name the Attribute name, or {@code latestMessage}
|
||||
* @return the property, or {@code null} when that name is not a read-write Attribute
|
||||
* and is not {@code latestMessage}
|
||||
* @param name the Attribute name, {@code latestMessage}, or a JavaFX property declared on
|
||||
* this ViewModel
|
||||
* @return the property, or {@code null} when that name is not a read-write Attribute,
|
||||
* not {@code latestMessage}, and not a JavaFX property declared on this ViewModel
|
||||
*/
|
||||
public Property<?> property(String name) {
|
||||
if ("latestMessage".equals(name)) {
|
||||
return latestMessageText;
|
||||
}
|
||||
return properties.get(name);
|
||||
Property<?> stored = properties.get(name);
|
||||
if (stored != null) {
|
||||
return stored;
|
||||
}
|
||||
return declaredProperty(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,6 +180,16 @@ public class ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a JavaFX property a subclass declares under {@code name}, such as {@code titleProperty}.
|
||||
*
|
||||
* @param name the property name
|
||||
* @return the property, or {@code null} when this class declares none
|
||||
*/
|
||||
private Property<?> declaredProperty(String name) {
|
||||
return NamedProperties.get(this, name);
|
||||
}
|
||||
|
||||
private static List<PropertyDescriptor> descriptors(Class<?> type) {
|
||||
try {
|
||||
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
|
||||
@@ -287,10 +305,41 @@ public class ViewModel {
|
||||
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
|
||||
}
|
||||
|
||||
private void watchComplex(Property<?> property, Method setter, String name) {
|
||||
if (property.getValue() instanceof ViewModel child) {
|
||||
attachChild(name, child);
|
||||
/**
|
||||
* Holds a Complex attribute and builds its ViewModel the first time the value is read.
|
||||
* <p>
|
||||
* Attaching the change listener reads the value. That read is ignored until
|
||||
* {@link #attached()} so a Component annotation can name the class first.
|
||||
*/
|
||||
private static final class LazyComplex extends SimpleObjectProperty<Object> {
|
||||
|
||||
private final Object current;
|
||||
private boolean realized;
|
||||
private boolean attaching = true;
|
||||
|
||||
private LazyComplex(Object current) {
|
||||
this.current = current;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get() {
|
||||
if (!attaching && !realized) {
|
||||
realized = true;
|
||||
super.set(current == null ? null : ViewModels.of(current));
|
||||
}
|
||||
return super.get();
|
||||
}
|
||||
|
||||
private void attached() {
|
||||
attaching = false;
|
||||
}
|
||||
|
||||
private boolean pending(Object instance) {
|
||||
return !realized && current == instance;
|
||||
}
|
||||
}
|
||||
|
||||
private void watchComplex(Property<?> property, Method setter, String name) {
|
||||
property.addListener((observable, oldValue, newValue) -> {
|
||||
if (oldValue != newValue) {
|
||||
detachChild(name);
|
||||
@@ -299,6 +348,7 @@ public class ViewModel {
|
||||
write(setter, name, written);
|
||||
if (oldValue != newValue && newValue instanceof ViewModel child) {
|
||||
attachChild(name, child);
|
||||
realizeShared(child.model());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -309,6 +359,25 @@ public class ViewModel {
|
||||
* @param name the Attribute that leads to the child
|
||||
* @param child the child ViewModel
|
||||
*/
|
||||
/**
|
||||
* Realizes every other Complex attribute that holds the same Model instance.
|
||||
* <p>
|
||||
* Home and work can name one address. Reading one still carries a notice under both
|
||||
* attribute names.
|
||||
*
|
||||
* @param instance the Model instance just selected
|
||||
*/
|
||||
private void realizeShared(Object instance) {
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
for (Property<?> property : properties.values()) {
|
||||
if (property instanceof LazyComplex lazy && lazy.pending(instance)) {
|
||||
lazy.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void attachChild(String name, ViewModel child) {
|
||||
childMirrors.put(name, ChildMessageMirror.attach(this, name, child));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Names the ViewModel class for a Controller or a Component.
|
||||
* <p>
|
||||
* The library constructs that class with the Model instance for the first View of the instance.
|
||||
* A later View that names a different class fails to load. A later View that names none reuses
|
||||
* the ViewModel already built. The container does not create the ViewModel.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ViewModelClass {
|
||||
|
||||
/**
|
||||
* Returns the ViewModel class.
|
||||
* <p>
|
||||
* The class has a constructor that takes the Model instance. It may extend {@link ViewModel}
|
||||
* or be another object that exposes JavaFX properties by name.
|
||||
*
|
||||
* @return the ViewModel class
|
||||
*/
|
||||
Class<?> value();
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -10,23 +12,107 @@ import java.util.Map;
|
||||
*/
|
||||
final class ViewModels {
|
||||
|
||||
private static final Map<Object, ViewModel> BY_INSTANCE = new IdentityHashMap<>();
|
||||
private static final Map<Object, Object> BY_INSTANCE = new IdentityHashMap<>();
|
||||
|
||||
private ViewModels() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel for a Model instance, building it when none exists yet.
|
||||
* Returns the ViewModel for a Model instance, building the generic one when none exists yet.
|
||||
*
|
||||
* @param model the Model instance
|
||||
* @return the ViewModel for that instance
|
||||
* @return the ViewModel for that instance, or a replacement that exposes JavaFX properties by name
|
||||
*/
|
||||
static ViewModel of(Object model) {
|
||||
ViewModel existing = BY_INSTANCE.get(model);
|
||||
static Object of(Object model) {
|
||||
return of(model, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel for a Model instance.
|
||||
* <p>
|
||||
* Builds {@code viewModelClass} with the Model when none exists yet. A null class, or
|
||||
* {@link ViewModel} itself, builds the generic ViewModel. The class may extend {@link ViewModel}
|
||||
* or be a replacement that exposes JavaFX properties by name. The container does not create it.
|
||||
* The first View fixes the class. A later View that names a different class fails. A later
|
||||
* View that names none reuses the ViewModel.
|
||||
*
|
||||
* @param model the Model instance
|
||||
* @param viewModelClass the class named by {@link ViewModelClass}, or {@code null}
|
||||
* @return the ViewModel for that instance, or the replacement
|
||||
*/
|
||||
static Object of(Object model, Class<?> viewModelClass) {
|
||||
Object existing = BY_INSTANCE.get(model);
|
||||
if (existing != null) {
|
||||
if (viewModelClass != null && viewModelClass != ViewModel.class
|
||||
&& existing.getClass() != viewModelClass) {
|
||||
throw new IllegalArgumentException(
|
||||
"ViewModel is " + existing.getClass().getName()
|
||||
+ " but the View names " + viewModelClass.getName());
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return new ViewModel(model);
|
||||
if (viewModelClass == null || viewModelClass == ViewModel.class) {
|
||||
return new ViewModel(model);
|
||||
}
|
||||
return construct(model, viewModelClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel class named on a Controller or Component, or {@code null} when the
|
||||
* type names none.
|
||||
*
|
||||
* @param type the Controller or Component class
|
||||
* @return the named class, or {@code null}
|
||||
*/
|
||||
static Class<?> namedOn(Class<?> type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
ViewModelClass annotation = type.getAnnotation(ViewModelClass.class);
|
||||
if (annotation == null) {
|
||||
return null;
|
||||
}
|
||||
return annotation.value();
|
||||
}
|
||||
|
||||
private static Object construct(Object model, Class<?> viewModelClass) {
|
||||
Constructor<?> constructor = constructor(model, viewModelClass);
|
||||
constructor.setAccessible(true);
|
||||
try {
|
||||
Object created = constructor.newInstance(model);
|
||||
if (!(created instanceof ViewModel)) {
|
||||
register(model, created);
|
||||
}
|
||||
return created;
|
||||
} catch (InvocationTargetException failure) {
|
||||
if (failure.getCause() instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
throw new IllegalStateException("Could not construct " + viewModelClass.getName(), failure);
|
||||
} catch (IllegalAccessException | InstantiationException failure) {
|
||||
throw new IllegalStateException("Could not construct " + viewModelClass.getName(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Constructor<?> constructor(Object model, Class<?> viewModelClass) {
|
||||
Constructor<?> chosen = null;
|
||||
for (Constructor<?> candidate : viewModelClass.getDeclaredConstructors()) {
|
||||
if (candidate.getParameterCount() != 1) {
|
||||
continue;
|
||||
}
|
||||
Class<?> parameter = candidate.getParameterTypes()[0];
|
||||
if (!parameter.isAssignableFrom(model.getClass())) {
|
||||
continue;
|
||||
}
|
||||
if (chosen == null || chosen.getParameterTypes()[0].isAssignableFrom(parameter)) {
|
||||
chosen = candidate;
|
||||
}
|
||||
}
|
||||
if (chosen == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"No constructor taking the Model on " + viewModelClass.getName());
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +121,7 @@ final class ViewModels {
|
||||
* @param model the Model instance
|
||||
* @param viewModel the ViewModel under construction
|
||||
*/
|
||||
static void register(Object model, ViewModel viewModel) {
|
||||
static void register(Object model, Object viewModel) {
|
||||
BY_INSTANCE.put(model, viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user