My JavaFX ListViews are doubling up
18 January 2022You’re making a JavaFX app and you want to render a collection properly by using a ListView
or TableView
. These components are great: you point them to an ObservableList
and all you need to do is update this list and the views will automatically be updated. You can also generate a bunch of your favourite JavaFX UI elements for each item in the list — including buttons, labels, inputs — any components at all!
There’s a problem you might run into though. We’ll see it soon.
Hello ListView #
We’ll start with the “hello world” of JavaFX list views. The code below is essentially what you’d find in the JavaFX ListView docs. But I’ve rendered inside of an application scene:
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.ListView;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class Main extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
ObservableList<String> names = FXCollections.observableArrayList(
"Julia", "Ian", "Sue", "Matthew", "Hannah", "Stephan", "Denise");
ListView<String> listView = new ListView<String>(names);
VBox pane = new VBox();
pane.getChildren().add(listView);
Scene scene = new Scene(pane, 700, 500);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
This works fine and prints off a nice list of names.
Cell factories #
What if we wanted to customize our ListView
?
Let’s make it render the following for each item in our observable list:
- A
Text
component for the name - A
Region
component to separate the name from the others - A
TextField
component to edit the name - A submit
Button
to confirm the change
We’ll wrap these in an HBox
component so they’re nicely displayed horizontally on the screen.
You can do this using a cell factory - which needs to be a class that implements the JavaFX CallBack
interface. In this class we override the call
method to return a new ListCell
object.
In the ListCell
object we return, we want to override the updateItem
method and call setGraphic
inside. In the arguments of setGraphic
, we pass the component that should be created for each cell. We’re going to pass it our HBox
after we’ve created it and filled it with the list of components from before.
I promise it’s not too bad. Here’s what it looks like:
/* Main.java
Do not write your JavaFX like this - we need to fix this up!
*/
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Callback;
public class Main extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
ObservableList<String> names = FXCollections.observableArrayList(
"Julia", "Ian", "Sue", "Matthew", "Hannah", "Stephan", "Denise");
ListView<String> listView = new ListView<String>(names);
class PersonCellFactory implements Callback<ListView<String>, ListCell<String>> {
@Override
public ListCell<String> call(ListView<String> listView) {
return new ListCell<String>(){
@Override
public void updateItem(String nameString, boolean empty) {
super.updateItem(nameString, empty);
Text nameText = new Text(nameString);
Region region1 = new Region();
TextField nameField = new TextField(nameString);
Button changeNameButton = new Button("update");
HBox box = new HBox();
HBox.setHgrow(region1, Priority.ALWAYS);
box.setAlignment(Pos.BASELINE_LEFT);
box.getChildren().addAll(nameText,region1,nameField,changeNameButton);
setGraphic(box);
}
};
}
}
listView.setCellFactory(new PersonCellFactory());
VBox pane = new VBox();
pane.getChildren().add(listView);
Scene scene = new Scene(pane, 700, 500);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
If you run this you’ll get something a little weird, where you get a clickable list of names, then a big list of items underneath in which the button and texfield is clickable, but not the rest. This is sort of what we want, but not really.
Checking for empty cells #
What we need to do is add a check in updateItem
to make sure that if it’s an empty cell, we don’t add anything in. This is what you might think this should like:
@Override
public void updateItem(String nameString, boolean empty) {
super.updateItem(nameString, empty);
/*
* Uh oh! This is buggy! Don't worry we'll fix it soon
*/
if (empty) {
return;
}
Text nameText = new Text(nameString);
Region region1 = new Region();
TextField nameField = new TextField(nameString);
Button changeNameButton = new Button("update");
HBox box = new HBox();
HBox.setHgrow(region1, Priority.ALWAYS);
box.setAlignment(Pos.BASELINE_LEFT);
box.getChildren().addAll(nameText, region1, nameField, changeNameButton);
setGraphic(box);
}
Okay, when we run it it seems okay? We’re only rendering the items that aren’t empty - just what we wanted.
But let’s say that we add a button afterwards to add a new person to our list:
listView.setCellFactory(new PersonCellFactory());
/* New code from here */
Button newPersonButton = new Button("Add person");
newPersonButton.setOnAction(event->{
names.add("Johnny");
});
VBox pane = new VBox();
pane.getChildren().add(listView,newPersonButton);
/* New code ends here */
Scene scene = new Scene(pane, 700, 500);
/* etc */
When we add this, everything looks fine to start with, but when we click the newPersonButton
we run into our problem. The new item, “Johnny”, gets added, but all the other items seem to appear under it in reverse order! What is going on?
Why isn’t my JavaFX ListView working? #
This is something to do with the internals of the way the ListView
interacts with cell factories. What we need to do when the cell is empty is not just return, but set the graphic as null.
This is because the updateItem
method is used not only to create the cell, but also to clean it up!
This is how our check should look:
if (empty) {
/* This is the important part */
setGraphic(null);
return;
}
And here’s how the whole program should look at this point:
/* Main.java
Do not write your JavaFX like this - we need to fix this up!
*/
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Callback;
public class Main extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
ObservableList<String> names = FXCollections.observableArrayList("Julia", "Ian", "Sue", "Matthew", "Hannah",
"Stephan", "Denise");
ListView<String> listView = new ListView<String>(names);
class PersonCellFactory implements Callback<ListView<String>, ListCell<String>> {
@Override
public ListCell<String> call(ListView<String> listView) {
return new ListCell<String>() {
@Override
public void updateItem(String nameString, boolean empty) {
super.updateItem(nameString, empty);
if (empty ) {
/* This is the important part */
setGraphic(null);
return;
}
Text nameText = new Text(nameString);
Region region1 = new Region();
TextField nameField = new TextField(nameString);
Button changeNameButton = new Button("update");
HBox box = new HBox();
HBox.setHgrow(region1, Priority.ALWAYS);
box.setAlignment(Pos.BASELINE_LEFT);
box.getChildren().addAll(nameText, region1, nameField, changeNameButton);
setGraphic(box);
}
};
}
}
listView.setCellFactory(new PersonCellFactory());
Button newPersonButton = new Button("Add person");
newPersonButton.setOnAction(event -> {
names.add("Johnny");
});
VBox pane = new VBox();
pane.getChildren().addAll(listView, newPersonButton);
Scene scene = new Scene(pane, 700, 500);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
But wait! This still isn't ideal. There's a better way!!!!
We're doing too much work in our updateItem method! A big thank you to Dirk Lemmermann for pointing this out.
Reducing the amount of work in the updateItem method #
It turns out the updateItem
method gets called all the time. This includes getting called in response to select and focus events as well as changes in the data model. At the moment our code is rebuilding the box and all of its child components from scratch for every item, every single time one of these events happens.
To reduce the amount of work that we're doing in our updateItem
method, we can build the child components once in the cell factory (or if you make a custom cell class: in the constructor). After that, the only thing our updateItem
method needs to do, is call setText
on the nameText
and nameField
components.
To stop the cell from showing up when it's empty we can use a superpower JavaFX provides: Binding. We want to bind the visibility property of the HBox
to the inverse of the empty property of the ListCell
. Here's what this looks like:
box.visibleProperty().bind(cell.emptyProperty().not());
And here's how it looks in context:
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Callback;
public class Main extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
ObservableList<String> names = FXCollections.observableArrayList("Julia", "Ian", "Sue", "Matthew", "Hannah",
"Stephan", "Denise");
ListView<String> listView = new ListView<String>(names);
class PersonCellFactory implements Callback<ListView<String>, ListCell<String>> {
@Override
public ListCell<String> call(ListView<String> listView) {
Text nameText = new Text();
Region region1 = new Region();
TextField nameField = new TextField();
Button changeNameButton = new Button("update");
HBox.setHgrow(region1, Priority.ALWAYS);
HBox box = new HBox();
box.setAlignment(Pos.BASELINE_LEFT);
box.getChildren().addAll(nameText, region1, nameField, changeNameButton);
ListCell<String> cell = new ListCell<String>() {
public void updateItem(String nameString, boolean empty) {
super.updateItem(nameString, empty);
nameText.setText(nameString);
nameField.setText(nameString);
}
};
cell.setGraphic(box);
box.visibleProperty().bind(cell.emptyProperty().not());
return cell;
}
}
listView.setCellFactory(new PersonCellFactory());
Button newPersonButton = new Button("Add person");
newPersonButton.setOnAction(event -> {
names.add("Johnny");
});
VBox pane = new VBox();
pane.getChildren().addAll(listView, newPersonButton);
Scene scene = new Scene(pane, 700, 500);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Nice! Custom JavaFX ListViews behaving just the way we want them to and not doing to much work to recreate the UI. 😌
Now onto writing the code to edit the items. . .
Back to blog