Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Animate new ListView entries in Javafx

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: enter image description here 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: enter image description here

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

  1. Why is the listener firing twice?
  2. What is the best way to observe the appearance/disappearance of a cell on the screen
  3. 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%;
}
like image 618
ShrimpPhaser Avatar asked Nov 25 '14 15:11

ShrimpPhaser


1 Answers

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);
    }

}
like image 103
José Pereda Avatar answered Sep 27 '22 21:09

José Pereda