Search code examples
animationjavafxsequential

Animation depends on previous animations


I am trying to set node animation, that depends on previous animations of that node as well as other nodes.
To demonstrate the issue I'll use a simple Pane with 4 Label children:

enter image description here

The main class as well as the model and view classes:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch; 
import javafx.animation.TranslateTransition;
import javafx.application.Application;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.stage.Stage;
import javafx.util.Duration;

public final class Puzzle extends Application{

    private Controller controller;
    @Override
    public void start(Stage stage) throws Exception {
        controller = new Controller();
        BorderPane root = new BorderPane(controller.getBoardPane());
        root.setTop(controller.getControlPane());
        Scene scene = new Scene(root);
        stage.setScene(scene);
        stage.show();
    }

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

class View{

    private static final double size = 70;
    private static Duration animationDuration = Duration.millis(600);
    private int[][] cellModels;
    private Node[][] cellNodes;
    private CountDownLatch latch;
    Button play = new Button("Play");
    private Pane board, control = new HBox(play);;

    View(Model model) {
        cellModels = model.getCellModels();
        cellNodes = new Node[cellModels.length][cellModels[0].length];
        makeBoardPane();
        ((HBox) control).setAlignment(Pos.CENTER_RIGHT);
    }

    private void makeBoardPane() {

        board = new Pane();
        for (int row = 0; row < cellModels.length ; row ++ ) {
            for (int col = 0; col < cellModels[row].length ; col ++ ) {

               Label label = new Label(String.valueOf(cellModels[row][col]));
               label.setPrefSize(size, size);
               Point2D location = getLocationByRowCol(row, col);
               label.setLayoutX(location.getX());
               label.setLayoutY(location.getY());
               label.setStyle("-fx-border-color:blue");
               label.setAlignment(Pos.CENTER);
               cellNodes[row][col] = label;
               board.getChildren().add(label);
            }
        }
    }

    synchronized void updateCell(int id, int row, int column) {

        if(latch !=null) {
            try {
                latch.await();
            } catch (InterruptedException ex) { ex.printStackTrace();}
        }
        latch = new CountDownLatch(1);

        Node node = getNodesById(id).get(0);
        Point2D newLocation = getLocationByRowCol(row, column);
        Point2D moveNodeTo = node.parentToLocal(newLocation );

        TranslateTransition transition = new TranslateTransition(animationDuration, node);
        transition.setFromX(0); transition.setFromY(0);
        transition.setToX(moveNodeTo.getX());
        transition.setToY(moveNodeTo.getY());

        //set animated node layout to the translation co-ordinates:
        //https://stackoverflow.com/a/30345420/3992939
        transition.setOnFinished(ae -> {
            node.setLayoutX(node.getLayoutX() + node.getTranslateX());
            node.setLayoutY(node.getLayoutY() + node.getTranslateY());
            node.setTranslateX(0);
            node.setTranslateY(0);
            latch.countDown();
        });
        transition.play();
    }

    private List<Node> getNodesById(int...ids) {

        List<Node> nodes = new ArrayList<>();
        for(Node node : board.getChildren()) {
            if(!(node instanceof Label)) { continue; }
            for(int id : ids) {
                if(((Label)node).getText().equals(String.valueOf(id))) {
                    nodes.add(node);
                    break;
                }
            }
        }
        return nodes ;
    }

    private Point2D getLocationByRowCol(int row, int col) {
        return new Point2D(size * col, size * row);
    }

    Pane getBoardPane() { return board; }
    Pane getControlPane() { return control;}
    Button getPlayBtn() {return play ;}
}

class Model{

    private int[][] cellModels = new int[][] { {0,1}, {2,3} };
    private SimpleObjectProperty<int[][]> cellModelsProperty =
            cellModelsProperty = new SimpleObjectProperty<>(cellModels);

    void addChangeListener(ChangeListener<int[][]> listener) {
        cellModelsProperty.addListener(listener);
    }

    int[][] getCellModels() {
        return  (cellModelsProperty == null) ? null : cellModelsProperty.get();
    }

    void setCellModels(int[][] cellModels) {
        cellModelsProperty.set(cellModels);
    }
}

and the controller class:

import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.layout.Pane;

class Controller {

    private View view ;
    private Model model;


    Controller() {
        model = new Model();
        model.addChangeListener(getModelCangeListener());//observe model changes
        view = new View(model);
        view.getPlayBtn().setOnAction( a -> shuffle());  //animation works fine
        //view.getPlayBtn().setOnAction( a -> IntStream.
        //        range(0,4).forEach( (i)-> shuffle())); //messes the animation
    }

    private ChangeListener<int[][]> getModelCangeListener() {

        return  (ObservableValue<? extends int[][]> observable,
                int[][] oldValue, int[][] newValue)-> {
                    for (int row = 0; row < newValue.length ; row++) {
                        for (int col = 0; col < newValue[row].length ; col++) {
                            if(newValue[row][col] != oldValue[row][col]) {
                                final int fRow = row, fCol = col;
                                new Thread( () -> view.updateCell(
                                    newValue[fRow][fCol], fRow, fCol)).start();
                            }
                        }
                    }
                };
    }

    void shuffle() {
        int[][] modelData = model.getCellModels();
        int rows = modelData.length, columns = modelData[0].length;
        int[][] newModelData = new int[rows][columns];
        for (int row = 0; row < rows ; row ++) {
            for (int col = 0; col < columns ; col ++) {
                int colIndex = ((col+1) < columns ) ? col + 1  : 0;
                int rowIndex =  ((col+1) < columns ) ?  row : ((row + 1) < rows) ? row +1 : 0;
                newModelData[row][col] = modelData[rowIndex][colIndex];
            }
        }

        model.setCellModels(newModelData);
    }

    Pane getBoardPane() { return view.getBoardPane(); }

    Pane getControlPane() { return view.getControlPane(); }
}

Play button handler changes the model data (see shuffle()), the change triggers ChangeListener. The ChangeListener animates each changed label by invoking view.updateCell(..) on a separate thread. This all works as expected.
The problem starts when I try to run a few consecutive model updates (shuffle()). To simulate it I change

view.getPlayBtn().setOnAction( a -> shuffle()); 

with

view.getPlayBtn().setOnAction( a -> IntStream.range(0,4).forEach( (i)-> shuffle()));

which messes the animation (it plays in wrong order and ends up in wrong positions).
This does not come as a surprise: to work properly animations have to be played in a certain order: a label should be re-animated only after all four labels finished their previous animation.
The code posted runs each update on a thread, so execution order is not guaranteed.

My question is what is the right way to implement the needed sequence of multiple nodes and multiple animations ?

I looked at SequentialTransition but I couldn't figure out how it can be used to overcome the issue in question.
I did come up with a solution which I will post as an answer, because of the length of this post, and because I don't think the solution is good.


Solution

  • Your code does not adhere to the seperation of concerns principle. (Or at least you don't do it well.)

    The animation should be done by the view and the view alone instead of collaborating between the controller and the view. Put all the animations for scheduling the animations in the View class.

    SequentialTransition could wrap multiple animations in a single animation that plays them sequentially, but it shouldn't be the controller's concern to do this.

    public class View {
    
        private static final double SIZE = 70;
        private static final Duration ANIMATION_DURATION = Duration.millis(600);
    
        private final Map<Integer, Label> labelsById = new HashMap<>(); // stores labels by id
        private final Button play = new Button("Play");
        private Pane board;
        private final HBox control;
    
        private final Timeline animation;
        private final LinkedList<ElementPosition> pendingAnimations = new LinkedList<>(); // stores parameter combination for update calls
    
        private Node animatedNode;
    
        public View(int[][] cellModels) { // we don't really need the whole model here
            makeBoardPane(cellModels);
    
            this.control = new HBox(play);
            control.setAlignment(Pos.CENTER_RIGHT);
    
            final DoubleProperty interpolatorValue = new SimpleDoubleProperty();
    
            animation = new Timeline(
                    new KeyFrame(Duration.ZERO, evt -> {
                        ElementPosition ePos = pendingAnimations.removeFirst();
                        animatedNode = labelsById.get(ePos.id);
    
                        Point2D newLocation = getLocationByRowCol(ePos.row, ePos.column);
    
                        // create binding for layout pos
                        animatedNode.layoutXProperty().bind(
                                interpolatorValue.multiply(newLocation.getX() - animatedNode.getLayoutX())
                                        .add(animatedNode.getLayoutX()));
                        animatedNode.layoutYProperty().bind(
                                interpolatorValue.multiply(newLocation.getY() - animatedNode.getLayoutY())
                                        .add(animatedNode.getLayoutY()));
                    }, new KeyValue(interpolatorValue, 0d)),
                    new KeyFrame(ANIMATION_DURATION, evt -> {
                        interpolatorValue.set(1);
    
                        // remove bindings
                        animatedNode.layoutXProperty().unbind();
                        animatedNode.layoutYProperty().unbind();
    
                        animatedNode = null;
    
                        if (pendingAnimations.isEmpty()) {
                            // abort, if no more animations are pending
                            View.this.animation.stop();
                        }
                    }, new KeyValue(interpolatorValue, 1d)));
            animation.setCycleCount(Animation.INDEFINITE);
        }
    
        private void makeBoardPane(int[][] cellModels) {
            board = new Pane();
            for (int row = 0; row < cellModels.length; row++) {
                for (int col = 0; col < cellModels[row].length; col++) {
                    Point2D location = getLocationByRowCol(row, col);
                    int id = cellModels[row][col];
    
                    Label label = new Label(Integer.toString(id));
                    label.setPrefSize(SIZE, SIZE);
                    label.setLayoutX(location.getX());
                    label.setLayoutY(location.getY());
                    label.setStyle("-fx-border-color:blue");
                    label.setAlignment(Pos.CENTER);
    
                    labelsById.put(id, label);
    
                    board.getChildren().add(label);
                }
            }
        }
    
        private static class ElementPosition {
    
            private final int id;
            private final int row;
            private final int column;
    
            public ElementPosition(int id, int row, int column) {
                this.id = id;
                this.row = row;
                this.column = column;
            }
    
        }
    
        public void updateCell(int id, int row, int column) {
            pendingAnimations.add(new ElementPosition(id, row, column));
            animation.play();
        }
    
        private static Point2D getLocationByRowCol(int row, int col) {
            return new Point2D(SIZE * col, SIZE * row);
        }
    
        public Pane getBoard() {
            return board;
        }
    
        public Pane getControlPane() {
            return control;
        }
    
        public Button getPlayBtn() {
            return play;
        }
    }
    

    Usage example with reduced complexity:

    private int[][] oldValue;
    
    @Override
    public void start(Stage primaryStage) {
        List<Integer> values = new ArrayList<>(Arrays.asList(0, 1, 2, 3));
        oldValue = new int[][]{{0, 1}, {2, 3}};
        View view = new View(oldValue);
        Button btn = new Button("Shuffle");
    
        btn.setOnAction((ActionEvent event) -> {
            Collections.shuffle(values);
            int[][] newValue = new int[2][2];
            for (int i = 0; i < 2 * 2; i++) {
                newValue[i / 2][i % 2] = values.get(i);
            }
            System.out.println(values);
    
            for (int row = 0; row < newValue.length; row++) {
                for (int col = 0; col < newValue[row].length; col++) {
                    if (newValue[row][col] != oldValue[row][col]) {
                        view.updateCell(newValue[row][col], row, col);
                    }
                }
            }
            oldValue = newValue;
        });
    
        Scene scene = new Scene(new BorderPane(view.getBoard(), null, view.getControlPane(), btn, null));
    
        primaryStage.setScene(scene);
        primaryStage.show();
    }