Search code examples
javafx-8property-binding

JavaFX bind PathTransition's element coordinates


I'd like to have a path transition in such a way, that the path behaves relative to the window size, even when the size is changed during the animation. Furthermore, when the animation is completed, the animated element should use the stay at the relative destination location.

tileWidthProperty and tileHeightProperty are class members that I added for convinience and they simply divide the main pane's size in eights.

This is the code I have:

public void applyMove(final Ellipse toBeMoved, final int startCol, final int startRow, final int endCol, final int endRow)
{

    Platform.runLater(() ->
    {
        final Path path = new Path();

        final MoveTo startingPoint = new MoveTo();
        final LineTo endPoint = new LineTo();

        startingPoint.xProperty().bind(tileWidthProperty.multiply(startCol).add(tileWidthProperty.divide(2)));
        startingPoint.yProperty().bind(tileHeightProperty.multiply(startRow).add(tileHeightProperty.divide(2)));

        endPoint.xProperty().bind(tileWidthProperty.multiply(endCol).add(tileWidthProperty.divide(2)));
        endPoint.yProperty().bind(tileHeightProperty.multiply(endRow).add(tileHeightProperty.divide(2)));

        path.getElements().add(startingPoint);
        path.getElements().add(endPoint);

        toBeMoved.centerXProperty().unbind();
        toBeMoved.centerYProperty().unbind();

        PathTransition transition = new PathTransition(Duration.millis(10000), path, toBeMoved);
        transition.setOrientation(PathTransition.OrientationType.NONE);
        transition.setCycleCount(1);
        transition.setAutoReverse(false);

        //bind the node at the destination.
        transition.setOnFinished(event ->
        {
            toBeMoved.centerXProperty().bind(tileWidthProperty.multiply(endCol).add(tileWidthProperty.divide(2)));
            toBeMoved.centerYProperty().bind(tileHeightProperty.multiply(endRow).add(tileHeightProperty.divide(2)));
            toBeMoved.setTranslateX(0.0);
            toBeMoved.setTranslateY(0.0);
        });

        transition.play();
    });
}

The col and row parameters are integers that are known to be 0 <= x < 8. They are the column and row of the tile position in the 8*8 grid.

The binding of the MoveTo and LineTo elements, however does not seem to have any effect.


Solution

  • First of all, once any standard Animation is started it does not change parameters. So if anything changes, you have to stop the animation and start it again with new parameters.

    I noticed you're trying to bind centerX and centerY after the transition is complete, which is wrong: PathTransition moves elements using translateX and translateY.

    And for better debug you can actually add Path to the scene graph to see where your element will go.

    Path path = new Path();
    parent.getChildren().add(path);
    

    I assume that tileWidthProperty and tileHeightProperty are bound to the actual parent size using something like this:

    tileWidthProperty.bind(parent.widthProperty().divide(8));
    tileHeightProperty.bind(parent.heightProperty().divide(8));
    

    So I created example just to show how it might look like.

    import javafx.animation.PathTransition;
    import javafx.application.Application;
    import javafx.application.Platform;
    import javafx.beans.InvalidationListener;
    import javafx.beans.property.DoubleProperty;
    import javafx.beans.property.SimpleDoubleProperty;
    import javafx.scene.Scene;
    import javafx.scene.layout.Pane;
    import javafx.scene.shape.Ellipse;
    import javafx.scene.shape.LineTo;
    import javafx.scene.shape.MoveTo;
    import javafx.scene.shape.Path;
    import javafx.stage.Stage;
    import javafx.util.Duration;
    
    public class Main extends Application {
    
        private Pane parent;
        private final DoubleProperty
                tileWidthProperty = new SimpleDoubleProperty(),
                tileHeightProperty = new SimpleDoubleProperty();
        private EllipseTransition ellipseTransition;
    
        @Override
        public void start(Stage primaryStage) {
            this.parent = new Pane();
    
            tileWidthProperty.bind(parent.widthProperty().divide(8));
            tileHeightProperty.bind(parent.heightProperty().divide(8));
    
            // create ellipse
            final Ellipse ellipse = new Ellipse(25., 25.);
            parent.getChildren().add(ellipse);
    
            // show the stage
            primaryStage.setScene(new Scene(parent, 800, 800));
            primaryStage.show();
    
            // create listeners that listen to size changes
            InvalidationListener sizeChangeListener = l -> {
                if(ellipseTransition != null) {
                    // refreshAndStart returns null if transition is completed
                    // let's call it delayed cleanup :)
                    ellipseTransition = ellipseTransition.refreshAndStart();
                } else {
                    System.out.println("ellipseTransition cleaned up!");
                }
            };
            // add listeners to the corresponding properties
            tileWidthProperty.addListener(sizeChangeListener);
            tileHeightProperty.addListener(sizeChangeListener);
    
            // move ellipse 0,0 -> 7,7
            applyMove(ellipse, 0, 0, 7, 7, Duration.millis(5000));
    
            // interrupt transition at the middle, just for fun
            /*new Thread(() -> {
                try {
                    Thread.sleep(2500);
                } catch (InterruptedException e) {
                    return;
                }
                applyMove(ellipse, 2, 3, 4, 5, Duration.millis(1000));
            }).start();*/
        }
    
    
        public void applyMove(final Ellipse toBeMoved, final int startCol, final int startRow, final int endCol, final int endRow, final Duration duration) {
            Platform.runLater(() -> {
                // if transition is still up, then stop it
                if(ellipseTransition != null) {
                    ellipseTransition.finish();
                }
                // and create a new one
                ellipseTransition = new EllipseTransition(toBeMoved, startCol, startRow, endCol, endRow, Duration.ZERO, duration, 0);
                // then start it
                ellipseTransition.start();
            });
        }
    
        // I decided to write separate class for the transition to make it more convenient
        private class EllipseTransition {
            // these variables are the same you used in your code
            private final Path path;
            private final Ellipse ellipse; // this one was "toBeMoved"
            private final int startCol, startRow, endCol, endRow;
            private final Duration duration;
            private final PathTransition transition;
    
            // if we change parent size in the middle of the transition, this will give the new transition information about where we were.
            private Duration startTime;
    
            // we call System.currentTimeMillis() when we start
            private long startTimestamp;
    
            // if true, transition would not start again
            private boolean finished;
    
            public EllipseTransition(Ellipse ellipse, int startCol, int startRow, int endCol, int endRow, Duration startTime, Duration duration, long realStartTimestamp) {
                this.path = new Path();
                this.ellipse = ellipse;
                this.startCol = startCol;
                this.startRow = startRow;
                this.endCol = endCol;
                this.endRow = endRow;
                this.startTime = startTime;
                this.duration = duration;
                this.transition = new PathTransition();
    
                // applyMove passes 0, because we don't know our start time yet
                this.startTimestamp = realStartTimestamp;
            }
    
            // this is called right before starting the transition
            private void init() {
                // show path for debugging
                parent.getChildren().add(path);
    
                // binding values here is useless, you can compute everything in old-fashioned way for better readability
                final MoveTo startingPoint = new MoveTo();
                startingPoint.setX(tileWidthProperty.get() * startCol + tileWidthProperty.get() / 2.);
                startingPoint.setY(tileHeightProperty.get() * startRow + tileHeightProperty.get() / 2.);
    
                final LineTo endPoint = new LineTo();
                endPoint.setX(tileWidthProperty.get() * endCol + tileWidthProperty.get() / 2);
                endPoint.setY(tileHeightProperty.get() * endRow + tileHeightProperty.get() / 2);
    
                path.getElements().clear(); // clear paths from the last time
                path.getElements().add(startingPoint);
                path.getElements().add(endPoint);
    
                ellipse.translateXProperty().unbind();
                ellipse.translateYProperty().unbind();
    
                transition.setNode(ellipse);
                transition.setDuration(duration);
                transition.setPath(path);
                transition.setOrientation(PathTransition.OrientationType.NONE);
                transition.setCycleCount(1);
                transition.setAutoReverse(false);
    
                transition.setOnFinished(event ->
                {
                    // bind ellipse to the new location
                    ellipse.translateXProperty().bind(tileWidthProperty.multiply(endCol).add(tileWidthProperty.divide(2)));
                    ellipse.translateYProperty().bind(tileHeightProperty.multiply(endRow).add(tileHeightProperty.divide(2)));
    
                    // cleanup
                    stop();
    
                    // mark as finished
                    finished = true;
                });
            }
    
            // stops the transition
            private void stop() {
                // remove debug path ( added it in init() )
                parent.getChildren().remove(path);
                transition.stop();
            }
    
            // starts the transition
            public void start() {
                if(finished) {
                    return;
                }
    
                init(); // initialize parameters
    
                // start from the place where previous we stopped last time
                // if we did not stop anywhere, then we start from beginning (applyMove passes Duration.ZERO)
                this.transition.playFrom(startTime);
    
                // applyMove passes 0, as it doesn't know when transition will start
                // but now we know
                if(this.startTimestamp == 0) {
                    this.startTimestamp = System.currentTimeMillis();
                }
            }
    
            // stops the transition
            public void finish() {
                stop();
                finished = true;
            }
    
            // stops and refreshes the transition.
            // that will continue transition but for new values
            private void refresh() {
                // stop the transition
                stop();
    
                // determine how much time we spend after transition has started
                long currentDuration = System.currentTimeMillis() - startTimestamp;
    
                // update startTime to the current time.
                // when we call start() next time, transition will continue, but with new parameters
                this.startTime = Duration.millis(currentDuration);
            }
    
            // this method is called from change listener
            public EllipseTransition refreshAndStart() {
                if(finished) {
                    // return null to the listener
                    // we want to cleanup completely
                    return null;
                }
                // refresh new values and start
                refresh(); start();
                return this;
            }
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    }