Introduction to the ILcyCustomizerPanel interface
The ILcyCustomizerPanel
interface defines a contract for UI elements to customize a certain object.
The panel offers the ILcyCustomizerPanel.setObject
method to specify which object must be customized.
Once the object is set on the customizer panel, it is the responsibility of the panel to:
-
Listen for changes made to the object:_ whenever the object changes, the panel must update its UI to match the state of the object.
-
Indicate when the user made changes to the panel: whenever the user makes a change in the UI of the panel, the panel must indicate that changes are pending. It can do so by firing a
PropertyChangeEvent
for thechangesPending
property. -
Apply the UI changes onto the object: when the
ILcyCustomizerPanel.applyChanges
method is called, the panel must apply the current state of the UI onto the object. For example, the Object Properties panel action calls this method whenever the panel indicates that changes are pending. Consequently, when the user makes a change in the panel, the changes are immediately applied and visible on the map.
You can use the same |
Typically those customizer panel instances are created through a ILcyCustomizerPanelFactory
.
That interface defines a simple factory class that offers methods to:
-
Check whether it can create a customizer panel for a certain object
-
Create the customizer panel for a certain object
Goal
In this tutorial, we implement an ILcyCustomizerPanel
for a waypoint domain object.
The waypoint object is an ILcdDataObject
with two properties:
-
An identifier, which is a string
-
A location, which is a
TLcdLonLatHeightPoint
The waypoint data format is introduced in the Support a custom vector format tutorial. You can check that tutorial for all details about the format, but for this tutorial it is sufficient to know that those waypoints are data objects with two properties. |
We will create a panel that contains:
-
A text field that allows users to change the identifier
-
A text field that allows users to change the location of the waypoint. This text field accepts only valid coordinates and provides visual feedback to the user when the input string is incorrect.
-
A text field that allows the user to change the height of the waypoint. This text field only accepts distances: a number and a unit.

Creating the UI
As a first step, we create a Swing UI that contains the three text fields.
Because the waypoint customizer panel is designed to edit waypoints, which are elements contained in an ILcdModel
,
we can start from the ALcyDomainObjectCustomizerPanel
class.
In LuciadLightspeed, domain objects are stored as elements in an |
This ALcyDomainObjectCustomizerPanel
class is an extension from javax.swing.JPanel
.
It is an abstract base class that implements part of the ILcyCustomizerPanel
interface for panels operating on domain objects.
To benefit from that functionality, our WayPointCustomizerPanel
extends from this class instead of implementing the interface directly:
public class WayPointCustomizerPanel extends ALcyDomainObjectCustomizerPanel {
}
We now create and add three text fields to this JPanel
.
Although we could use standard Swing code for this, we will use some utility classes available in the Lucy API and sample
code:
private ValidatingTextField fIdentifierField;
private ValidatingTextField fLocationField;
private ValidatingTextField fHeightField;
private void initUI(ILcyLucyEnv aLucyEnv) {
fIdentifierField = new ValidatingTextField(new StringFormat(), aLucyEnv);
fLocationField = new ValidatingTextField(aLucyEnv.getDefaultLonLatPointFormat(), aLucyEnv);
fHeightField = new ValidatingTextField(aLucyEnv.getDefaultAltitudeFormat(), aLucyEnv);
TLcyTwoColumnLayoutBuilder.newBuilder()
.addTitledSeparator("Way point")
.row()
.columnOne(new JLabel("Identifier"), fIdentifierField)
.build()
.row()
.columnOne(new JLabel("Location"), fLocationField)
.build()
.row()
.columnOne(new JLabel("Height"), fHeightField)
.build()
.populate(this);
}
-
The
samples.lucy.util.ValidatingTextField
is aJTextField
that also performs validation. When the user enters an invalid input string, the background color of the text field will become red. -
The
TLcyTwoColumnLayoutBuilder
is a builder class that makes it easy to create a layout of labels and text fields. Consult the class Javadoc for an image.
You are completely free to choose how you create your UI.
There is no need to use the TLcyTwoColumnLayoutBuilder
if you prefer to use another mechanism, such as a
standard BorderLayout
.
Setting the values in the text fields
The text fields in our UI must display the values of the waypoint.
As documented in the class Javadoc of ALcyDomainObjectCustomizerPanel
, this base class calls the abstract method updateCustomizerPanelFromObject
each time the domain object is changed or a new domain object is installed on the panel.
It is in the implementation of this method that we read the values from our domain object, and set them on our text fields:
@Override
protected void updateCustomizerPanelFromObject(boolean aPanelEditable) {
fIdentifierField.setEditable(aPanelEditable);
fLocationField.setEditable(aPanelEditable);
fHeightField.setEditable(aPanelEditable);
ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
if (waypoint != null) {
//Take a read lock so that we can safely read the values from the domain object
try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.readLock(getModel())) {
fIdentifierField.setValue(waypoint.getValue("identifier"));
TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
fLocationField.setValue(location);
fHeightField.setValue(location.getZ());
}
} else {
fIdentifierField.setValue("");
fLocationField.setValue(null);
fHeightField.setValue(0);
}
}
Applying the changes
The panel is also responsible for updating the domain object when somebody calls the ILcyCustomizerPanel.applyChanges
method on it.
The ALcyDomainObjectCustomizerPanel
class already implements this method, and delegates to the abstract method applyChangesImpl
.
In this method, we read the values from the text fields and apply them to the domain object:
@Override
protected boolean applyChangesImpl() {
ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
if (waypoint != null) {
ILcdModel model = getModel();
//Changing a model element requires a write lock
try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.writeLock(model)) {
waypoint.setValue("identifier", fIdentifierField.getValue());
TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
ILcdPoint updatedLocation = (ILcdPoint) fLocationField.getValue();
double height = (double) fHeightField.getValue();
location.move3D(updatedLocation.getX(), updatedLocation.getY(), height);
model.elementChanged(waypoint, ILcdModel.FIRE_LATER);
} finally {
model.fireCollectedModelChanges();
}
}
return true;
}
Gluing it all together: keeping the UI and the domain object in-sync
The introduction mentioned the responsibilities of a customizer panel once an object was installed on it:
-
✓ Listen for changes made to the object and update the panel accordingly: the
ALcyDomainObjectCustomizerPanel
takes care of that for us. It listens to theILcdModel
containing the domain object, and callsupdateCustomizerPanelFromObject
whenever the domain object changes. In our implementation of this method, we update the UI. -
✓ Apply the UI changes to the object when
applyChanges
is called: our implementation ofapplyChangesImpl
reads the values from the UI and applies them to the domain object. -
✗ Indicate when the user made changes to the panel: this is not yet done. Our customizer panel needs to change its
changesPending
property totrue
and fire an event.
A first step is to detect when the values in the text fields are changed. For this we add a listener to the text fields. You can do that in the constructor after the fields have been created, for example:
The constructor requires us to call the super
constructor with a filter and a name:
public WayPointCustomizerPanel(ILcyLucyEnv aLucyEnv) {
super(WAYPOINT_DOMAIN_OBJECT_FILTER, "Way points");
//Create the text fields and add them to this panel
initUI(aLucyEnv);
}
The filter must accept only waypoint domain objects, as that is the only object our panel can deal with:
private static final ILcdFilter WAYPOINT_DOMAIN_OBJECT_FILTER = new ILcdFilter() {
@Override
public boolean accept(Object aObject) {
if (aObject instanceof TLcyDomainObjectContext) {
TLcyDomainObjectContext context = (TLcyDomainObjectContext) aObject;
//Check if the model of the domain object is a waypoint model
return "CWP".equals(context.getModel().getModelDescriptor().getTypeName());
}
return false;
}
};
Now we create and install the listener on the text fields.
The ValidatingTextField
fires a PropertyChangeEvent
for the value
property each time a new, valid value is entered and confirmed by the user.
Our listener listens for that property:
//Install the listener on the text fields
fIdentifierField.addPropertyChangeListener("value", textFieldListener);
fLocationField.addPropertyChangeListener("value", textFieldListener);
fHeightField.addPropertyChangeListener("value", textFieldListener);
The listener itself changes the changesPending
property of the panel:
//Create a listener to detect changes made by the user in the text fields
PropertyChangeListener textFieldListener = evt -> {
if (!fUpdatingUI) {
setChangesPending(true);
}
};
As you can see in the code above, the listener first checks a boolean flag before calling setChangesPending
.
We do this to prevent a loop in the following scenario:
-
The user modifies the domain object on the map
-
The
ALcyDomainObjectCustomizerPanel
detects this change, and callsupdateCustomizerPanelFromObject
. -
In this method, the panel updates the contents of the text fields. This generates an event.
-
The text field listener picks up this event. However, at this point the UI is in-sync with the domain object, so there are no pending changes.
To prevent that loop and allow the text field listener to distinguish between "changes in the text fields made by the user"
and
"changes in the text field made by our code updating the panel", we adjust the updateCustomizerPanelFromObject
to change the state of that flag
before and after updating the text fields:
@Override
protected void updateCustomizerPanelFromObject(boolean aPanelEditable) {
fIdentifierField.setEditable(aPanelEditable);
fLocationField.setEditable(aPanelEditable);
fHeightField.setEditable(aPanelEditable);
boolean old = fUpdatingUI;
try {
//Switch the flag to indicate we are currently updating the panel
//and the changes to the text fields are not made by the user
fUpdatingUI = true;
ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
if (waypoint != null) {
//Take a read lock so that we can safely read the values from the domain object
try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.readLock(getModel())) {
fIdentifierField.setValue(waypoint.getValue("identifier"));
TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
fLocationField.setValue(location);
fHeightField.setValue(location.getZ());
}
} else {
fIdentifierField.setValue("");
fLocationField.setValue(null);
fHeightField.setValue(0);
}
} finally {
//Restore the state of the flag
fUpdatingUI = old;
}
}
The flag itself is a boolean field:
/**
* This boolean field is used to avoid loops:
* <ul>
* <li>When the user updates the waypoint on the map,
* the customizer panel will update the contents of the text fields.</li>
* <li>The listener attached to the text fields would detect this change,
* and indicate that the user made a change in the text fields (which isn't the case).</li>
* </ul>
* This boolean is used to indicate when the customizer panel itself is updating the text fields,
* allowing the text field listener to distinguish between user-made changes
* and changes made by the panel itself.
*/
boolean fUpdatingUI = false;
Keeping the point and distance format in-sync
In the UI, we used the point and distance format from the Lucy back-end for formatting our location and altitude. The Lucy UI and API allow changing this format at runtime.
We always want to use the current version of this format, so we need to update our UI when this format changes at the back-end. For this, we add listeners to the back-end in the constructor:
aLucyEnv.addPropertyChangeListener(new PointFormatListener(this));
aLucyEnv.addPropertyChangeListener(new AltitudeFormatListener(this));
Those listeners are attached to the Lucy back-end, which remains alive as long as our application is running.
To prevent a memory leak, we use an ALcdWeakPropertyChangeListener
, which only keeps a weak reference to our panel.
See the class Javadoc of |
/**
* The location field should be formatted using the point format exposed on the Lucy back-end.
* When this format changes, the UI must be updated.
*/
private static class PointFormatListener extends
ALcdWeakPropertyChangeListener<WayPointCustomizerPanel> {
private PointFormatListener(WayPointCustomizerPanel aObjectToModify) {
super(aObjectToModify);
}
@Override
protected void propertyChangeImpl(WayPointCustomizerPanel aWayPointCustomizerPanel, PropertyChangeEvent aPropertyChangeEvent) {
String propertyName = aPropertyChangeEvent.getPropertyName();
if ("defaultLonLatPointFormat".equals(propertyName)) {
aWayPointCustomizerPanel.updatePointFormat((Format) aPropertyChangeEvent.getNewValue());
}
}
}
/**
* The altitude field should be formatted using the altitude format exposed on the Lucy back-end.
* When this format changes, the UI must be updated
*/
private static class AltitudeFormatListener extends ALcdWeakPropertyChangeListener<WayPointCustomizerPanel> {
private AltitudeFormatListener(WayPointCustomizerPanel aObjectToModify) {
super(aObjectToModify);
}
@Override
protected void propertyChangeImpl(WayPointCustomizerPanel aWayPointCustomizerPanel, PropertyChangeEvent aPropertyChangeEvent) {
String propertyName = aPropertyChangeEvent.getPropertyName();
if ("defaultAltitudeFormat".equals(propertyName) || "defaultUserAltitudeUnit".equals(propertyName)) {
aWayPointCustomizerPanel.updateAltitudeFormat(((ILcyLucyEnv) aPropertyChangeEvent.getSource()).getDefaultAltitudeFormat());
}
}
}
When the listener detects a change in the format, it needs to update the text fields.
Similar to what we did in the updateCustomizerPanelFromObject
method, we switch the state of the boolean flag to indicate that the change to the text fields is not made by the user.
private void updatePointFormat(Format aPointFormat) {
boolean old = fUpdatingUI;
try {
fUpdatingUI = true;
fLocationField.setFormat(aPointFormat, fLocationField.getValue());
} finally {
fUpdatingUI = old;
}
}
private void updateAltitudeFormat(Format aAltitudeFormat) {
boolean old = fUpdatingUI;
try {
fUpdatingUI = true;
fHeightField.setFormat(aAltitudeFormat, fHeightField.getValue());
} finally {
fUpdatingUI = old;
}
}
Using the customizer panel
The customizer panel we created does not contain any buttons to apply the changes. This is intentional. The normal usage of a customizer panel is:
-
Create a container to contain a customizer panel.
-
Decide how to apply the changes made in the customizer panel, for example:
-
Add an Apply button underneath the customizer panel.
-
Use a listener for the
changesPending
property and automatically apply changes as soon as the user makes them.
If the panel already contained an Apply button, the container would have to know that this button is present and would no longer be able to choose when to apply the changes. This limits the possible usages of the panel.
-
For the same reason, a customizer panel should not use a |
-
Create the customizer panel — typically by using an
ILcyCustomizerPanelFactory
— , callsetObject
on it, and display it in the container. -
Once the customizer panel is no longer needed, remove it from the container and call
setObject(null)
on it.
See the Adding support for custom editable data to a Lightspeed view tutorial for plugging in your own customizer panel for your custom data format. |
Full code of the customizer panel
package com.luciad.lucy.addons.tutorial.editabledata.model;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.text.Format;
import javax.swing.JLabel;
import com.luciad.datamodel.ILcdDataObject;
import com.luciad.lucy.ILcyLucyEnv;
import com.luciad.lucy.gui.TLcyTwoColumnLayoutBuilder;
import com.luciad.lucy.gui.customizer.ALcyDomainObjectCustomizerPanel;
import com.luciad.lucy.util.context.TLcyDomainObjectContext;
import com.luciad.model.ILcdModel;
import com.luciad.shape.ALcdShape;
import com.luciad.shape.ILcdPoint;
import com.luciad.shape.shape3D.TLcdLonLatHeightPoint;
import com.luciad.util.ALcdWeakPropertyChangeListener;
import com.luciad.util.ILcdFilter;
import com.luciad.util.concurrent.TLcdLockUtil;
import samples.lucy.text.StringFormat;
import samples.lucy.util.ValidatingTextField;
public class WayPointCustomizerPanel extends ALcyDomainObjectCustomizerPanel {
private static final ILcdFilter WAYPOINT_DOMAIN_OBJECT_FILTER = new ILcdFilter() {
@Override
public boolean accept(Object aObject) {
if (aObject instanceof TLcyDomainObjectContext) {
TLcyDomainObjectContext context = (TLcyDomainObjectContext) aObject;
//Check if the model of the domain object is a waypoint model
return "CWP".equals(context.getModel().getModelDescriptor().getTypeName());
}
return false;
}
};
private ValidatingTextField fIdentifierField;
private ValidatingTextField fLocationField;
private ValidatingTextField fHeightField;
/**
* This boolean field is used to avoid loops:
* <ul>
* <li>When the user updates the waypoint on the map,
* the customizer panel will update the contents of the text fields.</li>
* <li>The listener attached to the text fields would detect this change,
* and indicate that the user made a change in the text fields (which isn't the case).</li>
* </ul>
* This boolean is used to indicate when the customizer panel itself is updating the text fields,
* allowing the text field listener to distinguish between user-made changes
* and changes made by the panel itself.
*/
boolean fUpdatingUI = false;
public WayPointCustomizerPanel(ILcyLucyEnv aLucyEnv) {
super(WAYPOINT_DOMAIN_OBJECT_FILTER, "Way points");
//Create the text fields and add them to this panel
initUI(aLucyEnv);
//Create a listener to detect changes made by the user in the text fields
PropertyChangeListener textFieldListener = evt -> {
if (!fUpdatingUI) {
setChangesPending(true);
}
};
//Install the listener on the text fields
fIdentifierField.addPropertyChangeListener("value", textFieldListener);
fLocationField.addPropertyChangeListener("value", textFieldListener);
fHeightField.addPropertyChangeListener("value", textFieldListener);
aLucyEnv.addPropertyChangeListener(new PointFormatListener(this));
aLucyEnv.addPropertyChangeListener(new AltitudeFormatListener(this));
}
private void initUI(ILcyLucyEnv aLucyEnv) {
fIdentifierField = new ValidatingTextField(new StringFormat(), aLucyEnv);
fLocationField = new ValidatingTextField(aLucyEnv.getDefaultLonLatPointFormat(), aLucyEnv);
fHeightField = new ValidatingTextField(aLucyEnv.getDefaultAltitudeFormat(), aLucyEnv);
TLcyTwoColumnLayoutBuilder.newBuilder()
.addTitledSeparator("Way point")
.row()
.columnOne(new JLabel("Identifier"), fIdentifierField)
.build()
.row()
.columnOne(new JLabel("Location"), fLocationField)
.build()
.row()
.columnOne(new JLabel("Height"), fHeightField)
.build()
.populate(this);
}
@Override
protected void updateCustomizerPanelFromObject(boolean aPanelEditable) {
fIdentifierField.setEditable(aPanelEditable);
fLocationField.setEditable(aPanelEditable);
fHeightField.setEditable(aPanelEditable);
boolean old = fUpdatingUI;
try {
//Switch the flag to indicate we are currently updating the panel
//and the changes to the text fields are not made by the user
fUpdatingUI = true;
ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
if (waypoint != null) {
//Take a read lock so that we can safely read the values from the domain object
try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.readLock(getModel())) {
fIdentifierField.setValue(waypoint.getValue("identifier"));
TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
fLocationField.setValue(location);
fHeightField.setValue(location.getZ());
}
} else {
fIdentifierField.setValue("");
fLocationField.setValue(null);
fHeightField.setValue(0);
}
} finally {
//Restore the state of the flag
fUpdatingUI = old;
}
}
@Override
protected boolean applyChangesImpl() {
ILcdDataObject waypoint = (ILcdDataObject) getDomainObject();
if (waypoint != null) {
ILcdModel model = getModel();
//Changing a model element requires a write lock
try (TLcdLockUtil.Lock autoUnlock = TLcdLockUtil.writeLock(model)) {
waypoint.setValue("identifier", fIdentifierField.getValue());
TLcdLonLatHeightPoint location = (TLcdLonLatHeightPoint) ALcdShape.fromDomainObject(waypoint);
ILcdPoint updatedLocation = (ILcdPoint) fLocationField.getValue();
double height = (double) fHeightField.getValue();
location.move3D(updatedLocation.getX(), updatedLocation.getY(), height);
model.elementChanged(waypoint, ILcdModel.FIRE_LATER);
} finally {
model.fireCollectedModelChanges();
}
}
return true;
}
private void updatePointFormat(Format aPointFormat) {
boolean old = fUpdatingUI;
try {
fUpdatingUI = true;
fLocationField.setFormat(aPointFormat, fLocationField.getValue());
} finally {
fUpdatingUI = old;
}
}
private void updateAltitudeFormat(Format aAltitudeFormat) {
boolean old = fUpdatingUI;
try {
fUpdatingUI = true;
fHeightField.setFormat(aAltitudeFormat, fHeightField.getValue());
} finally {
fUpdatingUI = old;
}
}
/**
* The location field should be formatted using the point format exposed on the Lucy back-end.
* When this format changes, the UI must be updated.
*/
private static class PointFormatListener extends
ALcdWeakPropertyChangeListener<WayPointCustomizerPanel> {
private PointFormatListener(WayPointCustomizerPanel aObjectToModify) {
super(aObjectToModify);
}
@Override
protected void propertyChangeImpl(WayPointCustomizerPanel aWayPointCustomizerPanel, PropertyChangeEvent aPropertyChangeEvent) {
String propertyName = aPropertyChangeEvent.getPropertyName();
if ("defaultLonLatPointFormat".equals(propertyName)) {
aWayPointCustomizerPanel.updatePointFormat((Format) aPropertyChangeEvent.getNewValue());
}
}
}
/**
* The altitude field should be formatted using the altitude format exposed on the Lucy back-end.
* When this format changes, the UI must be updated
*/
private static class AltitudeFormatListener extends ALcdWeakPropertyChangeListener<WayPointCustomizerPanel> {
private AltitudeFormatListener(WayPointCustomizerPanel aObjectToModify) {
super(aObjectToModify);
}
@Override
protected void propertyChangeImpl(WayPointCustomizerPanel aWayPointCustomizerPanel, PropertyChangeEvent aPropertyChangeEvent) {
String propertyName = aPropertyChangeEvent.getPropertyName();
if ("defaultAltitudeFormat".equals(propertyName) || "defaultUserAltitudeUnit".equals(propertyName)) {
aWayPointCustomizerPanel.updateAltitudeFormat(((ILcyLucyEnv) aPropertyChangeEvent.getSource()).getDefaultAltitudeFormat());
}
}
}
}