JavaFx 8: Moving component between parents while staying in place


Question

I need to move Nodes (mosty clipped ImageViews) between containers. During the "parent change" I have to keep the components visually in place, so from the users' perspective this reorganization should be transparent.

The schema of my scene is like this:

enter image description here

The Nodes I have to move are clipped, translated some effects applied to them and originally reside under the Board pane. The contents group of the desk is scaled, clipped and translated.

I would like to move them to the MoverLayer. It works fine, due to the moverLayer is bound to the Board:

    moverLayer.translateXProperty().bind(board.translateXProperty());
    moverLayer.translateYProperty().bind(board.translateYProperty());
    moverLayer.scaleXProperty().bind(board.scaleXProperty());
    moverLayer.scaleYProperty().bind(board.scaleYProperty());
    moverLayer.layoutXProperty().bind(board.layoutXProperty());
    moverLayer.layoutYProperty().bind(board.layoutYProperty());

so I can simply move the nodes between them:

public void start(MouseEvent me) {
    board.getContainer().getChildren().remove(node);
    desk.getMoverLayer().getChildren().add(node);
}


public void finish(MouseEvent me) {
    desk.getMoverLayer().getChildren().remove(node);
    board.getContainer().getChildren().add(node);
}

However, when moving nodes between the contents of tray and the MoverLayer it starts to get complicated. I tried to play with different coordinates (local, parent, scene, screen), but somehow it is always misplaced. It seems, that when the scale is 1.0 for desk.contents, it works to map the coordinates of translateX and translateY to screen coordinates, switch the parent and then map back the screen coordinates to local and use as translation. But with non-identical scaling, the coordinates differs (and the node moves). I also tried to map the coordinates to the common parent (desk) recursively, but works neither.

My generalized question is, what is the best practice to calculate coordinates of the same point relative to different parents?

Here is the MCVE code. Sorry, I simply couldn't make it more simple.

package hu.vissy.puzzlefx.stackoverflow;

import javafx.application.Application;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;

public class Mcve extends Application {

    // The piece to move
    private Rectangle piece;

    // The components representing the above structure
    private Pane board;
    private Group moverLayer;
    private Pane trayContents;
    private Pane desk;
    private Group deskContents;

    // The zoom scale
    private double scale = 1;

    // Drag info
    private double startDragX;
    private double startDragY;
    private Point2D dragAnchor;

    public Mcve() {
    }

    public void init(Stage primaryStage) {

        // Added only for simulation
        Button b = new Button("Zoom");
        b.setOnAction((ah) -> setScale(0.8));

        desk = new Pane();
        desk.setPrefSize(800, 600);
        desk.setBackground(new Background(new BackgroundFill(Color.LIGHTGREEN, CornerRadii.EMPTY, Insets.EMPTY)));

        deskContents = new Group();
        desk.getChildren().add(deskContents);

        board = new Pane();
        board.setPrefSize(700, 600);
        board.setBackground(new Background(new BackgroundFill(Color.LIGHTCORAL, CornerRadii.EMPTY, Insets.EMPTY)));

        // Symbolize the piece to be dragged
        piece = new Rectangle();
        piece.setTranslateX(500);
        piece.setTranslateY(50);
        piece.setWidth(50);
        piece.setHeight(50);
        piece.setFill(Color.BLACK);
        board.getChildren().add(piece);

        // Mover layer is always on top and is bound to the board (used to display the 
        // dragged items above all desk contents during dragging)
        moverLayer = new Group();
        moverLayer.translateXProperty().bind(board.translateXProperty());
        moverLayer.translateYProperty().bind(board.translateYProperty());
        moverLayer.scaleXProperty().bind(board.scaleXProperty());
        moverLayer.scaleYProperty().bind(board.scaleYProperty());
        moverLayer.layoutXProperty().bind(board.layoutXProperty());
        moverLayer.layoutYProperty().bind(board.layoutYProperty());

        board.setTranslateX(50);
        board.setTranslateY(50);

        Pane tray = new Pane();
        tray.setPrefSize(400, 400);
        tray.relocate(80, 80);

        Pane header = new Pane();
        header.setPrefHeight(30);
        header.setBackground(new Background(new BackgroundFill(Color.LIGHTSLATEGRAY, CornerRadii.EMPTY, Insets.EMPTY)));

        trayContents = new Pane();
        trayContents.setBackground(new Background(new BackgroundFill(Color.BEIGE, CornerRadii.EMPTY, Insets.EMPTY)));
        VBox layout = new VBox();
        layout.getChildren().addAll(header, trayContents);
        VBox.setVgrow(trayContents, Priority.ALWAYS);
        layout.setPrefSize(400, 400);

        tray.getChildren().add(layout);
        deskContents.getChildren().addAll(board, tray, moverLayer, b);

        Scene scene = new Scene(desk);

        // Piece is draggable
        piece.setOnMousePressed((me) -> startDrag(me));
        piece.setOnMouseDragged((me) -> doDrag(me));
        piece.setOnMouseReleased((me) -> endDrag(me));

        primaryStage.setScene(scene);
    }

    // Changing the scale
    private void setScale(double scale) {
        this.scale = scale;
        // Reseting piece position and parent if needed
        if (piece.getParent() != board) {
            piece.setTranslateX(500);
            piece.setTranslateY(50);
            trayContents.getChildren().remove(piece);
            board.getChildren().add(piece);
        }
        deskContents.setScaleX(getScale());
        deskContents.setScaleY(getScale());
    }

    private double getScale() {
        return scale;
    }

    private void startDrag(MouseEvent me) {
        // Saving drag options
        startDragX = piece.getTranslateX();
        startDragY = piece.getTranslateY();
        dragAnchor = new Point2D(me.getSceneX(), me.getSceneY());

        // Putting the item into the mover layer -- works fine with all zoom scale level
        board.getChildren().remove(piece);
        moverLayer.getChildren().add(piece);
        me.consume();
    }

    // Doing the drag
    private void doDrag(MouseEvent me) {
        double newTranslateX = startDragX + (me.getSceneX() - dragAnchor.getX()) / getScale();
        double newTranslateY = startDragY + (me.getSceneY() - dragAnchor.getY()) / getScale();

        piece.setTranslateX(newTranslateX);
        piece.setTranslateY(newTranslateY);
        me.consume();
    }

    private void endDrag(MouseEvent me) {
        // For MCVE's sake I take that the drop is over the tray.

        Bounds op = piece.localToScreen(piece.getBoundsInLocal());

        moverLayer.getChildren().remove(piece);
        // One of my several tries: mapping the coordinates till the common parent.
        // I also tried to use localtoScreen -> change parent -> screenToLocal
        Bounds b = localToParentRecursive(trayContents, desk, trayContents.getBoundsInLocal());
        Bounds b2 = localToParentRecursive(board, desk, board.getBoundsInLocal());
        trayContents.getChildren().add(piece);
        piece.setTranslateX(piece.getTranslateX() + b2.getMinX() - b.getMinX() * getScale());
        piece.setTranslateY(piece.getTranslateY() + b2.getMinY() - b.getMinY() * getScale());
        me.consume();
    }


    public static Point2D localToParentRecursive(Node n, Parent parent, double x, double y) {
        // For simplicity I suppose that the n node is on the path of the parent
        Point2D p = new Point2D(x, y);
        Node cn = n;
        while (true) {
            if (cn == parent) {
                break;
            }
            p = cn.localToParent(p);
            cn = cn.getParent();
        }
        return p;
    }

    public static Bounds localToParentRecursive(Node n, Parent parent, Bounds bounds) {
        Point2D p = localToParentRecursive(n, parent, bounds.getMinX(), bounds.getMinY());
        return new BoundingBox(p.getX(), p.getY(), bounds.getWidth(), bounds.getHeight());
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        init(primaryStage);

        primaryStage.show();
    }

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

}
1
4
6/11/2014 4:07:55 PM

Accepted Answer

Well. After a lot of debugging, calculation and try I was able to find a solution.

Here is the utility function I wrote for doing the parent swap:

/**
 * Change the parent of a node.
 *
 * <p>
 * The node should have a common ancestor with the new parent.
 * </p>
 *
 * @param item
 *            The node to move.
 * @param newParent
 *            The new parent.
 */
@SuppressWarnings("unchecked")
public static void changeParent(Node item, Parent newParent) {
    try {
        // HAve to use reflection, because the getChildren method is protected in common ancestor of all
        // parent nodes.

        // Checking old parent for public getChildren() method
        Parent oldParent = item.getParent();
        if ((oldParent.getClass().getMethod("getChildren").getModifiers() & Modifier.PUBLIC) != Modifier.PUBLIC) {
            throw new IllegalArgumentException("Old parent has no public getChildren method.");
        }
        // Checking new parent for public getChildren() method
        if ((newParent.getClass().getMethod("getChildren").getModifiers() & Modifier.PUBLIC) != Modifier.PUBLIC) {
            throw new IllegalArgumentException("New parent has no public getChildren method.");
        }

        // Finding common ancestor for the two parents
        Parent commonAncestor = findCommonAncestor(oldParent, newParent);
        if (commonAncestor == null) {
            throw new IllegalArgumentException("Item has no common ancestor with the new parent.");
        }

        // Bounds of the item
        Bounds itemBoundsInParent = item.getBoundsInParent();

        // Mapping coordinates to common ancestor
        Bounds boundsInParentBeforeMove = localToParentRecursive(oldParent, commonAncestor, itemBoundsInParent);

        // Swapping parent
        ((Collection<Node>) oldParent.getClass().getMethod("getChildren").invoke(oldParent)).remove(item);
        ((Collection<Node>) newParent.getClass().getMethod("getChildren").invoke(newParent)).add(item);

        // Mapping coordinates back from common ancestor
        Bounds boundsInParentAfterMove = parentToLocalRecursive(newParent, commonAncestor, boundsInParentBeforeMove);

        // Setting new translation
        item.setTranslateX(
                        item.getTranslateX() + (boundsInParentAfterMove.getMinX() - itemBoundsInParent.getMinX()));
        item.setTranslateY(
                        item.getTranslateY() + (boundsInParentAfterMove.getMinY() - itemBoundsInParent.getMinY()));
    } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
        throw new IllegalStateException("Error while switching parent.", e);
    }
}

/**
 * Finds the topmost common ancestor of two nodes.
 *
 * @param firstNode
 *            The first node to check.
 * @param secondNode
 *            The second node to check.
 * @return The common ancestor or null if the two node is on different
 *         parental tree.
 */
public static Parent findCommonAncestor(Node firstNode, Node secondNode) {
    // Builds up the set of all ancestor of the first node.
    Set<Node> parentalChain = new HashSet<>();
    Node cn = firstNode;
    while (cn != null) {
        parentalChain.add(cn);
        cn = cn.getParent();
    }

    // Iterates down through the second ancestor for common node.
    cn = secondNode;
    while (cn != null) {
        if (parentalChain.contains(cn)) {
            return (Parent) cn;
        }
        cn = cn.getParent();
    }
    return null;
}

/**
 * Transitively converts the coordinates from the node to an ancestor's
 * coordinate system.
 *
 * @param node
 *            The node the starting coordinates are local to.
 * @param ancestor
 *            The ancestor to map the coordinates to.
 * @param x
 *            The X of the point to be converted.
 * @param y
 *            The Y of the point to be converted.
 * @return The converted coordinates.
 */
public static Point2D localToParentRecursive(Node node, Parent ancestor, double x, double y) {
    Point2D p = new Point2D(x, y);
    Node cn = node;
    while (cn != null) {
        if (cn == ancestor) {
            return p;
        }
        p = cn.localToParent(p);
        cn = cn.getParent();
    }
    throw new IllegalStateException("The node is not a descedent of the parent.");
}

/**
 * Transitively converts the coordinates of a bound from the node to an
 * ancestor's coordinate system.
 *
 * @param node
 *            The node the starting coordinates are local to.
 * @param ancestor
 *            The ancestor to map the coordinates to.
 * @param bounds
 *            The bounds to be converted.
 * @return The converted bounds.
 */
public static Bounds localToParentRecursive(Node node, Parent ancestor, Bounds bounds) {
    Point2D p = localToParentRecursive(node, ancestor, bounds.getMinX(), bounds.getMinY());
    return new BoundingBox(p.getX(), p.getY(), bounds.getWidth(), bounds.getHeight());
}

/**
 * Transitively converts the coordinates from an ancestor's coordinate
 * system to the nodes local.
 *
 * @param node
 *            The node the resulting coordinates should be local to.
 * @param ancestor
 *            The ancestor the starting coordinates are local to.
 * @param x
 *            The X of the point to be converted.
 * @param y
 *            The Y of the point to be converted.
 * @return The converted coordinates.
 */
public static Point2D parentToLocalRecursive(Node n, Parent parent, double x, double y) {
    List<Node> parentalChain = new ArrayList<>();
    Node cn = n;
    while (cn != null) {
        if (cn == parent) {
            break;
        }
        parentalChain.add(cn);
        cn = cn.getParent();
    }
    if (cn == null) {
        throw new IllegalStateException("The node is not a descedent of the parent.");
    }

    Point2D p = new Point2D(x, y);
    for (int i = parentalChain.size() - 1; i >= 0; i--) {
        p = parentalChain.get(i).parentToLocal(p);
    }

    return p;
}

/**
 * Transitively converts the coordinates of the bounds from an ancestor's
 * coordinate system to the nodes local.
 *
 * @param node
 *            The node the resulting coordinates should be local to.
 * @param ancestor
 *            The ancestor the starting coordinates are local to.
 * @param bounds
 *            The bounds to be converted.
 * @return The converted coordinates.
 */
public static Bounds parentToLocalRecursive(Node n, Parent parent, Bounds bounds) {
    Point2D p = parentToLocalRecursive(n, parent, bounds.getMinX(), bounds.getMinY());
    return new BoundingBox(p.getX(), p.getY(), bounds.getWidth(), bounds.getHeight());
}

The above solution works well, but I wonder if it is the simpliest way to do the task. For the sake of generality I had to use some reflection: the getChildren() method of the Parent class is protected, only its descedents make it public if they wish, so I can't call it directly through Parent.

The use of the above utility is simple: call changeParent( node, newParent ).

This utilities gives also function to find the common ancestor of two nodes and to recursively convert coordinates through the nodes ancestal chain to and from.

2
6/12/2014 8:49:57 PM

Licensed under: CC-BY-SA with attribution
Not affiliated with: Stack Overflow
Icon