Animation upon layout changes

2019-01-17 13:09发布

The basic FlowPane in JavaFX lays out the items inside each time the window is resized. However, there is no animation and the result is rather jarring.

I've hooked up a change listener on the layoutX and layoutY properties of each Node inside the FlowPane and the result more or less works, but sometimes when I resize the window quickly, the elements are left in inconsistent places.

What am I missing?

package javafxapplication1;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javafx.animation.Transition;
import javafx.animation.TranslateTransition;
import javafx.beans.property.DoubleProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.util.Duration;

/**
 * Animates an object when its position is changed. For instance, when
 * additional items are added to a Region, and the layout has changed, then the
 * layout animator makes the transition by sliding each item into its final
 * place.
 */
public class LayoutAnimator implements ChangeListener, ListChangeListener<Node> {

  private Map<Node, Transition> nodesInTransition;

  public LayoutAnimator() {
    this.nodesInTransition = new HashMap<>();
  }

  /**
   * Animates all the children of a Region.
   * <code>
   *   VBox myVbox = new VBox();
   *   LayoutAnimator animator = new LayoutAnimator();
   *   animator.observe(myVbox.getChildren());
   * </code>
   *
   * @param nodes
   */
  public void observe(ObservableList<Node> nodes) {
    for (Node node : nodes) {
      this.observe(node);
    }
    nodes.addListener(this);
  }

  public void unobserve(ObservableList<Node> nodes) {
    nodes.removeListener(this);
  }

  public void observe(Node n) {
    n.layoutXProperty().addListener(this);
    n.layoutYProperty().addListener(this);
  }

  public void unobserve(Node n) {
    n.layoutXProperty().removeListener(this);
    n.layoutYProperty().removeListener(this);
  }

  @Override
  public void changed(ObservableValue ov, Object oldValue, Object newValue) {
    final Double oldValueDouble = (Double) oldValue;
    final Double newValueDouble = (Double) newValue;
    final Double changeValueDouble = newValueDouble - oldValueDouble;
    DoubleProperty doubleProperty = (DoubleProperty) ov;

    Node node = (Node) doubleProperty.getBean();
    final TranslateTransition t;
    if ((TranslateTransition) nodesInTransition.get(node) == null) {
      t = new TranslateTransition(Duration.millis(150), node);
    } else {
      t = (TranslateTransition) nodesInTransition.get(node);
    }

    if (doubleProperty.getName().equals("layoutX")) {
      Double orig = node.getTranslateX();
      if (Double.compare(t.getFromX(), Double.NaN) == 0) {
        t.setFromX(orig - changeValueDouble);
        t.setToX(orig);
      }
    }
    if (doubleProperty.getName().equals("layoutY")) {
      Double orig = node.getTranslateY();
      if (Double.compare(t.getFromY(), Double.NaN) == 0) {
        t.setFromY(orig - changeValueDouble);
        t.setToY(orig);
      }
    }
    t.play();

  }

  @Override
  public void onChanged(ListChangeListener.Change change) {
    while (change.next()) {
      if (change.wasAdded()) {
        for (Node node : (List<Node>) change.getAddedSubList()) {
          this.observe(node);
        }
      } else if (change.wasRemoved()) {
        for (Node node : (List<Node>) change.getRemoved()) {
          this.unobserve(node);
        }
      }
    }
  }
}

A Gist is available here for readability and comes with a test case: https://gist.github.com/teyc/5668517

标签: javafx-2
1条回答
三岁会撩人
2楼-- · 2019-01-17 13:38

This is a really neat idea. I like this. You should consider contributing your new layout manager to jfxtras.

Why your Originally Posted Solution Does not Work

Your problem is the logic around trying to record an original value for the translateX/Y values and ending your translate transition at that original value.

When a TranslateTransition is in progress, it modifies the translateX/Y values. With your current code, if you resize the screen rapidly before your animations finish, you set the toX/Y properties of your TranslateTransition to the current translateX/Y values. This makes the final resting place of the animation some prior intermediate point rather than the desired final resting place for the node (the desired place is just the point where the translateX/Y values for the node are 0).

How to Fix it

The fix is simple - toX/Y for the TranslateTransition should always be zero, so the transition always ends up translating the node to whatever it's current layout position is supposed to be with no translate delta.

Sample Solution Code Snippet

Here is the core updated transition code:

TranslateTransition t;
switch (doubleProperty.getName()) {
  case  "layoutX":
    t = nodeXTransitions.get(node);
    if (t == null) {
      t = new TranslateTransition(Duration.millis(150), node);
      t.setToX(0);
      nodeXTransitions.put(node, t);
    }
    t.setFromX(node.getTranslateX() - delta);
    node.setTranslateX(node.getTranslateX() - delta);
    break;

  default: // "layoutY"
    t = nodeYTransitions.get(node);
    if (t == null) {
      t = new TranslateTransition(Duration.millis(150), node);
      t.setToY(0);
      nodeYTransitions.put(node, t);
    }
    t.setFromY(node.getTranslateY() - delta);
    node.setTranslateY(node.getTranslateY() - delta);
}

t.playFromStart();

Sample Solution Output

Output of the updated animator after adding a few more rectangles and resizing the screen to force new layouts is (using the test harness you supplied on gist):

layoutanimator

On Debugging Animations and Fixing Additional Issues with your Sample Code

In debugging animations, I find it is easier if you slow down animations. To work out how to fix the posted program, I set the TranslateTransition to a second to give the eye enough time to see what is actually going on.

Slowing down the animation helped me see that the actual animations generated by your listeners don't seem to take place until a frame after the node has been relaid out, making a brief glitch where the node will momentarily appear in it's target position, then go back to it's original position, then slowly animate to the target position.

The fix for the initial positioning glitch is to translate the node in the listener back to original position before starting to play the animation.

Your code makes use of a nodesInTransition map to keep track of the currently transitioning nodes, but it never puts anything in the map. I renamed it to nodeXTransitions and nodeYTransitions (because I use separate x and y transitions rather than a single transition for both because using separate ones seemed easier). I place the node transitions into the map as they are created so that I can stop old transitions when I create new ones. This didn't seem strictly necessary as everything seemed to work fine without the map logic (perhaps JavaFX already does something like this implicitly inside it's animation framework), but it seems like a safe thing to do, so I kept it.

After I made the changes detailed above everything seemed to work OK.

Potential Further Improvements

There are perhaps some improvements which could be made for timing of animations so that if an animation is partially played and a relayout occurs, maybe you don't want to play a whole animation from the beginning for the new relayout. Or perhaps you might want to have all of the animated nodes move at a constant, identical velocity rather than for a duration. But visually, that didn't seem to matter much, so I wouldn't really worry about it.

Test System

I did my testing on Java8b91 and OS X 10.8.

Full Code for the Updated Solution

Full code the updated layout animator is:

import javafx.animation.TranslateTransition;
import javafx.beans.property.DoubleProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.util.Duration;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Animates an object when its position is changed. For instance, when
 * additional items are added to a Region, and the layout has changed, then the
 * layout animator makes the transition by sliding each item into its final
 * place.
 */
public class LayoutAnimator implements ChangeListener<Number>, ListChangeListener<Node> {

  private Map<Node, TranslateTransition> nodeXTransitions = new HashMap<>();
  private Map<Node, TranslateTransition> nodeYTransitions = new HashMap<>();

  /**
   * Animates all the children of a Region.
   * <code>
   *   VBox myVbox = new VBox();
   *   LayoutAnimator animator = new LayoutAnimator();
   *   animator.observe(myVbox.getChildren());
   * </code>
   *
   * @param nodes
   */
  public void observe(ObservableList<Node> nodes) {
    for (Node node : nodes) {
      this.observe(node);
    }
    nodes.addListener(this);
  }

  public void unobserve(ObservableList<Node> nodes) {
    nodes.removeListener(this);
  }

  public void observe(Node n) {
    n.layoutXProperty().addListener(this);
    n.layoutYProperty().addListener(this);
  }

  public void unobserve(Node n) {
    n.layoutXProperty().removeListener(this);
    n.layoutYProperty().removeListener(this);
  }

  @Override
  public void changed(ObservableValue<? extends Number> ov, Number oldValue, Number newValue) {
    final double delta = newValue.doubleValue() - oldValue.doubleValue();
    final DoubleProperty doubleProperty = (DoubleProperty) ov;
    final Node node = (Node) doubleProperty.getBean();

    TranslateTransition t;
    switch (doubleProperty.getName()) {
      case  "layoutX":
        t = nodeXTransitions.get(node);
        if (t == null) {
          t = new TranslateTransition(Duration.millis(150), node);
          t.setToX(0);
          nodeXTransitions.put(node, t);
        }
        t.setFromX(node.getTranslateX() - delta);
        node.setTranslateX(node.getTranslateX() - delta);
        break;

      default: // "layoutY"
        t = nodeYTransitions.get(node);
        if (t == null) {
          t = new TranslateTransition(Duration.millis(150), node);
          t.setToY(0);
          nodeYTransitions.put(node, t);
        }
        t.setFromY(node.getTranslateY() - delta);
        node.setTranslateY(node.getTranslateY() - delta);
    }

    t.playFromStart();
  }

  @Override
  public void onChanged(Change change) {
    while (change.next()) {
      if (change.wasAdded()) {
        for (Node node : (List<Node>) change.getAddedSubList()) {
          this.observe(node);
        }
      } else if (change.wasRemoved()) {
        for (Node node : (List<Node>) change.getRemoved()) {
          this.unobserve(node);
        }
      }
    }
  }
}

On Making Layout Animation Changes Independent from User TranslateX/Y Settings

One drawback of the solution as currently presented is that if the user of the custom layout manager had applied translateX/Y settings directly to the laid out nodes, then they will lose those values as the layout manager relays out all the content (as the translateX/Y ends up being set back to 0).

To preserve the user's translateX/Y, the solution could be updated to use custom transitions rather than translate transitions. The custom transitions can be applied to properties of a Translate to the nodes' transform. Then only the additional transform on each node is impacted by the inner workings of the layout animation - the user's original translateX/Y values are not affected.

I forked the gist from your question to implement the custom transition enhancement mentioned above.


If you were keen, you could look over the openjfx code for tab headers, TitledPanes and Accordions and see how the skins for those controls handle the animation of layout changes for their child nodes.

查看更多
登录 后发表回答