Issue
I use two JavaFX Timeline objects to create a countdown timer and a progress bar (i.e. a shrinking JavaFX Rectangle) inside of a ListCell. When the timer reaches zero, the width of the rectangle becomes zero and the cell is removed from the ListView. However, the entire ListView is automatically refreshed causing the Timeline in the other cell to reset. Here is what it looks like starting with two cells:
In the code, the updateItem method gets the number of seconds from the model and sets the text for the timer label. When a cell is removed from the ListView, updateItem is called with a CellData object containing the string "20" which resets the timeline. But I want the cell with the unfinished timer to continue; not start from the beginning. Here's the minimum, reproducible example:
public class AnimatedCells extends Application {
public class MyCell extends ListCell<CellData> {
private CellComponent cc;
private Timeline rectTimeline, timerTimeline;
private KeyValue rectWidth;
public MyCell() {
cc = new CellComponent();
rectTimeline = new Timeline();
timerTimeline = new Timeline();
rectWidth = new KeyValue( cc.getRect().widthProperty(), 0 );
}
@Override
protected void updateItem( CellData item, boolean empty ) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
} else {
if ( item.getData() != null ) {
System.out.println(item.getData());
cc.getTimer().setText( item.getData() );
rectTimeline.getKeyFrames().add( new KeyFrame( Duration.seconds( Double.parseDouble( cc.getTimer().getText() ) ), rectWidth ) );
timerTimeline.getKeyFrames().addAll(
new KeyFrame( Duration.seconds( 0 ),
event -> {
int timerSeconds = Integer.parseInt( cc.getTimer().getText() );
if ( timerSeconds > 0 ) {
cc.getTimer().setText( Integer.toString(--timerSeconds) );
}
else {
rectTimeline.stop();
timerTimeline.stop();
super.getListView().getItems().remove(this.getItem());
}
}),
new KeyFrame( Duration.seconds( 1 ) ) );
timerTimeline.setCycleCount( Animation.INDEFINITE );
setGraphic( cc.getCellPane() );
rectTimeline.play();
timerTimeline.play();
}
}
}
}
public class CellComponent {
@FXML
private Pane cellPane;
@FXML
private Label timer;
@FXML
private Rectangle rect;
public CellComponent() {
FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/fxml/listcell.fxml"));
fxmlLoader.setController(this);
try
{
fxmlLoader.load();
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
public Pane getCellPane() {
return cellPane;
}
public void setCellPane(Pane cellPane) {
this.cellPane = cellPane;
}
public Label getTimer() {
return timer;
}
public void setTimer(Label timer) {
this.timer = timer;
}
public Rectangle getRect() {
return rect;
}
public void setRect(Rectangle rect) {
this.rect = rect;
}
}
public class CellData {
private String data;
public CellData() {
super();
}
public CellData(String data) {
super();
this.setData(data);
}
public String getData() {
return data;
}
public void setData(String data) {
this.data = data;
}
}
@FXML
private ListView<CellData> listView;
private ObservableList<CellData> observableList = FXCollections.observableArrayList();
public void initialize() {
observableList.add( new CellData("20") );
observableList.add( new CellData("10") );
listView.setItems( observableList );
listView.setCellFactory( listView -> {
MyCell cell = new MyCell();
return cell;
});
}
@Override
public void start(Stage stage) throws Exception {
Parent root = new FXMLLoader(getClass().getResource("/fxml/listmanager.fxml")).load();
Scene scene = new Scene(root);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Also, why is updateItem called three times on startup when there are only two rows of data? The lone print statement in the above code outputs:
20
20
10
I am using Java 11.0.6.
Solution
A Cell’s job is to determine how to display data. The underlying data must not be changed in the Cell’s updateItem
method, because a single Cell instance can be used for multiple values.
From the documentation for Cell:
Because TreeView, ListView, TableView and other such controls can potentially be used for displaying incredibly large amounts of data, it is not feasible to create an actual Cell for every single item in the control. We represent extremely large data sets using only very few Cells. Each Cell is "recycled", or reused.
You will need to move all Timelines into the CellData object. And the Cell must not modify those Timelines, or the ListView’s items.
It’s customary, and useful, to make data objects have observable properties, so visual components can bind visual properties to them.
The CellData objects shouldn’t know about the ListView that contains them. You can give each CellData instance a Consumer or other function which does the work of removing the data.
So, you’ll want something like this for the data class:
public static class CellData {
private final StringProperty data;
private final ReadOnlyDoubleWrapper secondsRemaining;
private final Timeline timeline = new Timeline();
public CellData(Consumer<? super CellData> finished) {
data = new SimpleStringProperty(this, "data");
secondsRemaining =
new ReadOnlyDoubleWrapper(this, "secondsRemaining");
data.addListener((o, oldData, newData) -> {
try {
double startTime = Double.parseDouble(newData);
timeline.stop();
timeline.getKeyFrames().setAll(
new KeyFrame(Duration.ZERO,
new KeyValue(secondsRemaining, startTime)),
new KeyFrame(Duration.seconds(startTime),
new KeyValue(secondsRemaining, 0.0))
);
timeline.setOnFinished(e -> finished.accept(this));
timeline.play();
} catch (NumberFormatException e) {
System.err.println(
"Cannot start timer for invalid seconds value: " + e);
Platform.runLater(() -> finished.accept(this));
}
});
}
public CellData(String data,
Consumer<? super CellData> finished) {
this(finished);
this.setData(data);
}
public StringProperty dataProperty() {
return data;
}
public String getData() {
return data.get();
}
public void setData(String data) {
this.data.set(data);
}
public ReadOnlyDoubleProperty secondsRemainingProperty() {
return secondsRemaining.getReadOnlyProperty();
}
public double getSecondsRemaining() {
return secondsRemaining.get();
}
}
An instance would be constructed with something like this:
new CellData(secondsText,
c -> list.getItems().remove(c)));
This allows the cell class to be much simpler:
public static class MyCell extends ListCell<CellData> {
private CellComponent cc;
public MyCell() {
cc = new CellComponent();
}
@Override
protected void updateItem(CellData item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
} else {
cc.getTimer().textProperty().unbind();
cc.getTimer().textProperty().bind(
item.secondsRemainingProperty().asString("%.0f"));
cc.getRect().widthProperty().unbind();
cc.getRect().widthProperty().bind(
item.secondsRemainingProperty().multiply(10));
setText(null);
setGraphic(cc.getCellPane());
}
}
}
Putting it all together looks like this. (For simplicity and reproducibility, I have replaced the FXML with inline code.)
import java.util.function.Consumer;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.animation.Timeline;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.beans.property.StringProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.stage.Stage;
import javafx.scene.Scene;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
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.scene.shape.Rectangle;
import javafx.util.Duration;
import javafx.util.converter.DoubleStringConverter;
public class AnimatedCells
extends Application {
@Override
public void start(Stage stage) {
ListView<CellData> list = new ListView<>();
list.setCellFactory(l -> new MyCell());
TextField secondsField = new TextField();
Button newTimerButton = new Button("Create");
newTimerButton.setOnAction(e -> {
list.getItems().add(
new CellData(secondsField.getText(),
c -> list.getItems().remove(c)));
});
secondsField.setOnAction(e -> {
list.getItems().add(
new CellData(secondsField.getText(),
c -> list.getItems().remove(c)));
});
HBox newButtonPane = new HBox(6, secondsField, newTimerButton);
newButtonPane.setPadding(new Insets(12));
stage.setScene(new Scene(
new BorderPane(list, null, null, newButtonPane, null)));
stage.setTitle("Animated Cells");
stage.show();
}
public static class MyCell extends ListCell<CellData> {
private CellComponent cc;
public MyCell() {
cc = new CellComponent();
}
@Override
protected void updateItem(CellData item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
} else {
cc.getTimer().textProperty().unbind();
cc.getTimer().textProperty().bind(
item.secondsRemainingProperty().asString("%.0f"));
cc.getRect().widthProperty().unbind();
cc.getRect().widthProperty().bind(
item.secondsRemainingProperty().multiply(10));
setText(null);
setGraphic(cc.getCellPane());
}
}
}
public static class CellData {
private final StringProperty data;
private final ReadOnlyDoubleWrapper secondsRemaining;
private final Timeline timeline = new Timeline();
public CellData(Consumer<? super CellData> finished) {
data = new SimpleStringProperty(this, "data");
secondsRemaining =
new ReadOnlyDoubleWrapper(this, "secondsRemaining");
data.addListener((o, oldData, newData) -> {
try {
double startTime = Double.parseDouble(newData);
timeline.stop();
timeline.getKeyFrames().setAll(
new KeyFrame(Duration.ZERO,
new KeyValue(secondsRemaining, startTime)),
new KeyFrame(Duration.seconds(startTime),
new KeyValue(secondsRemaining, 0.0))
);
timeline.setOnFinished(e -> finished.accept(this));
timeline.play();
} catch (NumberFormatException e) {
System.err.println(
"Cannot start timer for invalid seconds value: " + e);
Platform.runLater(() -> finished.accept(this));
}
});
}
public CellData(String data,
Consumer<? super CellData> finished) {
this(finished);
this.setData(data);
}
public StringProperty dataProperty() {
return data;
}
public String getData() {
return data.get();
}
public void setData(String data) {
this.data.set(data);
}
public ReadOnlyDoubleProperty secondsRemainingProperty() {
return secondsRemaining.getReadOnlyProperty();
}
public double getSecondsRemaining() {
return secondsRemaining.get();
}
}
public static class CellComponent {
private final Pane cellPane;
private final Label timer;
private final Rectangle rect;
public CellComponent() {
// For the sake of example, I'm building this in code rather than
// with FXML.
//FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/fxml/listcell.fxml"));
//fxmlLoader.setController(this);
//try
//{
// fxmlLoader.load();
//}
//catch (IOException e)
//{
// throw new RuntimeException(e);
//}
rect = new Rectangle(1, 40);
rect.setArcWidth(20);
rect.setArcHeight(20);
rect.setStyle(
"-fx-fill: red; -fx-stroke: black; -fx-stroke-width: 2;");
timer = new Label(" ");
timer.setStyle("-fx-font-size: 18pt; -fx-alignment: center;");
cellPane = new HBox(24, timer, rect);
cellPane.setStyle("-fx-alignment: center-left;");
}
public Pane getCellPane() {
return cellPane;
}
public Label getTimer() {
return timer;
}
public Rectangle getRect() {
return rect;
}
}
public static class Main {
public static void main(String[] args) {
Application.launch(AnimatedCells.class, args);
}
}
}
Answered By - VGR
Answer Checked By - Pedro (JavaFixing Volunteer)