Search code examples
javalistviewjavafx

How can I Populate a ListView in JavaFX using Custom Objects?


I'm a bit new to Java, JavaFX, and programming in general, and I have an issue that is breaking my brain.

In most of the tutorials I have looked up regarding populating a ListView (Using an ObservableArrayList, more specifically) the simplest way to do it is to make it from an ObservableList of Strings, like so:

ObservableList<String> wordsList = FXCollections.observableArrayList("First word","Second word", "Third word", "Etc."); 
ListView<String> listViewOfStrings = new ListView<>(wordsList);

But I don't want to use Strings. I would like to use a custom object I made called Words:

ObservableList<Word> wordsList = FXCollections.observableArrayList();
wordsList.add(new Word("First Word", "Definition of First Word");
wordsList.add(new Word("Second Word", "Definition of Second Word");
wordsList.add(new Word("Third Word", "Definition of Third Word");
ListView<Word> listViewOfWords = new ListView<>(wordsList);

Each Word object only has 2 properties: wordString (A string of the word), and definition (Another string that is the word's definition). I have getters and setters for both.

You can see where this is going- the code compiles and works, but when I display it in my application, rather than displaying the titles of every word in the ListView, it displays the Word object itself as a String!

Image showing my application and its ListView

My question here is, specifically, is there a simple way to rewrite this:

ListView<Word> listViewOfWords = new ListView<>(wordsList);

In such a way that, rather than taking Words directly from wordsList, it accesses the wordString property in each Word of my observableArrayList?

Just to be clear, this isn't for android, and the list of words will be changed, saved, and loaded eventually, so I can't just make another array to hold the wordStrings. I have done a bit of research on the web and there seems to be a thing called 'Cell Factories', but it seems unnecessarily complicated for what seems to be such a simple problem, and as I stated before, I'm a bit of a newbie when it comes to programming.

Can anyone help? This is my first time here, so I'm sorry if I haven't included enough of my code or I've done something wrong.


Solution

  • Solution Approach

    I advise using a cell factory to solve this problem.

    listViewOfWords.setCellFactory(param -> new ListCell<Word>() {
        @Override
        protected void updateItem(Word item, boolean empty) {
            super.updateItem(item, empty);
    
            if (empty || item == null || item.getWord() == null) {
                setText(null);
            } else {
                setText(item.getWord());
            }
        }
    });
    

    Sample Application

    add image

    import javafx.application.Application;
    import javafx.collections.*;
    import javafx.scene.Scene;
    import javafx.scene.control.*;
    import javafx.stage.Stage;
    
    public class CellFactories extends Application {    
        @Override
        public void start(Stage stage) {
            ObservableList<Word> wordsList = FXCollections.observableArrayList();
            wordsList.add(new Word("First Word", "Definition of First Word"));
            wordsList.add(new Word("Second Word", "Definition of Second Word"));
            wordsList.add(new Word("Third Word", "Definition of Third Word"));
            ListView<Word> listViewOfWords = new ListView<>(wordsList);
            listViewOfWords.setCellFactory(param -> new ListCell<Word>() {
                @Override
                protected void updateItem(Word item, boolean empty) {
                    super.updateItem(item, empty);
    
                    if (empty || item == null || item.getWord() == null) {
                        setText(null);
                    } else {
                        setText(item.getWord());
                    }
                }
            });
            stage.setScene(new Scene(listViewOfWords));
            stage.show();
        }
    
        public static class Word {
            private final String word;
            private final String definition;
    
            public Word(String word, String definition) {
                this.word = word;
                this.definition = definition;
            }
    
            public String getWord() {
                return word;
            }
    
            public String getDefinition() {
                return definition;
            }
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    }
    

    If the above information was sufficient for you to understand the implementation needed for your application, you don't need to read the rest of this answer, which provides optimized implementations for different use cases.

    Customized Implementations

    Don't override toString for UI presentation purposes

    Although you could override toString in your Word class to provide a string representation of the word aimed at representation in your ListView, I would recommend providing a cell factory in the ListView for extraction of the view data from the word object and representation of it in your ListView. Using this approach you get separation of concerns as you don't tie a graphical view of your Word object with it's textual toString method; so toString could continue to have different output (for example full information on Word fields with a word name and a description for debugging purposes).

    Customization of cells using graphic nodes

    Also, a cell factory is more flexible as you can apply various graphical nodes to create a visual representation of your data beyond just a straight text string (if you wish to do that). Any node (including layout panes comprising multiple nodes) can be added to a cell using setGraphic(node), usually also setting text to null with setText(null), though you can have both a graphic and text if you wish.

    As an demonstration of this approach, the example can be updated to define a custom list cell which sets a graphic.

    listview with graphic

    Replace the setCellFactory call with:

    listViewOfWords.setCellFactory(param -> new WordListCell());
    

    And add the following class:

    private static class WordListCell extends ListCell<Word> {
        private final Label title = new Label();
        private final Label detail = new Label();
        private final VBox layout = new VBox(title, detail);
    
        public WordListCell() {
            super();
            title.setStyle("-fx-font-size: 20px;");
        }
    
        @Override
        protected void updateItem(Word item, boolean empty) {
            super.updateItem(item, empty);
    
            setText(null);
    
            if (empty || item == null || item.getWord() == null) {
                title.setText(null);
                detail.setText(null);
                setGraphic(null);
            } else {
                title.setText(item.getWord());
                detail.setText(
                        item.getDefinition() != null
                                ? item.getDefinition()
                                : "Undefined"
                );
                setGraphic(layout);
            }
        }
    }
    

    Immutable objects (records) recommended unless dynamic field updates required

    Also, as an aside, I recommend making your Word objects immutable objects, by removing their setters (or using records in Java 16+). If you really need to modify the word objects themselves, then the best way to handle that is to have exposed observable properties in classes for the object fields.

    To convert the application above to use immutable objects, replace the Word class definition with a Word record definition.

    public record Word(String word, String definition) {}
    

    Then, whenever you access the fields in the records, don't use the get prefix. For example in the cell factory, as item is a Word, use item.word() rather than item.getWord() to get the word string from the Word.

    Dynamic updates

    Just adding and removing to the underlying observable items list will trigger updates to the view.

    If you also want your UI to update as the observable properties of your objects change, then you need to make your list cells aware of the changes to the associated items, by listening for changes to them (which is quite a bit more complex in this case, an extractor can help). Note that, the list containing the words is already observable and ListView will take care of handling changes to that list, but if you modified the word definition for instance within a displayed word object, then your list view wouldn't pick up changes to the definition without appropriate listener logic in the ListView cell factory (or, preferably, an extractor).

    Example of extractor definition for an ObservableList of the Word objects. For this to work, the Word class has been updated to use properties for its fields rather than simple types.

    ObservableList<Word> wordsList = FXCollections.observableArrayList(word ->
            new Observable[] {
                    word.wordProperty(),
                    word.definitionProperty()
            }
    );
    

    A full example demonstrating the usage of an extractor.

    In the example, the "Update second word" button has been pressed three times, to update the word. On each press, the current count of times the word has been updated is appended to the word. Without the extractor to notify of changes, the second word in the list would not be updated when the button is pressed.

    enter image description here

    import javafx.application.Application;
    import javafx.beans.Observable;
    import javafx.beans.property.SimpleStringProperty;
    import javafx.beans.property.StringProperty;
    import javafx.collections.*;
    import javafx.geometry.Insets;
    import javafx.scene.Scene;
    import javafx.scene.control.*;
    import javafx.scene.layout.Priority;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    
    public class CellFactoriesWithExtractor extends Application {
        private int nChanges = 0;
    
        @Override
        public void start(Stage stage) {
            ObservableList<Word> wordsList = FXCollections.observableArrayList(word ->
                    new Observable[] {
                            word.wordProperty(),
                            word.definitionProperty()
                    }
            );
    
            wordsList.add(new Word("First Word", "Definition of First Word"));
            wordsList.add(new Word("Second Word", "Definition of Second Word"));
            wordsList.add(new Word("Third Word", "Definition of Third Word"));
    
            ListView<Word> listViewOfWords = new ListView<>(wordsList);
            listViewOfWords.setCellFactory(param -> new ListCell<Word>() {
                @Override
                protected void updateItem(Word item, boolean empty) {
                    super.updateItem(item, empty);
    
                    if (empty || item == null || item.getWord() == null) {
                        setText(null);
                    } else {
                        setText(item.getWord());
                    }
                }
            });
    
            Button updateWord = new Button("Update second word");
            updateWord.setOnAction(event -> {
                ++nChanges;
                Word wordToChange = wordsList.get(1);
                wordToChange.setWord(wordToChange.getWord() + " " + nChanges);
            });
    
            VBox layout = new VBox(10,
                    updateWord,
                    listViewOfWords
            );
            layout.setPadding(new Insets(10));
            VBox.setVgrow(listViewOfWords, Priority.ALWAYS);
    
            stage.setScene(new Scene(layout));
            stage.show();
        }
    
        public static class Word {
            private final StringProperty word = new SimpleStringProperty();
            private final StringProperty definition = new SimpleStringProperty();
    
            public Word(String word, String definition) {
                this.word.setValue(word);
                this.definition.setValue(definition);
            }
    
            public String getWord() {
                return word.get();
            }
    
            public StringProperty wordProperty() {
                return word;
            }
    
            public void setWord(String word) {
                this.word.set(word);
            }
    
            public String getDefinition() {
                return definition.get();
            }
    
            public StringProperty definitionProperty() {
                return definition;
            }
    
            public void setDefinition(String definition) {
                this.definition.set(definition);
            }
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    }