package nl.utwente.viskell.ui; import java.util.function.BiConsumer; import java.util.function.Consumer; import javafx.event.EventHandler; import javafx.event.EventType; import javafx.geometry.BoundingBox; import javafx.geometry.Bounds; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.input.TouchEvent; /** * Helper class used for event handling of dragging a Node. */ public class DragContext { /** The id of the finger/cursor that is currently dragging the Node */ private int touchId; /** Touch ID representing the mouse cursor. */ private static final int MOUSE_ID = -1; /** An unused touch ID. */ private static final int NULL_ID = Integer.MIN_VALUE; /** The Node that can be dragged. */ final Node node; // Node that is being dragged /** Whether this node will go to the foreground when the user starts a drag gesture with it. */ boolean goToForegroundOnContact; /** The x,y position in the Node where the dragging started. */ private double localOffsetX, localOffsetY; /** reference to internal touch event handler */ private final EventHandler<TouchEvent> touchHandler; /** reference to internal mouse event handler */ private final EventHandler<MouseEvent> mouseHandler; /** the method to be called when the node is contact with touch or mouse, may be null */ private Consumer<DragContext> contactAction; /** the method to be called when the node has been released, may be null */ private Consumer<DragContext> releaseAction; /** the method to be called when a drag action has started, may be null */ private Consumer<DragContext> dragInitAction; /** the method to be called when a drag action has finished, may be null */ private Consumer<DragContext> dragFinishAction; /** the method to be called when a secondary action is performed, may be null. */ private BiConsumer<Point2D, Boolean> secondaryClickAction; private boolean activated; /** bounds to wherein the dragging is constrained */ private Bounds dragLimits; /** the initial drag distance before it is considered a proper drag action */ private double dragThreshold; /** whether a thresholded drag action has started */ private boolean dragStarted; /** minimal movement offset before a node relocation is triggered, to reduce wasteful redraws */ private double relocateThreshold; /** * Creates a DragContext keeping track of touch events, so that a Node is made draggable. * @param draggable the Node that is to be made draggable. */ public DragContext(Node draggable) { this.node = draggable; this.goToForegroundOnContact = true; this.touchId = NULL_ID; this.activated = false; this.dragLimits = new BoundingBox(0, 0, Integer.MAX_VALUE, Integer.MAX_VALUE); this.dragThreshold = 10.0; this.dragStarted = false; this.relocateThreshold = 1.0; this.dragInitAction = null; this.dragFinishAction = null; touchHandler = event -> { EventType<TouchEvent> type = event.getEventType(); if (type == TouchEvent.TOUCH_PRESSED) { if (this.touchId == DragContext.NULL_ID) { this.touchId = event.getTouchPoint().getId(); this.handleTouchPressed(event.getTouchPoint().getX(), event.getTouchPoint().getY()); } event.consume(); } else if (type == TouchEvent.TOUCH_MOVED) { if (this.touchId == event.getTouchPoint().getId()) { this.handleTouchMoved(event.getTouchPoint().getX(), event.getTouchPoint().getY()); } else if (this.touchId == DragContext.NULL_ID) { // this can happen when dragging from menu this.touchId = event.getTouchPoint().getId(); this.handleTouchMoved(event.getTouchPoint().getX(), event.getTouchPoint().getY()); } event.consume(); } else if (type == TouchEvent.TOUCH_RELEASED) { long fingerCount = event.getTouchPoints().stream().filter(tp -> tp.belongsTo(this.node)).count(); if (this.touchId > MOUSE_ID && fingerCount == 1) { if (this.activated) { this.activated = false; if (this.releaseAction != null) { this.releaseAction.accept(this); } } if (this.dragStarted) { this.handleTouchReleased(); } this.touchId = DragContext.NULL_ID; } else if (!this.dragStarted && fingerCount == 2) { if (this.secondaryClickAction != null) { this.secondaryClickAction.accept(new Point2D(event.getTouchPoint().getX(), event.getTouchPoint().getY()), false); } } event.consume(); } }; mouseHandler = event -> { if (event.isSynthesized()) { event.consume(); return; } EventType<? extends MouseEvent> type = event.getEventType(); if (type == MouseEvent.MOUSE_PRESSED) { if (this.touchId == DragContext.NULL_ID) { this.touchId = DragContext.MOUSE_ID; this.handleTouchPressed(event.getX(), event.getY()); } event.consume(); } else if (type == MouseEvent.MOUSE_DRAGGED) { if (this.touchId == DragContext.MOUSE_ID) { this.handleTouchMoved(event.getX(), event.getY()); } event.consume(); } else if (type == MouseEvent.MOUSE_RELEASED) { if (this.touchId == DragContext.MOUSE_ID && this.activated && !event.isPrimaryButtonDown() && !event.isSecondaryButtonDown()) { this.activated = false; if (this.releaseAction != null) this.releaseAction.accept(this); } if (event.getButton() == MouseButton.SECONDARY && !this.dragStarted) { if (this.secondaryClickAction != null) { this.secondaryClickAction.accept(new Point2D(event.getX(), event.getY()), true); } } else if (this.touchId == DragContext.MOUSE_ID) { this.touchId = DragContext.NULL_ID; handleTouchReleased(); } event.consume(); this.touchId = DragContext.NULL_ID; } }; draggable.addEventHandler(TouchEvent.ANY, touchHandler); draggable.addEventHandler(MouseEvent.ANY, mouseHandler); } private void handleTouchPressed(double localX, double localY) { this.localOffsetX = localX; this.localOffsetY = localY; if (this.goToForegroundOnContact) { node.toFront(); } if (!this.activated) { this.activated = true; if (this.contactAction != null) { this.contactAction.accept(this); } } } private void handleTouchMoved(double localX, double localY) { double diffX = localX - this.localOffsetX; double diffY = localY - this.localOffsetY; // check if the movement distance surpassed the threshold if (this.dragStarted || (diffX*diffX + diffY*diffY > this.dragThreshold*this.dragThreshold)) { if (! this.dragStarted) { this.dragStarted = true; // first call the drag initiation action if available if (this.dragInitAction != null) { this.dragInitAction.accept(this); } } // skip actual node relocation if the movement is too small if ((Math.abs(diffX) > this.relocateThreshold) || (Math.abs(diffY) > this.relocateThreshold)) { double moveX = node.getLayoutX() + diffX; double moveY = node.getLayoutY() + diffY; // limit the movement by clamping on the drag boundaries double newX = Math.min(Math.max(moveX, this.dragLimits.getMinX()), this.dragLimits.getMaxX()); double newY = Math.min(Math.max(moveY, this.dragLimits.getMinY()), this.dragLimits.getMaxY()); node.relocate(newX, newY); } } } private void handleTouchReleased() { this.dragStarted = false; if (this.dragFinishAction != null) { this.dragFinishAction.accept(this); } } /** Make the attached Node stop acting on drag actions by removing drag event handlers */ public void removeDragEventHandlers() { node.removeEventHandler(TouchEvent.ANY, touchHandler); node.removeEventHandler(MouseEvent.ANY, mouseHandler); } /** * The Node that is being dragged */ public Node getDraggable() { return this.node; } /** Sets whether the attached node will go to foreground on contact. */ public void setGoToForegroundOnContact(boolean goToForegroundOnContact) { this.goToForegroundOnContact = goToForegroundOnContact; } /** * @param bounds to wherein the dragging is constrained. */ public void setDragLimits(Bounds bounds) { this.dragLimits = bounds; } /** * @param threshold the initial drag distance before it is considered a proper drag action */ public void setDragThreshold(double threshold) { this.dragThreshold = threshold; } /** * @param threshold minimal movement offset before a node relocation is triggered, to reduce wasteful redraws */ public void setRelocateThreshold(double threshold) { this.relocateThreshold = threshold; } /** * @param action the method to be called when the node is contacted with touch or mouse, may be null */ public void setContactAction(Consumer<DragContext> action) { this.contactAction = action; } /** * @param action the method to be called when the node has been released, may be null */ public void setReleaseAction(Consumer<DragContext> action) { this.releaseAction = action; } /** * @param action the method to be called when a drag action has started, may be null */ public void setDragInitAction(Consumer<DragContext> action) { this.dragInitAction = action; } /** * @param action the method to be called when a secondary action is performed, may be null. * Secondary actions are either a right click on the mouse or a tap on the same node by a second finger * The boolean passed to the action is whether the action performed with a mouse */ public void setSecondaryClickAction(BiConsumer<Point2D, Boolean> action) { this.secondaryClickAction = action; } /** * @param action the method to be called when a drag action has finished, may be null */ public void setDragFinishAction(Consumer<DragContext> action) { this.dragFinishAction = action; } public boolean isActivated() { return this.activated; } @Override public String toString() { return String.format("DragContext [draggable = %s, ,touchId = %d, localOffsetX = %f, localOffsetY = %f]", node.toString(), touchId, localOffsetX, localOffsetY); } }