Jonatan Stenbacka
Jonatan Stenbacka

Reputation: 1864

TableCell.setText(String) doesn't set the data value associated with the cell

In my particular case I have a custom implementation of a TableCell that contains a Button. This button invokes a method that returns a String to be displayed instead of the button. The visual change is done by setting the graphic in the cell to null and setting the text to the String, using TableCell.setText(String).

What I've realized - and worked around so far, is that TableCell.setText(String) doesn't change the data value associated with the cell in the TableView. It just changes the visual representation of the cell. The underlying data structure is in my case a ObservableList<String> that represents a row, and each element in the list is, of course, cell data.

My current solution is to set the underlying value doing this:

getTableView().getItems().get(getIndex()).set(getTableView().getColumns().indexOf(getTableColumn()), "Value");

And this works fine. But I mean, the code is barely readable.

It seems like the data in the TableView and the TableCell are entirely separated, since you need to access the TableView to set the underlying data for a cell. There is a TableCell.getItem() to get the data value, but there's no setItem(String) method to set it.

I hope I explained my issue good enough.

Is there a better and prettier way to do this? Why doesn't just `TableCell.setText(String) change the data value as well?

Edit: I'll explain what I am trying to implement:

I basically have a table where one column contains a button that will load some arbitrary data to the column when pressed. Once the data has been loaded, the button is removed from the column and the data is displayed instead. That is basically it. This works fine unless the table is sorted/filtered. Here's a MCVE of my implementation:

import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;

public class MCVE extends Application {
    private final BooleanProperty countLoading = new SimpleBooleanProperty(this, "countLoading", false);

    @Override
    public void start(Stage stage) {
        int numOfCols = 3;

        ObservableList<ObservableList<String>> tableData = FXCollections.observableArrayList();

        // Generate dummy data.
        for (int i = 0; i < 100; i++) {
            ObservableList<String> row = FXCollections.observableArrayList();

            for (int j = 0; j < numOfCols; j++)
                row.add("Row" + i + "Col" + j);

            tableData.add(row);
        }

        TableView<ObservableList<String>> table = new TableView<ObservableList<String>>();

        // Add columns to the table.
        for (int i = 0; i < numOfCols; i++) {
            if (i == 2) {
                final int j = i;
                table.getColumns().add(addColumn(i, "Column " + i, e -> new QueueCountCell(j, countLoading)));
            } else {
                table.getColumns().add(addColumn(i, "Column " + i, null));
            }
        }

        table.getItems().addAll(tableData);

        Scene scene = new Scene(table);

        stage.setScene(scene);
        stage.show();
    }

    /**
     * Returns a simple column.
     */
    private TableColumn<ObservableList<String>, String> addColumn(int index, String name,
            Callback<TableColumn<ObservableList<String>, String>, TableCell<ObservableList<String>, String>> callback) {
        TableColumn<ObservableList<String>, String> col = new TableColumn<ObservableList<String>, String>(name);
        col.setCellValueFactory(e -> new SimpleStringProperty(e.getValue().get(index)));

        if (callback != null) {
            col.setCellFactory(callback);
        }
        return col;
    }

    public static void main(String[] args) {
        launch();
    }

    class QueueCountCell extends TableCell<ObservableList<String>, String> {
        private final Button loadButton = new Button("Load");

        public QueueCountCell(int colIndex, BooleanProperty countLoading) {

            countLoading.addListener((obs, oldValue, newValue) -> {
                if (newValue) {
                    loadButton.setDisable(true);
                } else {
                    if (getIndex() >= 0 && getIndex() < this.getTableView().getItems().size()) {
                        loadButton.setDisable(false);
                    }
                }
            });

            final Timeline timeline = new Timeline(new KeyFrame(Duration.ZERO, e -> setText("Loading .")),
                    new KeyFrame(Duration.millis(500), e -> setText("Loading . .")),
                    new KeyFrame(Duration.millis(1000), e -> setText("Loading . . .")),
                    new KeyFrame(Duration.millis(1500)));

            timeline.setCycleCount(Animation.INDEFINITE);

            loadButton.setOnAction(e -> {
                new Thread(new Task<String>() {
                    @Override
                    public String call() throws InterruptedException {
                        // Simlute task working.
                        Thread.sleep(3000);
                        return "5";
                    }

                    @Override
                    public void running() {
                        setGraphic(null);
                        timeline.play();
                        countLoading.set(true);
                    }

                    @Override
                    public void succeeded() {
                        timeline.stop();

                        countLoading.set(false);

                        setText(getValue());
                    }

                    @Override
                    public void failed() {
                        timeline.stop();

                        countLoading.set(false);

                        setGraphic(loadButton);
                        setText(null);

                        this.getException().printStackTrace();
                    }
                }).start();
            });
        }

        @Override
        public final void updateItem(String item, boolean empty) {
            super.updateItem(item, empty);

            if (item == null || empty) {
                setGraphic(null);
            } else {
                setGraphic(loadButton);
            }
        }
    }
}

Upvotes: 0

Views: 2196

Answers (1)

James_D
James_D

Reputation: 209694

Background: MVC

Much of JavaFX is designed around a Model-View-Controller (MVC) pattern. This is a loosely-defined pattern with many variants, but the basic idea is that there are three components:

Model: an object (or objects) that represent the data. The Model knows nothing about how the data is presented to the user.

View: an object that presents the data to the user. The view does not do any logical processing or store the data; it just knows how to convert the data to some kind of presentation for the user.

Controller: an object that modifies the data in the model, often (though not exclusively) in response to user input.

There are several variants of this pattern, including MVP, MVVM, supervising controller, passive view, and others, but the unifying theme in all of them is that there is a separation between the view, which simply presents data but does not otherwise "know" what the data is, and the model, which stores the state (data) but knows nothing about how it might be presented. The usually-cited motivation for this is the ability to have multiple views of the same data which have no need to refer to each other.

In the "classical" implementation of this, the view "observes" the model via some kind of subscriber-notification pattern (e.g. an observer pattern). So the view will register with the model to be notified of changes to the data, and will repaint accordingly. Often, since the controller relies on event listeners on the components in the view, the controller and view are tightly coupled; however there is always clear separation between the view and the model.

The best reference I know for learning more about this is Martin Fowler.


Background: JavaFX Virtualized Controls

JavaFX has a set of "virtualized controls", which includes ListView, TableView, TreeView, and TreeTableView. These controls are designed to be able to present large quantities of data to the user in an efficient manner. The key observation behind the design is that data is relatively inexpensive to store in memory, whereas the UI components (which typically have hundreds of properties) consume a relatively large amount of memory and are computationally expensive (e.g. to perform layout, apply style, etc). Moreover, in a table (for example) with a large amount of backing data, only a small proportion of those data are visible at any time, and there is no real need for UI controls for the remaining data.

Virtualized controls in JavaFX employ a cell rendering mechanism, in which "cells" are created only for the visible data. As the user scrolls around the table, the cells are reused to display data that was previously not visible. This allows the creation of a relatively small number of cells even for extremely large data sets: the number of (expensive) cells created is basically constant with respect to the size of the data. The Cell class defines an updateItem(...) method that is invoked when the cell is reused to present different data. All this is possible because the design is built on MVC principles: the cell is the view, and the data is stored in the model. The documentation for Cell has details on this.

Note that this means that you must not use the cell for any kind of data storage, because when the user scrolls in the control, that state will be lost. General MVC principles dictate that this is what you should do anyway.


The code you posted doesn't work correctly, as it violates these rules. In particular, if you click one of the "Load" buttons, and then scroll before the loading is complete, the cell that is performing the loading will now be referring to the wrong item in the model, and you end up with a corrupted view. The following series of screenshots occurred from pressing "Load", taking a screenshot, scrolling, waiting for the load to complete, and taking another screenshot. Note the value appears to have changed for an item that is different to the item for which "Load" was pressed.

enter image description here

enter image description here

To fix this, you have to have a model that stores all of the state of the application: you cannot store any state in the cells. It is a general truth in JavaFX that in order to make the UI code elegant, you should start with a well-defined data model. In particular, since your view (cell) changes when the data is in the process of loading, the "loading state" needs to be part of the model. So each item in each row in your table is represented by two pieces of data: the actual data value (strings in your case), and the "loading state" of the data.

So I would start with a class that represents that. You could just use a String for the data, or you could make it more general by making it a generic class. I'll do the latter. A good implementation will also keep the two states consistent: if the data is null and we have not explicitly stated it is loading, we consider it not loaded; if the data is non-null, we consider it loaded. So we have:

import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;

public class LazyLoadingData<T> {

    public enum LoadingState { NOT_LOADED, LOADING, LOADED }

    private final ObjectProperty<T> data = new SimpleObjectProperty<>(null);
    private final ReadOnlyObjectWrapper<LoadingState> loadingState 
        = new ReadOnlyObjectWrapper<>(LoadingState.NOT_LOADED);

    public LazyLoadingData(T data) {

        // listeners to keep properties consistent with each other:

        this.data.addListener((obs, oldData, newData) -> {
            if (newData == null) {
                loadingState.set(LoadingState.NOT_LOADED);
            } else {
                loadingState.set(LoadingState.LOADED);
            }
        });
        this.loadingState.addListener((obs, oldState, newState) -> {
            if (newState != LoadingState.LOADED) {
                this.data.set(null);
            }
        });

        this.data.set(data);
    }

    public LazyLoadingData() {
        this(null);
    }

    public void startLoading() {
        loadingState.set(LoadingState.LOADING);
    }

    public final ObjectProperty<T> dataProperty() {
        return this.data;
    }


    public final T getData() {
        return this.dataProperty().get();
    }


    public final void setData(final T data) {
        this.dataProperty().set(data);
    }


    public final ReadOnlyObjectProperty<LoadingState> loadingStateProperty() {
        return this.loadingState.getReadOnlyProperty();
    }


    public final LazyLoadingData.LoadingState getLoadingState() {
        return this.loadingStateProperty().get();
    }

}

The model here will just be an ObservableList<List<LazyLoadingData<String>>>, so each cell is a LazyLoadingData<String> and each row is a list of them.

To make this properly MVC, let's have a separate controller class which has a way of updating data in the model:

import java.util.List;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

import javafx.concurrent.Task;

public class LazyLoadingDataController {
    // data model:
    private final List<List<LazyLoadingData<String>>> data ;

    private final Random rng = new Random();

    private final Executor exec = Executors.newCachedThreadPool(r -> {
        Thread t = new Thread(r);
        t.setDaemon(true);
        return t ;
    });

    public LazyLoadingDataController(List<List<LazyLoadingData<String>>> data) {
        this.data = data ;
    }

    public void loadData(int column, int row) {
        Task<String> loader = new Task<String>() {
            @Override
            protected String call() throws InterruptedException {
                int value = rng.nextInt(1000);
                Thread.sleep(3000);
                return "Data: "+value;
            }
        };
        data.get(row).get(column).startLoading();
        loader.setOnSucceeded(e -> data.get(row).get(column).setData(loader.getValue()));
        exec.execute(loader);
    }
}

Now our cell implementation is pretty straightforward. The only tricky part is that each item has two properties, and we actually need to observe both of those properties and update the cell if either of them changes. We need to be careful to remove listener from items the cell is no longer displaying. So the cell looks like:

import java.util.List;

import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.beans.value.ChangeListener;
import javafx.scene.control.Button;
import javafx.scene.control.TableCell;
import javafx.util.Duration;

public class LazyLoadingDataCell<T> 
    extends TableCell<List<LazyLoadingData<T>>, LazyLoadingData<T>>{

    private final Button loadButton = new Button("Load");


    private final Timeline loadingAnimation = new Timeline(
            new KeyFrame(Duration.ZERO, e -> setText("Loading")),
            new KeyFrame(Duration.millis(500), e -> setText("Loading.")),
            new KeyFrame(Duration.millis(1000), e -> setText("Loading..")),
            new KeyFrame(Duration.millis(1500), e -> setText("Loading..."))
    );

    public LazyLoadingDataCell(LazyLoadingDataController controller, int columnIndex) {
        loadingAnimation.setCycleCount(Animation.INDEFINITE);
        loadButton.setOnAction(e -> controller.loadData(columnIndex, getIndex()));


        // listener for observing either the dataProperty()
        // or the loadingStateProperty() of the current item:
        ChangeListener<Object> listener = (obs, oldState, newState) -> doUpdate();

        // when the item changes, remove and add the listener:
        itemProperty().addListener((obs, oldItem, newItem) -> {
            if (oldItem != null) {
                oldItem.dataProperty().removeListener(listener);
                oldItem.loadingStateProperty().removeListener(listener);
            }
            if (newItem != null) {
                newItem.dataProperty().addListener(listener);
                newItem.loadingStateProperty().addListener(listener);
            }
            doUpdate();
        });

    }

    @Override
    protected void updateItem(LazyLoadingData<T> item, boolean empty) {
        super.updateItem(item, empty);
        doUpdate();
    }

    private void doUpdate() {
        if (isEmpty() || getItem() == null) {
            setText(null);
            setGraphic(null);
        } else {
            LazyLoadingData.LoadingState state = getItem().getLoadingState();
            if (state == LazyLoadingData.LoadingState.NOT_LOADED) {
                loadingAnimation.stop();
                setText(null);
                setGraphic(loadButton);
            } else if (state == LazyLoadingData.LoadingState.LOADING) {
                setGraphic(null);
                loadingAnimation.play();
            } else if (state == LazyLoadingData.LoadingState.LOADED) {
                loadingAnimation.stop();
                setGraphic(null);
                setText(getItem().getData().toString());
            }           
        }
    }

}

Note how

  1. The cell contains no state. The fields in the cell are entirely related to the display of data (a button and an animation).
  2. The action of the button doesn't (directly) change anything in the view. It simply tells the controller to update the data in the model. Because the cell (view) is observing the model, when the model changes, the view updates.
  3. The model also changes independently of user action, when the task in the controller completes. Because the view is observing the model for changes, it updates automatically.

Finally an example using this. There is not much unexpected here, we just create a model (ObservableList of List<LazyLoadingData<String>>), create a controller, and then a table with some columns.

import java.util.List;

import javafx.application.Application;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.stage.Stage;

public class LazyLoadingTableExample extends Application {

    private final int numCols = 3 ;
    private final int numRows = 100 ;

    @Override
    public void start(Stage primaryStage) {

        TableView<List<LazyLoadingData<String>>> table = new TableView<>();

        // data model:
        ObservableList<List<LazyLoadingData<String>>> data 
            = FXCollections.observableArrayList();

        table.setItems(data);

        LazyLoadingDataController controller = new LazyLoadingDataController(data);

        // build data:
        for (int i = 0; i < numRows; i++) {

            ObservableList<LazyLoadingData<String>> row 
                = FXCollections.observableArrayList();

            for (int j = 0 ; j < numCols - 1 ; j++) {
                row.add(new LazyLoadingData<>("Cell ["+j+", "+i+"]"));
            }
            row.add(new LazyLoadingData<>());

            data.add(row);
        }

        for (int i = 0 ; i < numCols ; i++) {
            table.getColumns().add(createColumn(controller, i));
        }

        Scene scene = new Scene(table, 600, 600);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    private TableColumn<List<LazyLoadingData<String>>,LazyLoadingData<String>> 
        createColumn(LazyLoadingDataController controller, int columnIndex) {

        TableColumn<List<LazyLoadingData<String>>,LazyLoadingData<String>> col 
            = new TableColumn<>("Column "+columnIndex);

        col.setCellValueFactory(cellData -> 
            new SimpleObjectProperty<>(cellData.getValue().get(columnIndex)));

        col.setCellFactory(tc -> 
            new LazyLoadingDataCell<>(controller, columnIndex));

        return col ;
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Upvotes: 3

Related Questions