3

I've created a custom class ImageButton to get rid of some boilerplate code

open class ImageButton(@NamedArg("image") image: Image,
                       @NamedArg("tooltipText") tooltipText: String,
                       @NamedArg("width") width: Double, 
                       @NamedArg("height") height: Double) : Button() {
    init {
        prefWidth = width
        minWidth = NEGATIVE_INFINITY
        maxWidth = NEGATIVE_INFINITY

        prefHeight = height
        minHeight = NEGATIVE_INFINITY
        maxHeight = NEGATIVE_INFINITY

        cursor = ImageCursor.HAND
        effect = ImageInput(image)
        tooltip = Tooltip(tooltipText)
    }
}

Now, instead of this:

<Button fx:id="deleteButton" prefWidth="32.0" prefHeight="32.0" 
        minHeight="-Infinity" maxHeight="-Infinity"
        minWidth="-Infinity" maxWidth="-Infinity" 
        onMouseClicked="#deleteThePiece">
    <cursor>
        <ImageCursor fx:constant="HAND"/>
    </cursor>
    <tooltip>
        <Tooltip text="Delete Current Piece"/>
    </tooltip>
    <effect>
        <ImageInput>
            <source>
                <Image url="@/icons/delete.png"/>
            </source>
        </ImageInput>
    </effect>
</Button>

I can write this:

<ImageButton fx:id="deleteButton" width="32.0" height="32.0"
        onMouseClicked="#deleteThePiece" tooltipText="Delete Current Piece">
    <image>
        <Image url="@/icons/dice.png"/>
    </image>
</ImageButton>

However, I would like to be able to shorten it even more, like this:

<ImageButton fx:id="deleteButton" width="32.0" height="32.0"
        onMouseClicked="#deleteThePiece" tooltipText="Delete Current Piece">
    <Image url="@/icons/dice.png"/>
</ImageButton>

I have a lot of these objects so it would be nice to be able to keep the fxml tags as short as possible.

I know that there is an annotation @DefaultProperty that can be used to unwrap the default tag (e.g you can omit the <children> tag inside a <Pane> tag because it has the @DefaultProperty("children") annotation) so I used it:

@DefaultProperty("image")
open class ImageButton(...) {...}

but then when loading fxml file I get the following error:

javafx.fxml.LoadException: Element does not define a default property.

I've done some research and come across this:

"Element does not define a default property" when @DefaultProperty is used

It, however, does not contain a solution. It only explains the problem.

So my question is:

Is it possible to use the @DefaultProperty annotation on custom classes that use @NamedArg annotation?

If yes, how do I achieve this?

If no, should I try constructing my ImageButton objects differently? e.g using <fx:factory>?

pjaro
  • 65
  • 4
  • Interesting. Scanning the JavaFX libraries, the only class that has both `@DefaultProperty` and `@NamedArg` is `javafx.scene.Scene`. And that class is one of the few classes that still has a dedicated `Builder` implementation; that `Builder` class "duplicates" the `@DefaultProperty` present on the `Scene` class which, going by the explanation in the linked question, is why that works. However, I don't believe `@DefaultProperty` was designed to work with constructor parameters, only properties. As you have no image property, this isn't possible. – Slaw Aug 21 '19 at 13:07
  • That said, why not accept a `String` URL in the constructor instead of the `Image` itself? Then you could even declare the URL in an attribute, not just a single-line element. – Slaw Aug 21 '19 at 13:08
  • 1
    You can't have both `@NamedArg` and `@DefaultProperty`, because as soon you use the former, the `ProxyBuilder` is used, and the latter won't work, as explained in the linked question. Having an empty constructor (or one with a `String text` argument), and an `ObjectProperty image` property works, but you will be missing the other arguments. Note that `prefWidth` and `prefHeight` can be set directly from ``, so there is no need for them. Would that work for you? – José Pereda Aug 21 '19 at 13:13
  • @JoséPereda Should the incompatibility between `@DefaultProperty` and `@NamedArg` be considered a bug? I tried finding a report (fixed or not) but was unsuccessful. – Slaw Aug 21 '19 at 13:25
  • I've indeed opted for the "URL in the constructor" solution. I only wanted to have the `Image` as the `@DefaultProperty` because I'm using my `ImageButton` as a base class for some more complex classes and wanted to take advantage of the fxml's location resolution instead of having to `App::class.java.getResource` every time but It's fine – pjaro Aug 21 '19 at 13:55
  • I've managed to get it working using the `Builder` pattern... Though builders were deprecated a long time ago, they are still used internally, and you can implement your own Builder, and add your own BuilderFactory (sadly it can't extend `JavaFXBuilderFactory`). The builder will allow `DefaultProperty`, while `NamedArg` can be used in the control... – José Pereda Aug 21 '19 at 17:08
  • @Slaw I wouldn't say it is a bug, but definitely there is room for improvement. See my previous comment... builders could be still an option... – José Pereda Aug 21 '19 at 17:13
  • Ok, I've posted it, I haven't found documented cases of a custom builder, so I guess someone might find it useful... – José Pereda Aug 21 '19 at 18:26
  • @pjaro You could have another constructor continue to accept an `Image` argument. – Slaw Aug 21 '19 at 23:31

1 Answers1

2

There are a few options here, but the more simple one is stated by @Slaw in a comment:

Use a String URL in the constructor instead of the Image

So this should work:

public ImageButton(@NamedArg("url") String url, @NamedArg("text") String text) {
    setEffect(new ImageInput(new Image(url)));
    setText(text);
}

with the FXML like:

<ImageButton fx:id="imageButton" text="Click Me!" url="@icon.png"/>

Let's explore now the use of <image /> combined with @DefaultProperty.

ImageButton control

First of all, let's define our control. For the sake of simplicity (and also because these can't be overridden), I won't include width and height:

public class ImageButton extends Button {

    public ImageButton(@NamedArg("image") Image image, @NamedArg("text") String text) {
        setImage(image);
        setText(text);
    }

    private final ObjectProperty<Image> image = new SimpleObjectProperty<>(this, "image") {
        @Override
        protected void invalidated() {
            // setGraphic(new ImageView(get()));
            setEffect(new ImageInput(get()));
        }
    };

    public final ObjectProperty<Image> imageProperty() {
       return image;
    }

    public final Image getImage() {
       return image.get();
    }

    public final void setImage(Image value) {
        image.set(value);
    }
}

And:

<ImageButton fx:id="imageButton" text="Click Me!">
     <image>
         <Image url="@icon.png"/>
     </image>
</ImageButton>

will work perfectly fine. However, the purpose is to remove the <image> tag.

DefaultProperty

The theory says you could do:

@DefaultProperty("image")
public class ImageButton extends Button {
...
}

and

<ImageButton fx:id="imageButton" text="Click Me!">
     <Image url="@icon.png"/>
</ImageButton>

However, an exception is thrown:

Caused by: javafx.fxml.LoadException: Element does not define a default property.

For more details, why this exception happens, see the linked question.

Basically, as discussed within comments, @DefaultProperty and @NamedArg don't work together: In order to extend the FXML attributes of a given class, @NamedArg provide new constructors to this class, which require the use of ProxyBuilder, so FXMLLoader will use instances of ProxyBuilder instead, and these don't have included the @DefaultProperty annotation.

Builders

Though builder design pattern was used in JavaFX 2.0, and it was deprecated a long time ago (in Java 8, removed in Java 9, link), there are still some builders in the current JavaFX code.

In fact, FXMLLoader makes use of JavaFXBuilderFactory, as default builder factory, that will call this ProxyBuilder if NamedArg annotations are found in the class constructor, among other builders like JavaFXImageBuilder.

There is some description about builders here.

Builder implementation

How can we add our own builder factory? FXMLLoader has a way: setBuilderFactory.

Can we extend JavaFXBuilderFactory? No, it's final, we can't extend it, we have to create one from the scratch.

ImageButtonBuilderFactory

Let's create it:

import javafx.util.Builder;
import javafx.util.BuilderFactory;

public class ImageButtonBuilderFactory implements BuilderFactory {

    @Override
    public Builder<?> getBuilder(Class<?> type) {
        if (type == null) {
            throw new NullPointerException();
        }
        if (type == ImageButton.class) {
            return ImageButtonBuilder.create();
        }
        return null;
    }
}

Now let's add the builder:

ImageButtonBuilder

import javafx.scene.image.Image;
import javafx.util.Builder;

import java.util.AbstractMap;
import java.util.HashSet;
import java.util.Set;

public class ImageButtonBuilder extends AbstractMap<String, Object> implements Builder<ImageButton> {

    private String text = "";
    private Image image;

    private ImageButtonBuilder() {}

    @Override
    public Set<Entry<String, Object>> entrySet() {
        return new HashSet<>();
    }

    public static ImageButtonBuilder create() {
        return new ImageButtonBuilder();
    }

    @Override
    public Object put(String key, Object value) {
        if (value != null) {
            String str = value.toString();

            if ("image".equals(key)) {
                image = (Image) value;
            } else if ("text".equals(key)) {
                text = str;
            } else {
                throw new IllegalArgumentException("Unknown property: " + key);
            }
        }

        return null;
    }

    @Override
    public ImageButton build() {
        return new ImageButton(image, text);
    }

}

Note that ImageButton is the same class as above (without DefaultProperty annotation).

Using the custom builder

Now we could use our custom builder:

FXMLLoader loader = new FXMLLoader();
loader.setLocation(getClass().getResource("imagebutton.fxml"));
loader.setBuilderFactory(new ImageButtonBuilderFactory());
Parent root = loader.load();
Scene scene = new Scene(root);
...

where the FXML is:

<StackPane prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/11" xmlns:fx="http://javafx.com/fxml/1">
      <ImageButton fx:id="imageButton" text="Click Me!">
            <image>
                  <Image url="@icon.png"/>
            </image>
      </ImageButton>
</StackPane>

If we run this now, it should work. We have verified that our new builder works. If we comment out the setBuilderFactory call, it will work as well (using NamedArg and ProxyBuilder). With the custom builder factory, it won't use ProxyBuilder but our custom builder.

Final step

Finally, we can make use of DefaultProperty to get rid of the <image> tag.

And we'll add the annotation to the builder class, not to the control!

So now we have:


@DefaultProperty("image")
public class ImageButtonBuilder extends AbstractMap<String, Object> implements Builder<ImageButton> {
...
}

and finally we can remove the <image> tag from the FXML file:

<StackPane prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/11" xmlns:fx="http://javafx.com/fxml/1">
      <ImageButton fx:id="imageButton" text="Click Me!">
            <Image url="@icon.png"/>
      </ImageButton>
</StackPane>

and it will work!

José Pereda
  • 44,311
  • 7
  • 104
  • 132
  • It works, but It's an awful lot of boilerplate code just to be able to use a `@DefaultProperty` annotation. Also, this solution doesn't cooperate with `SceneBuilder` - at least the one imbedded in `IntelliJ IDEA` so I will remain using the `@NamedArg("url")` solution. However, thank you for the detailed response and an actual example of how to use the `BuilderFactory` - even though it's deprecated now – pjaro Aug 22 '19 at 08:25
  • Indeed it is not pretty, compared to `NamedArg`, ... but `BuilderFactory` is still a valid solution, as I mentioned, it is still used by FXMLLoader itself, so that part is not deprecated. I didn't check Scene Builder, probably it will complain. – José Pereda Aug 22 '19 at 08:31