Animate new ListView entries in Javafx

animation, java, javafx, javafx-8, listview

Solution

To find the reason for the double firing of the listener, I've done some debugging, following the `layoutChildren` calls.

Long story short: for every click on the `Add Book` button, there are not two, but three changes in `itemProperty()`. To find out, add some printing to console on the listener and the `updateItem` method:

itemProperty().addListener((obs,oldv,newv)->{
    System.out.println("change: "+getIndex()+", newv "+(newv != null?newv.getIsbn():"null")+", oldv: "+(oldv != null?oldv.getIsbn():"null"));
}

protected void updateItem(Book item, boolean empty){
        super.updateItem(item, empty);
        System.out.println("update item: "+(item!=null?item.getIsbn():"null"));
}

For the first book "123", this is the trace:

update item: null
change: 0, newv 123, oldv: null
update item: 123
change: -1, newv null, oldv: 123
update item: null
update item: null
change: 0, newv 123, oldv: null
update item: 123

The first change happens as expected due to the creation of a new cell. But once it's created, this is followed by a call to `VirtualFlow.releaseCell()` with index -1, and finally all the cells are rebuilt all over again with `addLeadingCells()` and `addTrailingCells()` in `VirtualFlow.layoutChildren()`.

So by design, these calls can't be avoided, what means the animation shouldn't be in the listener if you want to make sure it's only called once.

ANIMATION

I've come with a solution that implies getting the cell and run the animation:

addButton.setOnAction((event)->{
    data.add(new Book(String.valueOf(newBookIndex++),"test"));

    VirtualFlow vf=(VirtualFlow)lv.lookup(".virtual-flow");
    BookCell cell = (BookCell)vf.getCell(lv.getItems().size()-1);
    cell.runAnimation();
});

This is not very advisable since uses `VirtualFlow`, private API.

This can be avoided by looking for the CSS selector `indexed-cells` instead:

addButton.setOnAction((event)->{
    data.add(new Book(String.valueOf(newBookIndex),"test"));

    lv.lookupAll(".indexed-cell").stream()
            .map(n->(BookCell)n)
            .filter(c->c.getBook().getIsbn().equals(String.valueOf(newBookIndex)))
            .findFirst().ifPresent(BookCell::runAnimation);
    newBookIndex++;
});

Note that since we're using lookups, the event handler for the button should be added after the stage is shown.

EDIT

There are weird problems reported by the OP with scrolling. I'm not sure if this is a bug, but the top cell is animated instead of the bottom one.

To solve this issue I've come with this workaround, removing `BookCell` animation, and creating one for the cell, using `VirtualFlow` to get the cell and scroll if necessary.

First we try to find if the scrollbars are not visible: we use the our fade transition. But if we have scrollbars, now we need to call `show()` to scroll to the last cell. A short `PauseTransition` does the trick before launching the fade transition.

addButton.setOnAction((event)->{
    addButton.setDisable(true);
    data.add(new Book(String.valueOf(newBookIndex),"test"));

    VirtualFlow vf=(VirtualFlow)lv.lookup(".virtual-flow");
    if(!lv.lookup(".scroll-bar").isVisible()){
        FadeTransition f = new FadeTransition();
        f.setDuration(Duration.seconds(1));
        f.setFromValue(0);
        f.setToValue(1);
        f.setNode(vf.getCell(lv.getItems().size()-1));
        f.setOnFinished(t->{
            newBookIndex++;
            addButton.setDisable(false);                        
        });
        f.play();
    } else {
        PauseTransition p = new PauseTransition(Duration.millis(20));
        p.setOnFinished(e->{
            vf.getCell(lv.getItems().size()-1).setOpacity(0);
            vf.show(lv.getItems().size()-1);
            FadeTransition f = new FadeTransition();
            f.setDuration(Duration.seconds(1));
            f.setFromValue(0);
            f.setToValue(1);
            f.setNode(vf.getCell(lv.getItems().size()-1));
            f.setOnFinished(t->{
                newBookIndex++;
                addButton.setDisable(false);                        
            });
            f.play();
        });
        p.play();
    }
});

UPDATED CODE

Finally, this is all the code I've used:

public class Main extends Application {

    private int newBookIndex = 2;        
    public final ObservableList<Book> data =  FXCollections.observableArrayList(
            new Book("123","Hugo"), new Book("456","Harry Potter"));
    private final ListView<Book> lv = new ListView<>();   

    @Override
    public void start(Stage primaryStage) {
        Button addButton = new Button("Add Book");

        lv.setCellFactory(param->new BookCell());
        lv.setItems(data);
        Scene myScene = new Scene(new VBox(10,lv,addButton), 200, 200);
        myScene.getStylesheets().add(getClass().getResource("application.css").toExternalForm());
        primaryStage.setScene(myScene);
        primaryStage.show();

        addButton.setOnAction((event)->{
            addButton.setDisable(true);
            data.add(new Book(String.valueOf(newBookIndex),"test"));

            VirtualFlow vf=(VirtualFlow)lv.lookup(".virtual-flow");
            if(!lv.lookup(".scroll-bar").isVisible()){
                FadeTransition f = new FadeTransition();
                f.setDuration(Duration.seconds(1));
                f.setFromValue(0);
                f.setToValue(1);
                f.setNode(vf.getCell(lv.getItems().size()-1));
                f.setOnFinished(t->{
                    newBookIndex++;
                    addButton.setDisable(false);                        
                });
                f.play();
            } else {
                PauseTransition p = new PauseTransition(Duration.millis(20));
                p.setOnFinished(e->{
                    vf.getCell(lv.getItems().size()-1).setOpacity(0);
                    vf.show(lv.getItems().size()-1);
                    FadeTransition f = new FadeTransition();
                    f.setDuration(Duration.seconds(1));
                    f.setFromValue(0);
                    f.setToValue(1);
                    f.setNode(vf.getCell(lv.getItems().size()-1));
                    f.setOnFinished(t->{
                        newBookIndex++;
                        addButton.setDisable(false);                        
                    });
                    f.play();
                });
                p.play();
            }
        });
    }

    class BookCell extends ListCell<Book>{
        private final Text text = new Text();
        private final HBox h = new HBox(text);

        {
            getStyleClass().add("book-list-cell");
        }

        @Override
        protected void updateItem(Book item, boolean empty){
            super.updateItem(item, empty);
            if(item!=null && !empty){
                text.setText(item.getIsbn());
                setGraphic(h);
            } else {
                setGraphic(null);
                setText(null);
            }
        }
    }

    class Book {
        private Book(String isbn, String name) {
            this.isbn.set(isbn);
            this.name.set(name);
        }

        private final StringProperty name = new SimpleStringProperty();

        public String getName() {
            return name.get();
        }

        public void setName(String value) {
            name.set(value);
        }

        public StringProperty nameProperty() {
            return name;
        }

        private final StringProperty isbn = new SimpleStringProperty();

        public String getIsbn() {
            return isbn.get();
        }

        public void setIsbn(String value) {
            isbn.set(value);
        }

        public StringProperty isbnProperty() {
            return isbn;
        }

    }

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

}

Problem

The Problem Hi, I'm trying to write an application where every new entry in a ListView gets animated. Here is my code: ``` public class BookCell extends ListCell<Book> { private Text text; private HBox h; public BookCell() { this.text = new Text(); this.h = new HBox(); this.h.getChildren().add(text); super.getStyleClass().add("book-list-cell"); super.itemProperty().addListener((obs,oldv,newv)->{ if(newv != null ) { if(getIndex() == this.getListView().getItems().size()-1 ) { //why does this get called twice for each update? System.out.println("isbn = "+newv.getIsbn().get() + " lastIndexOf=" + this.getListView().getItems().lastIndexOf(newv)+" Index="+getIndex()+" size="+this.getListView().getItems().size()); runAnimation(); } } }); this.getChildren().add(h); } @Override protected void updateItem(Book item, boolean empty) { super.updateItem(item, empty); if(!empty) super.setGraphic(h); text.setText(item == null ? "" : item.getIsbn().get()); } public void runAnimation() { FadeTransition f = new FadeTransition(); f.setFromValue(0); f.setToValue(1); f.setNode(this); f.play(); } } ``` I've tried to add a listener to the itemProperty, but I get some strange behavior. Firstly, the listener fires twice each time I add an entry: which results in the animation playing twice. This can be a little awkward to watch even though it is hardly noticeable in this case. Furthermore, after the list starts scrolling, the animation is sometimes repeated for a visible entry: To be completely honest, I wouldn't necessarily mind the animation repeating if an item gets visible again, but as it stands, it is very unreliable which cells get animated. I know that the cells are virtually controlled and get reused over time. But I'm having a hard time to find a reliable way to determine if a nonempty cell appears on the screen. The Questions - Why is the listener firing twice? - What is the best way to observe the appearance/disappearance of a cell on the screen - Is there a better way to add animations to a ListView? The complete code It might be helpful, so here are my css and main files. Main.java ``` public class Main extends Application { static int newBookIndex = 1; public static final ObservableList<Book> data = FXCollections.observableArrayList(); @Override public void start(Stage primaryStage) { try { GridPane myPane = (GridPane)FXMLLoader.load(getClass().getResource("quotes.fxml")); ListView<Book> lv = (ListView<Book>) myPane.getChildren().get(0); Button addButton = (Button) myPane.getChildren().get(1); addButton.setText("Add Book"); addButton.setOnAction((event)->{ data.add(new Book(String.valueOf(newBookIndex++),"test")); }); lv.setEditable(true); data.addAll( new Book("123","Hugo"), new Book("456","Harry Potter") ); lv.setItems(data); lv.setCellFactory((param)->return new BookCell()); Scene myScene = new Scene(myPane); myScene.getStylesheets().add(getClass().getResource("application.css").toExternalForm()); primaryStage.setScene(myScene); primaryStage.show(); } catch(Exception e) { e.printStackTrace(); } } public static void main(String[] args) { launch(args); } } ``` application.css (based on https://github.com/privatejava/javafx-listview-animation) ``` .book-list-cell:odd{ -fx-background-image: url('./wooden2.png') ,url('./gloss.png'); -fx-background-repeat: repeat,no-repeat; } .book-list-cell:empty { -fx-background-image: url('./gloss.png'); -fx-background-repeat: repeat; } .book-list-cell { -fx-font-size:45px; -fx-font-family: 'Segoe Script'; } .book-list-cell:selected,.book-list-cell:selected:hover{ -fx-border-color:linear-gradient(to bottom,transparent,rgb(22,22,22,1)); -fx-border-width:2 2 2 10px; -fx-effect:innershadow( three-pass-box,#764b0c,30,0.3,0,0); } .book-list-cell:hover{ -fx-border-color:rgb(255,255,255,0.7); -fx-border-width:1 1 1 10px; } .book-list-cell{ -fx-background-image: url('./wooden.png') ,url('./gloss.png'); -fx-background-repeat: repeat,no-repeat; -fx-background-position:left top; -fx-background-size: auto,100% 40%; } ```

Original source