Zyxl
Zyxl

Reputation: 124

JavaFX make Node partially mouseTransparent

If I have two Nodes stacked on top of each other and overlapping, how can I make the Node on top mouseTransparent (so that the bottom Node can react to MouseEvents) but also have the Node on top react to some MouseEvents like onMouseEntered?

For example, consider two (let's say rectangular) Panes inside a StackPane, with the bottom one smaller and completely underneath the top one:

<StackPane>
<Pane onMouseEntered="#printA" onMouseClicked="#printB" />
<Pane onMouseEntered="#printC" />
</StackPane>

If the user moves his mouse over the top Pane then C should be printed in the console. If he also moves his mouse over the bottom Pane then A should be printed too. If he clicks with mouse over the bottom Pane then B should be printed. Clicking over the top Pane but not over the bottom Pane should do nothing.

Why do I want to do something like this? I want to detect when the mouse moves near the center of a Pane so that I can change the Pane's center content (basically from display mode to edit mode) and let the user interact with the new content. I want the detection area to be larger than the center itself and thus it will overlap with some other things inside the Pane. So the Pane center can't be the detector, it has to be something transparent stacked on top. The detector also has to remain there so it can detect when the mouse moves away again.

There are lots of questions on Stackoverflow that appear similar, but almost all of them are solved by setMouseTransparent(true) or setPickOnBounds(true). setMouseTransparent doesn't work here since then the top Pane won't print C. setPickOnBounds makes the Pane mouseTransparent everywhere the Pane is alpha/visually transparent, but then the transparent parts won't print C and the opaque parts prevent the lower Pane from printing A or B. So even if the top Pane is completely transparent or completely opaque it doesn't solve my issue. Setting the visibility to false for the top Pane also won't work since then the top Pane cannot print C.

Upvotes: 0

Views: 397

Answers (2)

jewelsea
jewelsea

Reputation: 159341

Here is a potential strategy which might be used.

  1. Create a node which is the shape of the area you want to detect interaction with.
    • In the example below it is a circle and in this description I term it the "detection node".
  2. Make the detection node mouse transparent so that it doesn't consume or interact with any of the standard mouse events.
  3. Add an event listener to the scene (using an event handler or event filter as appropriate).
  4. When the scene event listener recognizes an event which should also be routed to the detection node, copy the event and fire it specifically at the detection node.
  5. The detection node can then respond to the duplicated event.
  6. The original event is also processed unaware of the detection node, so it can interact with other nodes in the scene as though the detection node was never there.

The effect will be that two events occur and can be separately handled by the target node and by the detection node.

lemon chiffon

import javafx.application.Application;
import javafx.event.Event;
import javafx.event.EventType;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ListView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;

import java.util.concurrent.atomic.AtomicBoolean;

public class LayersFX extends Application {
    private final ListView<String> logViewer = new ListView<>();

    // set to true if you with to see move events in the log.
    private final boolean LOG_MOVES = false;

    @Override
    public void start(Stage stage) {
        Rectangle square = new Rectangle(200, 200, Color.LIGHTSKYBLUE);
        Circle circle = new Circle(80, Color.LEMONCHIFFON);
        StackPane stack = new StackPane(square, circle);

        addEventHandlers(square);
        addEventHandlers(circle);

        VBox layout = new VBox(10, stack, logViewer);
        layout.setPadding(new Insets(10));
        logViewer.setPrefSize(200, 200);

        Scene scene = new Scene(layout);

        routeMouseEventsToNode(scene, circle);

        stage.setScene(scene);
        stage.show();
    }

    /**
     * Intercepts mouse events from the scene and created duplicate events which
     * are routed to the node when appropriate.
     */
    private void routeMouseEventsToNode(Scene scene, Node node) {
        // make the node transparent to standard mouse events.
        // it will only receive mouse events we specifically create and send to it.
        node.setMouseTransparent(true);

        // consume all events for the target node so that we don't 
        // accidentally let a duplicated event bubble back up to the scene
        // and inadvertently cause an infinite loop.
        node.addEventHandler(EventType.ROOT, Event::consume);

        // Atomic isn't used here for concurrency, it is just
        // a trick to make the boolean value effectively final
        // so that it can be used in the lambda.
        AtomicBoolean inNode = new AtomicBoolean(false);
        scene.setOnMouseMoved(
                event -> {
                    boolean wasInNode = inNode.get();
                    boolean nowInNode = node.contains(
                            node.sceneToLocal(
                                    event.getSceneX(),
                                    event.getSceneY()
                            )
                    );
                    inNode.set(nowInNode);

                    if (nowInNode) {
                        node.fireEvent(
                                event.copyFor(
                                        node,
                                        node,
                                        MouseEvent.MOUSE_MOVED
                                )
                        );
                    }

                    if (!wasInNode && nowInNode) {
                        node.fireEvent(
                                event.copyFor(
                                        node,
                                        node,
                                        MouseEvent.MOUSE_ENTERED_TARGET
                                )
                        );
                    }

                    if (wasInNode && !nowInNode) {
                        node.fireEvent(
                                event.copyFor(
                                        node,
                                        node,
                                        MouseEvent.MOUSE_EXITED_TARGET
                                )
                        );
                    }
                }
        );
    }

    private void addEventHandlers(Node node) {
        String nodeName = node.getClass().getSimpleName();

        node.setOnMouseEntered(
                event -> log("Entered " + nodeName)
        );

        node.setOnMouseExited(
                event -> log("Exited " + nodeName)
        );

        node.setOnMouseClicked(
                event -> log("Clicked " + nodeName)
        );

        node.setOnMouseMoved(event -> {
            if (LOG_MOVES) {
                log(
                        "Moved in " + nodeName +
                                " (" + Math.floor(event.getX()) + "," + Math.floor(event.getY()) + ")"
                );
            }
        });
    }

    private void log(String msg) {
        logViewer.getItems().add(msg);
        logViewer.scrollTo(logViewer.getItems().size() - 1);
    }

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

}

There is probably some better way of doing this using a custom event dispatch chain, but the logic above is what I came up with. The logic appears to do what you asked in your question, though it may not have the full functionality that you need for your actual application.

Upvotes: 1

Sai Dandem
Sai Dandem

Reputation: 9914

One way I can think, is to observe the mouse movements on the parent node, to see if the mouse pointer falls on the detection zone of the desired node. This way you don't need a dummy (transparent)node for detection.

So the idea is as follows:

<StackPane id="parent">
     <Pane onMouseEntered="#printA" onMouseClicked="#printB" />
      // Other nodes in the parent
</StackPane>
  • As usual, you will have handlers on the center node.
  • Add a mouseMoved handler on the parent node to detect for mouse entering in detection zone.

Please check the below working demo :

enter image description here

import javafx.application.Application;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;

public class MouseEventsDemo extends Application {
    double detectionSize = 30;

    @Override
    public void start(Stage primaryStage) throws Exception {
        Pane center = getBlock("red");
        center.setOnMouseEntered(e -> System.out.println("Entered on center pane..."));
        center.setOnMouseClicked(e -> System.out.println("Clicked on center pane..."));

        // Simulating that the 'center' is surrounded by other nodes
        GridPane grid = new GridPane();
        grid.setAlignment(Pos.CENTER);
        grid.addRow(0, getBlock("yellow"), getBlock("pink"), getBlock("orange"));
        grid.addRow(1, getBlock("orange"), center, getBlock("yellow"));
        grid.addRow(2, getBlock("yellow"), getBlock("pink"), getBlock("orange"));


        // Adding rectangle only for zone visual purpose
        Rectangle zone = new Rectangle(center.getPrefWidth() + 2 * detectionSize, center.getPrefHeight() + 2 * detectionSize);
        zone.setStyle("-fx-stroke:blue;-fx-stroke-width:1px;-fx-fill:transparent;-fx-opacity:.4");
        zone.setMouseTransparent(true);

        StackPane parent = new StackPane(grid, zone);

        VBox root = new VBox(parent);
        root.setAlignment(Pos.CENTER);
        root.setPadding(new Insets(20));
        root.setSpacing(20);
        Scene scene = new Scene(root, 500, 500);
        primaryStage.setScene(scene);
        primaryStage.show();

        addDetectionHandler(parent, center);
    }

    private Pane getBlock(String color) {
        Pane block = new Pane();
        block.setStyle("-fx-background-color:" + color);
        block.setMaxSize(100, 100);
        block.setPrefSize(100, 100);
        return block;
    }

    private void addDetectionHandler(StackPane parent, Pane node) {
        final String key = "onDetectionZone";
        parent.setOnMouseMoved(e -> {
            boolean mouseEntered = (boolean) node.getProperties().computeIfAbsent(key, p -> false);
            if (!mouseEntered && isOnDetectionZone(e, node)) {
                node.getProperties().put(key, true);
                // Perform your mouse enter operations on detection zone,.. like changing to edit mode.. or what ever
                System.out.println("Entered on center pane detection zone...");
                node.setStyle("-fx-background-color:green");

            } else if (mouseEntered && !isOnDetectionZone(e, node)) {
                node.getProperties().put(key, false);
                // Perform your mouse exit operations from detection zone,.. like change back to default state from edit mode
                System.out.println("Exiting from center pane detection zone...");
                node.setStyle("-fx-background-color:red");
            }
        });
    }

    private boolean isOnDetectionZone(MouseEvent e, Pane node) {
        Bounds b = node.localToScene(node.getBoundsInLocal());
        double d = detectionSize;
        Bounds detectionBounds = new BoundingBox(b.getMinX() - d, b.getMinY() - d, b.getWidth() + 2 * d, b.getHeight() + 2 * d);
        return detectionBounds.contains(e.getSceneX(), e.getSceneY());
    }
}

Note: You can go with a more better approach for checking if the mouse pointer falls in detection zone of the desired node :)

UPDATE : Approach#2

Looks like what you are seeking is a node which resembles a picture frame that has a hole through it :). If that is the case, the approach i can think of is to build a shape (like a frame) and place it over the desired node.

And then you can simply add mouse handlers to the detection zone and the center nodes.

Please check the below working demo:

enter image description here

import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Polyline;
import javafx.stage.Stage;

public class MouseEventsDemo2 extends Application {
    double detectionSize = 30;

    @Override
    public void start(Stage primaryStage) throws Exception {
        Pane center = getBlock("red");
        center.getChildren().add(new Label("Hello"));
        center.setOnMouseEntered(e -> {
            System.out.println("Entered on center pane...");
            center.setStyle("-fx-background-color:green");
        });
        center.setOnMouseClicked(e -> System.out.println("Clicked on center pane..."));

        // Simulating that the 'center' is surrounded by other nodes
        GridPane grid = new GridPane();
        grid.setAlignment(Pos.CENTER);
        grid.addRow(0, getBlock("yellow"), getBlock("pink"), getBlock("orange"));
        grid.addRow(1, getBlock("orange"), center, getBlock("yellow"));
        grid.addRow(2, getBlock("yellow"), getBlock("pink"), getBlock("orange"));

        StackPane parent = new StackPane(grid);

        VBox root = new VBox(parent);
        root.setAlignment(Pos.CENTER);
        root.setPadding(new Insets(20));
        root.setSpacing(20);
        Scene scene = new Scene(root, 500, 500);
        primaryStage.setScene(scene);
        primaryStage.show();

        // Building the frame using Polyline node
        Polyline zone = new Polyline();
        zone.setStyle("-fx-fill:grey;-fx-opacity:.5;-fx-stroke-width:0px;");
        zone.setOnMouseEntered(e -> {
            System.out.println("Entered on detection zone...");
            center.setStyle("-fx-background-color:green");
        });
        zone.setOnMouseExited(e -> {
            System.out.println("Exited on detection zone...");
            center.setStyle("-fx-background-color:red");
        });
        zone.setOnMouseClicked(e -> System.out.println("Clicked on detection zone..."));
        parent.getChildren().add(zone);
        parent.layoutBoundsProperty().addListener(p -> updatePolylineZone(center, zone));
        center.layoutBoundsProperty().addListener(p -> updatePolylineZone(center, zone));
        updatePolylineZone(center, zone);
    }

    /**
     * Update the poly line shape to build a frame around the center node if the parent or center bounds changed.
     */
    private void updatePolylineZone(Pane center, Polyline zone) {
        zone.getPoints().clear();

        Bounds b = center.localToParent(center.getBoundsInLocal());
        double s = detectionSize;
        ObservableList<Double> pts = FXCollections.observableArrayList();

//             A-------------------------B
//             |                         |
//             |    a---------------b    |
//             |    |               |    |
//             |    |               |    |
//             |    |               |    |
//             |    d---------------c    |
//             |                         |
//             D-------------------------C

        // Outer square
        pts.addAll(b.getMinX() - s, b.getMinY() - s);  // A
        pts.addAll(b.getMaxX() + s, b.getMinY() - s);  // B
        pts.addAll(b.getMaxX() + s, b.getMaxY() + s);  // C
        pts.addAll(b.getMinX() - s, b.getMaxY() + s);  // D

        // Inner Square
        pts.addAll(b.getMinX() + 1, b.getMaxY() - 1);  // d
        pts.addAll(b.getMaxX() - 1, b.getMaxY() - 1);  // c
        pts.addAll(b.getMaxX() - 1, b.getMinY() + 1);  // b
        pts.addAll(b.getMinX() + 1, b.getMinY() + 1);  // a

        // Closing the loop
        pts.addAll(b.getMinX() - s, b.getMinY() - s);  // A
        pts.addAll(b.getMinX() - s, b.getMaxY() + s);  // D
        pts.addAll(b.getMinX() + 1, b.getMaxY() - 1);  // d
        pts.addAll(b.getMinX() + 1, b.getMinY() + 1);  // a
        pts.addAll(b.getMinX() - s, b.getMinY() - s);  // A

        zone.getPoints().addAll(pts);
    }

    private Pane getBlock(String color) {
        Pane block = new Pane();
        block.setStyle("-fx-background-color:" + color);
        block.setMaxSize(100, 100);
        block.setPrefSize(100, 100);
        return block;
    }
}

Upvotes: 2

Related Questions