0

CONTEXT :

I have redundant code I want to factorize. The code allows a Node descendant to fire a DOUBLE click EventHandler, without firing a SINGLE click EventHandler.

I need this feature in different ButtonBase implementations :

  • Button
  • ToggleButton
  • CheckBox

For the moment, I have extended each leaf class written above with the same code allowing this feature.


PROBLEM :

I know this is smelling very bad, and I would like to factorize it, at least, to the common parent -> ButtonBase... if not Node where it should origin from (it is in Node that the onMouseClicked(...) is implemented).

[enter image description here

I have thus implemented an abstract class ButtonBaseCustom extending ButtonBase.

Knowing, and understanding, that multiple inheritage does not exist in Java, partially due to the diamond problem, I tried to follow this answer from @SCB, using :

  1. Both as interfaces (I created ButtonBaseCustomInterface & ButtonInterface for this),

  2. ButtonBaseCustomInterface as interface, and Button as parent (extending from it)

  3. ButtonInterface as interface and ButtonBaseCustom as parent (extending from it)


QUESTION :

How do I get a ButtonCustom having the parent abilities implemented in ButtonBaseCustom, and still acting as a Button ?


CLOSEST SOLUTION :

I have a very high instinct that the architecture N°3 here above is the one to go with.

The next code compiles and runs... even gives me the correct behavior of SINGLE/DOUBLE click caught... But there is an issue with the SKIN part : and that's the top of the iceberg I suppose. Maybe it smells like I'm in the wrong direction ?

import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.control.skin.ToggleButtonSkin;

public class ToggleButtonWithDblClick extends ButtonBaseCutom implements ToggleButtonInterface {
    ToggleButton tglBtn = null;

    public ToggleButtonWithDblClick() {
        super();
        tglBtn = new ToggleButton();
    }

    public ToggleButtonWithDblClick(String text) {
        super(text);
        tglBtn = new ToggleButton(text);
    }

    public ToggleButtonWithDblClick(String text, Node graphic) {
        super(text, graphic);
        tglBtn = new ToggleButton(text, graphic);
    }

    @Override
    public ToggleGroup getToggleGroup() {
        return tglBtn.getToggleGroup();
    }

    @Override
    public boolean isSelected() {
        return tglBtn.isSelected();
    }

    @Override
    public BooleanProperty selectedProperty() {
        return tglBtn.selectedProperty();
    }

    @Override
    public void setSelected(boolean value) {
        tglBtn.setSelected(value);
    }

    @Override
    public void setToggleGroup(ToggleGroup value) {
        tglBtn.setToggleGroup(value);
    }

    @Override
    public ObjectProperty<ToggleGroup> toggleGroupProperty() {
        return tglBtn.toggleGroupProperty();
    }

    @Override
    public void fire() {
        tglBtn.fire();
    }

    @Override
    public Skin<?> createDefaultSkin() {
        tglBtn.setSkin(new ToggleButtonSkin(tglBtn));
        return tglBtn.getSkin();
    }

}

EXAMPLE

For example, here is a full code of working button :

package application;

import java.util.concurrent.CompletableFuture;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class DblClickCatchedWithoutSingleClick extends Application {

    public class ButtonWithDblClick extends Button {

        private long        singleClickDelayMillis  = 250;
        private ClickRunner latestClickRunner       = null;

        private ObjectProperty<EventHandler<MouseEvent>>    onMouseSingleClickedProperty    = new SimpleObjectProperty<>();
        private ObjectProperty<EventHandler<MouseEvent>>    onMouseDoubleClickedProperty    = new SimpleObjectProperty<>();

        // CONSTRUCTORS
        public ButtonWithDblClick() {
            super();
            addClickedEventHandler();
        }

        public ButtonWithDblClick(String text) {
            super(text);
            addClickedEventHandler();
        }

        public ButtonWithDblClick(String text, Node graphic) {
            super(text, graphic);
            addClickedEventHandler();
        }

        private class ClickRunner implements Runnable {

            private final Runnable  onClick;
            private boolean         aborted = false;

            public ClickRunner(Runnable onClick) {
                this.onClick = onClick;
            }

            public void abort() {
                this.aborted = true;
            }

            @Override
            public void run() {
                try {
                    Thread.sleep(getSingleClickDelayMillis());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if (!aborted) {
                    Platform.runLater(onClick::run);
                }
            }
        }

        private void addClickedEventHandler() {
            //Handling the mouse clicked event (not using 'onMouseClicked' so it can still be used by developer).
            addEventHandler(MouseEvent.MOUSE_CLICKED, me -> {
                switch (me.getButton()) {
                    case PRIMARY:
                        if (me.getClickCount() == 1) {
                            latestClickRunner = new ClickRunner(() -> {
                                System.out.println("ButtonWithDblClick : SINGLE Click fired");
                                onMouseSingleClickedProperty.get().handle(me);
                            });
                            CompletableFuture.runAsync(latestClickRunner);
                        }
                        if (me.getClickCount() == 2) {
                            if (latestClickRunner != null) {
                                latestClickRunner.abort();
                            }
                            System.out.println("ButtonWithDblClick : DOUBLE Click fired");
                            onMouseDoubleClickedProperty.get().handle(me);
                        }
                        break;
                    case SECONDARY:
                        // Right-click operation. Not implemented since usually no double RIGHT click needs to be caught.
                        break;
                    default:
                        break;
                }
            });
        }

        public void setOnMouseSingleClicked(EventHandler<MouseEvent> eventHandler) {
            this.onMouseSingleClickedProperty.set(eventHandler);
        }

        public void setOnMouseDoubleClicked(EventHandler<MouseEvent> eventHandler) {
            this.onMouseDoubleClickedProperty.set(eventHandler);
        }

        public long getSingleClickDelayMillis() {
            return singleClickDelayMillis;
        }

        public void setSingleClickDelayMillis(long singleClickDelayMillis) {
            this.singleClickDelayMillis = singleClickDelayMillis;
        }
    }

    public void start(Stage stage) {
        VBox root = new VBox();

        Button btn = new Button("Double click me");
        btn.setOnMousePressed(mouseEvent -> {
            // CLICK catches
            if (mouseEvent.getClickCount() == 1) {
                System.out.println("Button clicked");
            } else if (mouseEvent.getClickCount() == 2)
                System.out.println("Button double clicked");
        });

        ButtonWithDblClick btn2 = new ButtonWithDblClick("Double click me too ;-)");
        btn2.setOnMouseSingleClicked(me -> {
            System.out.println("BUTTON_2 : Fire SINGLE Click");
        });
        btn2.setOnMouseDoubleClicked(me -> {
            System.out.println("BUTTON_2 : Fire DOUBLE Click");
        });
    
        root.getChildren().add(btn);
        root.getChildren().add(btn2);

        Scene scene = new Scene(root);
        stage.setScene(scene);
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }

}

CUSTOM BUTTON_BASE :

import java.util.concurrent.CompletableFuture;

import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.control.ButtonBase;
import javafx.scene.input.MouseEvent;

public abstract class ButtonBaseCutom extends ButtonBase {

    private long        singleClickDelayMillis  = 250;
    private ClickRunner latestClickRunner       = null;

    private ObjectProperty<EventHandler<MouseEvent>>    onMouseSingleClickedProperty    = new SimpleObjectProperty<>();
    private ObjectProperty<EventHandler<MouseEvent>>    onMouseDoubleClickedProperty    = new SimpleObjectProperty<>();

    // CONSTRUCTORS
    protected ButtonBaseCutom() {
        super();
        addClickedEventHandler();
    }

    protected ButtonBaseCutom(String text) {
        super(text);
        addClickedEventHandler();
    }

    protected ButtonBaseCutom(String text, Node graphic) {
        super(text, graphic);
        addClickedEventHandler();
    }

    private class ClickRunner implements Runnable {

        private final Runnable  onClick;
        private boolean         aborted = false;

        public ClickRunner(Runnable onClick) {
            this.onClick = onClick;
        }

        public void abort() {
            this.aborted = true;
        }

        @Override
        public void run() {
            try {
                Thread.sleep(getSingleClickDelayMillis());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (!aborted) {
                Platform.runLater(onClick::run);
            }
        }
    }

    private void addClickedEventHandler() {
        //Handling the mouse clicked event (not using 'onMouseClicked' so it can still be used by developer).
        addEventHandler(MouseEvent.MOUSE_CLICKED , me -> {
            switch (me.getButton()) {
                case PRIMARY:
                    if (me.getClickCount() == 1) {
                        latestClickRunner = new ClickRunner(() -> {
                            System.out.println("ButtonWithDblClick : SINGLE Click fired");
                            onMouseSingleClickedProperty.get().handle(me);
                        });
                        CompletableFuture.runAsync(latestClickRunner);
                    }
                    if (me.getClickCount() == 2) {
                        if (latestClickRunner != null) {
                            latestClickRunner.abort();
                        }
                        System.out.println("ButtonWithDblClick : DOUBLE Click fired");
                        onMouseDoubleClickedProperty.get().handle(me);
                    }
                    break;
                case SECONDARY:
                    // Right-click operation. Not implemented since usually no double RIGHT click needs to be caught.
                    break;
                default:
                    break;
            }
        });
    }

    public void setOnMouseSingleClicked(EventHandler<MouseEvent> eventHandler) {
        this.onMouseSingleClickedProperty.set(eventHandler);
    }

    public void setOnMouseDoubleClicked(EventHandler<MouseEvent> eventHandler) {
        this.onMouseDoubleClickedProperty.set(eventHandler);
    }

    public long getSingleClickDelayMillis() {
        return singleClickDelayMillis;
    }

    public void setSingleClickDelayMillis(long singleClickDelayMillis) {
        this.singleClickDelayMillis = singleClickDelayMillis;
    }

}

INTERFACES :

Here the interfaces. The ButtonBaseCutomInterface :

public interface ButtonBaseCutomInterface {
    public void setOnMouseSingleClicked(EventHandler<MouseEvent> eventHandler) ;

    public void setOnMouseDoubleClicked(EventHandler<MouseEvent> eventHandler) ;

    public long getSingleClickDelayMillis() ;

    public void setSingleClickDelayMillis(long singleClickDelayMillis) ;
}

and for example the ToggleButton interface :

import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.scene.AccessibleAttribute;
import javafx.scene.control.ToggleGroup;

public interface ToggleButtonInterface {

    //Skin<?>   createDefaultSkin();

    public void fire();

    public ToggleGroup  getToggleGroup();

    public boolean  isSelected();

    public Object   queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters);

    public BooleanProperty  selectedProperty();

    public void setSelected(boolean value);

    public void setToggleGroup(ToggleGroup value);

    public ObjectProperty<ToggleGroup>  toggleGroupProperty();

}

PS : Yes some methods are still missing in my Custom classes, but the question is probably not about these :

  • getOnMouseSingleClicked(...)
  • ...
Daric
  • 83
  • 9
  • Why do all this? My first thought is to use an event filter on the scene (perhaps only if a particular style class is set) or appropriate individual buttons to filter the mouse events, and consume the events for a single click. Also and an event handler on the same scene or individual buttons for the double click, that just calls fire() to action the button. Or, if you really want to change behavior, then use a custom skin rather on the existing button classes rather than trying to create your own button hierarchy. – jewelsea Dec 27 '21 at 20:19
  • Please explain why you want this behavior. (you can edit the question if it requires a lot of explanation). – jewelsea Dec 27 '21 at 20:32
  • If you want to create a skins linking to custom behavior, you can study the default [ButtonBehavior](https://github.com/openjdk/jfx/blob/fdc88341f1df8fb9c99356ada54b25124b77ea6e/modules/javafx.controls/src/main/java/com/sun/javafx/scene/control/behavior/ButtonBehavior.java) and its usage to understand how the in-built system does this. – jewelsea Dec 27 '21 at 20:47
  • Hello @jewelsea. My aim is just to 'factorize' this behavior. To my opinion, this should be 'included' in an abstract Class (such is ButtonBase), as parent of any instanciable children (such are Button, ToggleButton, ...). I am not aware of how to use 'CUSTOM SKIN', but this seems a good option... I'll dig into it... Thank you ! – Daric Dec 28 '21 at 09:46
  • This is the [UI controls architecture](https://wiki.openjdk.java.net/display/OpenJFX/UI+Controls+Architecture), it isn't entirely up-to-date. The best examples to study on how it works are in the JavaFX code itself. Button is a good place to start, that class defines public API, no need to change it unless you are changing that. Styling is in modena.css, no need to change it unless you want to change style. Visual layout is in ButtonSkin and user interaction (mouse/keyboard) is in ButtonBehavior, so that is what you want to change. – jewelsea Dec 28 '21 at 10:32
  • Leave the Button hierarchy alone, define a new button skin based on the existing ButtonSkin which invokes your new behaviour based on ButtonBehavior. You can attach it to any button you like using CSS -fx-skin. Unfortunately, there is little documentation on the process. So mostly just study the code, copy it judiciously and experiment. This is the thorough, inside-out approach. Alternately you just attach filters and handlers to existing controls using public API, but I wouldn't suggest trying to define a new hierarchy for the public API. – jewelsea Dec 28 '21 at 10:35
  • @jewelsea : Thanks a lot for these very useful hints... I will dig into this as soon as I got again time to go inside this personal project... Will then propose a solution based on that direction which is probably the way to go ! Thank you again ! – Daric Dec 28 '21 at 12:12

0 Answers0