1

A relative Java newbie question.

I'm trying set a TableRow's background colour based on whether or not it's selected and/or whether or not a boolean value in the data model is true.

I've found ways of doing each but not both together in the same setRowFactory.

What I would like to end up with is this (albeit with horrible colours for example purposes only!):

enter image description here

How would I go about achieving that?

This is what I found wrt changing row colour based on selection. It's adapted from user James_D's answer here https://community.oracle.com/thread/3528543.

final ObservableSet<Integer> selectedRowIndexes = FXCollections.observableSet();

table.getSelectionModel().getSelectedCells().addListener((Change<? extends TablePosition> change) -> {  
    selectedRowIndexes.clear();
    selectedRowIndexes.add( (table.getSelectionModel().getSelectedCells().get(0)).getRow() );
});

table.setRowFactory(tv -> {
    TableRow<TestModel> row = new TableRow<>();
    BooleanBinding selected = Bindings.createBooleanBinding(() ->  
        selectedRowIndexes.contains(new Integer(row.getIndex())), row.indexProperty(), selectedRowIndexes);
    row.styleProperty().bind(Bindings.when(selected)
        .then("-fx-background-color:  green;")
        .otherwise(""));
    return row;
});

And this is what I found wrt changing row colour based on a cell value. It's adapted from user kleopatra's answer here TreeTableView : setting a row not editable.

table.setRowFactory(tv -> {
    TableRow<TestModel> row = new TableRow<TestModel>() {
        @Override
        public void updateItem(TestModel testmodel, boolean empty) {
            super.updateItem(testmodel, empty);
            boolean locked = false;
            if ( getItem() != null ) {
                locked = getItem().lockedProperty().get();
                setEditable( ! locked);
            }
            if (!isEmpty() && locked ) {
                setStyle("-fx-background-color: red;");
            }else{
                setStyle(null);
            }
        }
    };
    return row;
});

However, I've ended up with two row factories and haven't been able to figure out how merge them into one.

If it helps, here is the MVCE I've been playing with. It has the two row factories. I haven't included my (many!) attempts to merge them as none worked.

I'm using JavaFX8 (JDK1.8.0_181), NetBeans 8.2 and Scene Builder 8.3.

package test31;

import java.util.Arrays;
import javafx.application.Application;
import static javafx.application.Application.launch;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener.Change;
import javafx.collections.ObservableList;
import javafx.collections.ObservableSet;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TablePosition;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.util.converter.BooleanStringConverter;

public class Test31 extends Application {

    private Parent createContent() {

        TableView<TestModel> table = new TableView<>();

        ObservableList<TestModel> olTestModel = FXCollections.observableArrayList(testmodel -> new Observable[] {});
        olTestModel.add(new TestModel("1", true));
        olTestModel.add(new TestModel("2", false));
        olTestModel.add(new TestModel("3", false));
        olTestModel.add(new TestModel("4", true));
        olTestModel.add(new TestModel("5", false));

        TableColumn<TestModel, String> colText = new TableColumn<>("textfield");
        colText.setCellValueFactory(cb -> cb.getValue().textFieldProperty());
        colText.setCellFactory(TextFieldTableCell.forTableColumn());

        TableColumn<TestModel, Boolean> colBoolean = new TableColumn<>("locked");
        colBoolean.setCellValueFactory(cb -> cb.getValue().lockedProperty());
        colBoolean.setCellFactory(TextFieldTableCell.forTableColumn(new BooleanStringConverter()));

        table.getSelectionModel().setCellSelectionEnabled(true);
        table.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
        table.setEditable(true);
        table.getColumns().addAll(Arrays.asList(colText, colBoolean));
        table.setItems(olTestModel);

        //****************************************************************************************
        //First row factory:  Set background colour based on whether or not the row is selected
        final ObservableSet<Integer> selectedRowIndexes = FXCollections.observableSet();

        table.getSelectionModel().getSelectedCells().addListener((Change<? extends TablePosition> change) -> {  
            selectedRowIndexes.clear();
            selectedRowIndexes.add( (table.getSelectionModel().getSelectedCells().get(0)).getRow() );
        });

        table.setRowFactory(tv -> {
            TableRow<TestModel> row = new TableRow<>();
            BooleanBinding selected = Bindings.createBooleanBinding(() ->  
                selectedRowIndexes.contains(new Integer(row.getIndex())), row.indexProperty(), selectedRowIndexes);
            row.styleProperty().bind(Bindings.when(selected)
                .then("-fx-background-color:  green;")
                .otherwise(""));
            return row;
        });

        //****************************************************************************************
        //Second row factory:  Set background colour based on the value of a boolean property
        table.setRowFactory(tv -> {
            TableRow<TestModel> row = new TableRow<TestModel>() {
                @Override
                public void updateItem(TestModel testmodel, boolean empty) {
                    super.updateItem(testmodel, empty);
                    boolean locked = false;
                    if ( getItem() != null ) {
                        locked = getItem().lockedProperty().get();
                        setEditable( ! locked);
                    }
                    if (!isEmpty() && locked ) {
                        setStyle("-fx-background-color: red;");
                    }else{
                        setStyle(null);
                    }
                }
            };
            return row;
        });

        BorderPane content = new BorderPane(table);

        return content;

    }

    public class TestModel {

        private StringProperty textField;
        private BooleanProperty locked;

        public TestModel() {
            this("", false);
        }

        public TestModel(
            String textField,
            boolean locked
        ) {
            this.textField = new SimpleStringProperty(textField);
            this.locked = new SimpleBooleanProperty(locked);
        }

        public String getTextField() {
            return textField.get().trim();
        }

        public void setTextField(String textField) {
            this.textField.set(textField);
        }

        public StringProperty textFieldProperty() {
            return textField;
        }

        public boolean getLocked() {
            return locked.get();
        }

        public void setLocked(boolean locked) {
            this.locked.set(locked);
        }

        public BooleanProperty lockedProperty() {
            return locked;
        }

    }

    @Override
    public void start(Stage stage) throws Exception {
        stage.setScene(new Scene(createContent()));
        stage.setTitle("Test");
        stage.setWidth(500);
        stage.show();
    }

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

}
GreenZebra
  • 362
  • 6
  • 14

1 Answers1

1

There are a couple ways you can do this. Here's an example using external CSS and pseudo-classes:

Main.java

import java.util.stream.Collectors;
import java.util.stream.IntStream;
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class Main extends Application {

  @Override
  public void start(Stage primaryStage) {
    TableView<Item> table = new TableView<>(createDummyData(100));
    table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
    table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);

    table.setRowFactory(t -> new ItemTableRow());

    TableColumn<Item, String> nameCol = new TableColumn<>("Name");
    nameCol.setCellValueFactory(features -> features.getValue().nameProperty());
    table.getColumns().add(nameCol);

    TableColumn<Item, Boolean> validCol = new TableColumn<>("Valid");
    validCol.setCellValueFactory(features -> features.getValue().validProperty());
    table.getColumns().add(validCol);

    primaryStage.setScene(new Scene(new StackPane(table), 800, 600));
    primaryStage.getScene().getStylesheets().add(getClass().getResource("Main.css").toExternalForm());
    primaryStage.setTitle("JavaFX Application");
    primaryStage.show();
  }


  private ObservableList<Item> createDummyData(int count) {
    return IntStream.rangeClosed(1, count)
        .mapToObj(i -> "Item #" + i)
        .map(name -> new Item(name, Math.random() >= 0.5))
        .collect(Collectors.toCollection(FXCollections::observableArrayList));
  }

}

ItemTableRow.java

import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.css.PseudoClass;
import javafx.scene.control.TableRow;

public class ItemTableRow extends TableRow<Item> {

  private static final PseudoClass VALID = PseudoClass.getPseudoClass("valid");

  private final ChangeListener<Boolean> listener = (obs, oldVal, newVal) -> updateValidPseudoClass(newVal);
  private final WeakChangeListener<Boolean> weakListener = new WeakChangeListener<>(listener);

  public ItemTableRow() {
    getStyleClass().add("item-table-row");
  }

  @Override
  protected void updateItem(Item item, boolean empty) {
    Item oldItem = getItem();
    if (oldItem != null) {
      oldItem.validProperty().removeListener(weakListener);
    }
    super.updateItem(item, empty);
    if (empty || item == null) {
      updateValidPseudoClass(false);
    } else {
      item.validProperty().addListener(weakListener);
      updateValidPseudoClass(item.isValid());
    }
  }

  private void updateValidPseudoClass(boolean active) {
    pseudoClassStateChanged(VALID, active);
  }

}

Item.java

import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class Item {

  private final StringProperty name = new SimpleStringProperty(this, "name");
  public final void setName(String name) { this.name.set(name); }
  public final String getName() { return name.get(); }
  public final StringProperty nameProperty() { return name; }

  private final BooleanProperty valid = new SimpleBooleanProperty(this, "valid");
  public final void setValid(boolean valid) { this.valid.set(valid); }
  public final boolean isValid() { return valid.get(); }
  public final BooleanProperty validProperty() { return valid; }

  public Item() {}

  public Item(String name, boolean valid) {
    setName(name);
    setValid(valid);
  }

}

Main.css

.item-table-row:selected {
    -fx-background-color: -fx-control-inner-background, green;
}

.item-table-row:valid {
    -fx-background-color: -fx-control-inner-background, yellow;
}

.item-table-row:valid:selected {
    -fx-background-color: -fx-control-inner-background, red;
}

If you prefer to only use code, change ItemTableRow to this (and remove getStylesheets().add(...) from Main):

import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.scene.control.TableRow;

public class ItemTableRow extends TableRow<Item> {

  private final InvalidationListener listener = observable -> updateStyle();
  private final WeakInvalidationListener weakListener = new WeakInvalidationListener(listener);

  public ItemTableRow() {
    getStyleClass().add("item-table-row");
    selectedProperty().addListener(listener); // could also override updateSelected
  }

  @Override
  protected void updateItem(Item item, boolean empty) {
    Item oldItem = getItem();
    if (oldItem != null) {
      oldItem.validProperty().removeListener(weakListener);
    }
    super.updateItem(item, empty);
    if (item != null) {
      item.validProperty().addListener(weakListener);
    }
    updateStyle();
  }

  private void updateStyle() {
    final Item item = getItem();
    if (item == null || (!isSelected() && !item.isValid())) {
      setStyle(null);
    } else if (isSelected() && item.isValid()) {
      setStyle("-fx-background-color: -fx-control-inner-background, red;");
    } else if (isSelected()) {
      setStyle("-fx-background-color: -fx-control-inner-background, green;");
    } else if (item.isValid()) {
      setStyle("-fx-background-color: -fx-control-inner-background, yellow;");
    } else {
      // I don't think this branch is possible, but not 100% sure
      throw new AssertionError("Shouldn't be here?");
    }
  }

}

The -fx-control-inner-background value is defined in modena.css (the default stylesheet for JavaFX 8+). It gives the TableRow that little bit of color padding.

Slaw
  • 37,820
  • 8
  • 53
  • 80
  • Hello again, Slaw. Thank you so much, I could never have figured that out! Q: In the non-CSS `ItemTableRow`, what sets `isSelected()`? Is it the `InvalidationListener`? I haven't explored them (or the 'weak' version) at all but will do some reading and try and understand how the code works. – GreenZebra Oct 19 '18 at 05:40
  • 1
    @GreenZebra The `isSelected()` method queries the `selected` property of the `TableRow`. This property is updated automatically by the `TableView` implementation; you don't need to worry about setting it, just listening to it for changes. – Slaw Oct 19 '18 at 05:47
  • 1
    @GreenZebra And weak listeners are mechanisms to avoid memory leaks. Let's say you "throw away" the `TableView` without clearing the `items` list (thus the listeners are never removed). The non-weak listener would have a strong reference to the `TableRow`, which means the `valid` property has a strong reference, which means the `Item` has a strong reference, which means the `TableRow` is held in memory for as long as the `Item` exists. A weak listener prevents this from happening. Note that you **must** keep a strong reference to the non-weak listener lest it is garbage collected too soon. – Slaw Oct 19 '18 at 05:54
  • Thanks for the explanation re weak listeners. I understand what you're saying and will do some reading to understand a bit more about them and how they work. Thanks also for the answer re `isSelected()`. The penny just dropped that `ItemTableRow` extends `TableRow` ergo it inherits the `selected` property. Sigh. One day I'll get my mind around all of this! :-) In the meantime, I have few weak listeners to add to my app ... – GreenZebra Oct 19 '18 at 06:17
  • the pseudoClass approach is spreading - nice :) Personally, I don't like listening to properties (neither item-related nor cell-related) in cells: it's already a ill-defined complex mixture. An alternative is to make sure the items list fires updates when an item property changed (via an extractor) and override the row's indexChanged to toggle cell state. – kleopatra Oct 19 '18 at 07:58
  • If you override `updateItem`, I recommend using this method instead of a listener to add/remove the listener to the property: add `Item oldVal = getItem()` before the call to `super.updateItem`. This reduces the amount of objects involved and doesn't react to the same event in 2 different ways. – fabian Oct 19 '18 at 09:09
  • @kleopatra Yes, I prefer the `PseudoClass` approach because I find it easier to modify a CSS file for such things. And it keeps the Java code cleaner. I find it strange that `updateIndex` is called when using an extractor but `updateItem` is not. But I just tried it and it works. Is this documented anywhere? – Slaw Oct 19 '18 at 09:10
  • @fabian Huh, I just assumed that the old `item` was not accessible inside the `updateItem` method. Makes perfect sense that it would be accessible before the call to `super`, though. I'll make the changes. – Slaw Oct 19 '18 at 09:13
  • no, not documented - it's the leftover of a bug that has been fixed (or at least made configurable) for indexedList/Table/Tree(?)/Cell but not (completely) for tableRow: updateIndex is called always, updateItem only if !old.equals(new) - that is not for identical items. Last time I looked (has been a while) deeper into it, a full fix did require a change in TableRowSkin. – kleopatra Oct 19 '18 at 12:26
  • @Slaw Q: My app uses `setCellSelectionEnabled(true)`. I'm guessing that because of that, there are no row selection events for the listener to action ergo no rows change colour. How would I adapt your solution for cell selection? Would you prefer me to post a fresh question (as I think I asked the wrong question to begin with, for which I apologise)? If it helps, my end goal is to block user updates on red and yellow rows by setting their editability to false at the same time colour is set. I’ll then bind the `disableProperty()` of my custom cells to row editability. That’s the theory, anyway! – GreenZebra Oct 20 '18 at 06:24
  • @kleopatra I've not yet ventured much into CSS and pseudoClasses but can see the benefit in Slaw's code. I've put them on my "to look at" list. Thanks for the suggestion. :-) – GreenZebra Oct 20 '18 at 06:26
  • @GreenZebra You should definitely ask a new question. – Slaw Oct 20 '18 at 07:21
  • @Slaw Thank you, but it was bugging me that I couldn't work this out for myself, so I chained myself to my PC and am happy to say I've got it working. I ended up changing my custom cell factories to model what you did in your example. For each, I used an `InvalidationListener` on the cell’s `selectedProperty()` (in case the cell - rather than the graphic within it - was selected) and a `ChangeListener` on the `focusedProperty()` of the graphic itself. The code feels a bit clunky but it works. I couldn't have figured it out without your example, so thank you very much again. Very happy! :-) – GreenZebra Oct 20 '18 at 23:40