How do you use a JavaFX TableView with java records?
Asked Answered
C

1

4

Records is a new feature with Java 16. Defined in JEP 395: Records.

Suppose you have a record such as this one.

public record Person(String last, String first, int age)
{
    public Person()
    {
        this("", "", 0);
    }
}

This is a final class. It automatically generates the getter methods first(), last(), and age().

Now here is a TableView in JavaFX.

/**************************************************
*   Author: Morrison
*   Date:  10 Nov 202021
**************************************************/

import javafx.application.Application;
import javafx.application.Platform;
import javafx.stage.Stage;
import javafx.scene.Scene;
import javafx.scene.layout.BorderPane;
import javafx.scene.control.TableView;
import javafx.scene.control.TableColumn;
import javafx.scene.control.cell.PropertyValueFactory;

public class TV extends Application
{
    public TV()
    {
    }

    @Override
    public void init()
    {
    }

    @Override
    public void start(Stage primary)
    {
        BorderPane root = new BorderPane();
        TableView<Person> table = new TableView<>();
        root.setCenter(table);

        TableColumn<Person, String> lastColumn = new TableColumn<>("Last");
        lastColumn.setCellValueFactory(new PropertyValueFactory<Person,
            String>("last"));

        TableColumn<Person, String> firstColumn = new TableColumn<>("First");
        firstColumn.setCellValueFactory(new PropertyValueFactory<Person,
            String>("first"));

        TableColumn<Person, Integer> ageColumn = new TableColumn<>("Age");
        ageColumn.setCellValueFactory(new PropertyValueFactory<Person,
            Integer>("age"));

        table.getColumns().add(lastColumn);
        table.getColumns().add(firstColumn);
        table.getColumns().add(ageColumn);
        table.getItems().add(new Person("Smith", "Justin", 41));
        table.getItems().add(new Person("Smith", "Sheila", 42));
        table.getItems().add(new Person("Morrison", "Paul", 58));
        table.getItems().add(new Person("Tyx", "Kylee", 40));
        table.getItems().add(new Person("Lincoln", "Abraham", 200));
        Scene s = new Scene(root, 500, 500);
        primary.setTitle("TableView Demo");
        primary.setScene(s);
        primary.show();
    }

    @Override
    public void stop()
    {
    }
}

The problem lies here.

TableColumn<Person, String> lastColumn = new TableColumn<>("Last");
        lastColumn.setCellValueFactory(new PropertyValueFactory<Person,
            String>("last"));
        

The TableView is expecting methods of name getFirst, getLast, and getAge to do its work. Is there a workaround other than doing this horrible thing?

public record Person(String last, String first, int age)
{
    public Person()
    {
        this("", "", 0);
    }
    public String getLast()
    {
        return last;
    }
    public String getFirst()
    {
        return first;
    }
    public int getAge()
    {
        return age;
    }
}
Cattalo answered 30/11, 2021 at 20:21 Comment(1)
This is basic functionality; check any reasonable tutorial on TableView written using Java 8 or later. Just implement the cellValueFactory for each column.Historical
V
12

Solution

Use a lambda cell value factory instead of a PropertyValueFactory.

For some explanation of the difference between the two, see:

Why this works

The issue, as you note, is that record accessors don't follow standard JavaBean property naming conventions, which is what the PropertyValueFactory expects. For example, a record uses first() rather than getFirst() as an accessor, which makes it incompatible with the PropertyValueFactory.

Should you apply a workaround of "doing this horrible thing" of adding additional get methods to the record, just so you can make use of a PropertyValueFactory to interface with a TableView? -> Absolutely not, there is a better way :-)

What is needed to fix it is to define your own custom cell factory instead of using a PropertyValueFactory.

This is best done using a lambda (or a custom class for really complicated cell value factories). Using a lambda cell factory has advantages of type safety and compile-time checks that a PropertyValueFactory does not have (see the prior referenced answer for more information).

Examples for defining lambdas instead of PropertyValueFactories

SimpleStringProperty

An example usage of a lambda cell factory definition for a record String field:

TableColumn<Person, String> lastColumn = new TableColumn<>("Last");
lastColumn.setCellValueFactory(
        p -> new SimpleStringProperty(p.getValue().last())
);

We can be explicit about the type of that p variable, for clarity: a reference to a TableColumn.CellDataFeatures object.

lastColumn.setCellValueFactory(
        ( TableColumn.CellDataFeatures < Person, String > p ) -> new SimpleStringProperty (p.getValue().last())
);

It is necessary to wrap the record field in a property or binding as the cell value factory implementation expects an observable value as input.

ReadOnlyStringWrapper

You may be able to use a ReadOnlyStringWrapper instead of SimpleStringProperty, like this:

lastColumn.setCellValueFactory(
        p -> new ReadOnlyStringWrapper(p.getValue().last()).getReadOnlyProperty()
);

In a quick test that worked. For immutable records, it might be a better approach, but I haven't thoroughly tested it to be sure, so, to be safe, I have used simple read-write properties throughout the rest of this example.

For other types of data

Similarly, for an int field:

TableColumn<Person, Integer> ageColumn = new TableColumn<>("Age");
ageColumn.setCellValueFactory(
        p -> new SimpleIntegerProperty(p.getValue().age()).asObject()
);

The need to put asObject() on the end of the lambda is explained here, in case you are curious (but it is just a weird aspect of the usage of java generics by the JavaFX framework, which isn't worth spending a lot of time investigating, just add the asObject() call and move on IMO):

Similarly, if your record contains other objects (or other records), then you can define a cell value factory for SimpleObjectProperty<MyType>.

Note: This approach for lambda cell factory definition and the patterns defined above also works for standard (non-record) classes. There is nothing special here for records. The only thing to be aware of is to take care to use the correct accessor name after the getValue() call in the lambda. For example, use first() rather than the standard getFirst() call which you would usually define on a class to support the standard Java Bean naming pattern. The really great thing about this is that, if you define the accessor name wrong, you will get a compiler error and know the exact issue and location before you even attempt to run the code.

Example Code

Full executable example based on the code in the question.

example

Person.java

public record Person(String last, String first, int age) {}

RecordTableViewer.java

import javafx.application.Application;
import javafx.beans.property.*;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.stage.Stage;

public class RecordTableViewer extends Application {
    @Override
    public void start(Stage stage) {
        TableView<Person> table = new TableView<>();

        TableColumn<Person, String> lastColumn = new TableColumn<>("Last");
        lastColumn.setCellValueFactory(
                p -> new SimpleStringProperty(p.getValue().last())
        );

        TableColumn<Person, String> firstColumn = new TableColumn<>("First");
        firstColumn.setCellValueFactory(
                p -> new SimpleStringProperty(p.getValue().first())
        );

        TableColumn<Person, Integer> ageColumn = new TableColumn<>("Age");
        ageColumn.setCellValueFactory(
                p -> new SimpleIntegerProperty(p.getValue().age()).asObject()
        );

        //noinspection unchecked
        table.getColumns().addAll(lastColumn, firstColumn, ageColumn);

        table.getItems().addAll(
                new Person("Smith", "Justin", 41),
                new Person("Smith", "Sheila", 42),
                new Person("Morrison", "Paul", 58),
                new Person("Tyx", "Kylee", 40),
                new Person("Lincoln", "Abraham", 200)
        );

        stage.setScene(new Scene(table, 200, 200));
        stage.show();
    }
}

Should PropertyValueFactory be "fixed" for records?

Record field accessors follow their own access naming convention, fieldname(), just like Java Beans do, getFieldname().

Potentially an enhancement request could be raised for PropertyValueFactory to change its implementation in the core framework so that it can also recognize the record accessor naming standard.

However, I do not believe that updating PropertyValueFactory to recognize record field accessors would be a good idea.

A better solution is not to update PropertyValueFactory for record support and to only allow the typesafe custom cell value approach which is outlined in this answer.

I believe this because of the explanation provided by kleopatra in comments:

a custom valueFactory is definitely the way to go :) Even if it might appear attractive to some to implement an equivalent to PropertyValueFactory - but: that would be a bad idea, looking at the sheer number of questions of type "data not showing in table" due to typos ..

Violent answered 30/11, 2021 at 20:34 Comment(6)
cell -> cell.getValue()::first etcetera if I am not erring. Please add some code, as otherwise your answer would be best given as comment.Hypo
@Joop It'd actually be something like data -> new SimpleStringProperty(data.getValue().first()), because it needs to return an ObservableValue.Compositor
Edited to add a full example and provide sample lambda definitions for different record field types.Violent
a custom valueFactory is definitely the way to go :) Even if it might appear attractive to some to implement an equivalent to PropertyValueFactory - but: that would be a bad idea, looking at the sheer number of questions of type "data not showing in table" due to typos ..Nullification
This is pretty cool, thank you for this answer. I love that we can simplify programmatically showing TableView using the new Record class. Though it would be even better if we could just pass a record class into the TableView and it auto-generates the columns, then just add the records via the getItems() method. Perhaps such a class could be written utilizing reflection to extract the relevant details from the Record to autogen the columns ...Zuleika
@MichaelSims yes, seems feasible. If done, hopefully such an approach would avoid the issues with PropertyValueFactory, which causes many issues for beginners using TableView.Violent

© 2022 - 2024 — McMap. All rights reserved.