Properties Extractor: Best way to get the ListView instantly updating its elements

This post is about how to deal with JavaFX ListViews and TableViews and how these controls are getting informed about changed content of the contained elements.
I wonder why I didn’t find anything about the following pattern in the relevant books as this is a really crucial mechanism.
Many posts out there suggest to force triggering a ChangeEvent getting a ListView to refresh by calling

list.remove(POJO);
list.add(index,POJO);

after each commited change! Brrr ;-)!

But there is a much better way:
Enable your list to report changes on the element by providing an properties extractor.

The Demo App
I have created a small demo app to play with for giving it a try.
Basically two TableViews and one ListView all sharing the same data.
To change properties of the elements one TableView is editable:
PropertiesExtractor Demo1

The DataModel

The compulsive PersonBean folling the JavaFX Bean Pattern/Convention

public class PersonBean {

    private StringProperty firstName;
    private StringProperty lastName;
    private ObjectProperty<LocalDate> birthday;
    private ObjectBinding<Long> age;

    public PersonBean() {
    }

    public PersonBean(String firstName, String lastName, LocalDate birthday) {
        setFirstName(firstName);
        setLastName(lastName);
        setBirthday(birthday);
    }

    public final StringProperty firstNameProperty() {
        if (firstName == null) {
            firstName = new SimpleStringProperty();
        }
        return firstName;
    }

    public final String getFirstName() {
        return firstNameProperty().get();
    }

    public final void setFirstName(final java.lang.String firstName) {
        firstNameProperty().set(firstName);
    }

    public final StringProperty lastNameProperty() {
        if (lastName == null) {
            lastName = new SimpleStringProperty();
        }
        return lastName;
    }

    public final java.lang.String getLastName() {
        return lastNameProperty().get();
    }

    public final void setLastName(final java.lang.String lastName) {
        lastNameProperty().set(lastName);
    }

    public final ObjectProperty<LocalDate> birthdayProperty() {
        if (birthday == null) {
            birthday = new SimpleObjectProperty<>();
        }
        return birthday;
    }

    public final LocalDate getBirthday() {
        return birthdayProperty().get();
    }

    public final void setBirthday(final java.time.LocalDate birthday) {
        birthdayProperty().set(birthday);

    }

    public String stringValue() {
        return String.format("%s %s %s", getFirstName(), getLastName(), getBirthday().format(DateTimeFormatter.ISO_LOCAL_DATE));
    }

    public final ObjectBinding<Long> ageBinding() {
        if (age == null) {
            age = new ObjectBinding<Long>() {
                {
                    bind(birthdayProperty());
                }

                @Override
                protected Long computeValue() {
                    if (getBirthday() == null) {
                        return null;
                    }
                    return getBirthday().until(LocalDate.now(), ChronoUnit.YEARS);
                }
            };
        }
        return age;
    }

    public static Callback<PersonBean, Observable&#91;&#93;> extractor() {
        return (PersonBean p) -> new Observable[]{p.lastNameProperty(), p.firstNameProperty(), p.birthdayProperty(), p.ageBinding()};
    }
}

DataModel containing a List of randomly created PersonBeans:

public class DataModel {

    private ObservableList<PersonBean> personFXBeans;

    public DataModel() {
        init();
    }

    private void init() {
        personFXBeans = DataSource.getRandomPersonBeansList(100);
    }

    public ObservableList<PersonBean> getPersonFXBeans() {
        return personFXBeans;
    }
}

As you may know to assign a DataModel e.g. to a TableView or a ListView in JavaFX you just have to use the setItems(ObvervableList) method.

@FXML
public void onFillWithDemoDataFXBeans() {
  readOnlyListView.setItems(model.getPersonFXBeans());
  readOnlyTableView.setItems(model.getPersonFXBeans());
  editableTableView.setItems(model.getPersonFXBeans());
}

Now getting a TableView informed about property changes of contained elements is already done you for by the binding either in two ways:
via a PropertyValueFactory and by more or less direct property binding:

readOnlyFirstNameColumn.setCellValueFactory(new PropertyValueFactory<>("firstName"));
readOnlyLastNameColumn.setCellValueFactory(new PropertyValueFactory<>("lastName"));
readOnlyBirthdayColumn.setCellValueFactory(new PropertyValueFactory<>("birthday"));
readOnlyAgeColumn.setCellValueFactory(i -> i.getValue().ageBinding());

editableFirstNameColumn.setCellValueFactory(i -> i.getValue().firstNameProperty());
editableLastNameColumn.setCellValueFactory(i -> i.getValue().lastNameProperty());
editableBirthdayColumn.setCellValueFactory(i -> i.getValue().birthdayProperty());
ageColumn.setCellValueFactory(i -> i.getValue().ageBinding());

But the ListView basically only observes the list and not the properties of each element of that list.

When using a ObservableList created by FXCollections.observableArrayList() the ListView will only refresh on ListChange Events like remove() an add() of elements. Therefore

list.remove(POJO);
list.add(index,POJO);

after each commited change.

But there is a much better way:
Enable your list to report changes on the element by providing an properties extractor.
You don’t have to care about refreshing then!

ObservableList persons = FXCollections.observableArrayList(PersonBean.extractor());

See DataSource.getRandomPersonBeansList(int length):

public static ObservableList<PersonBean> getRandomPersonBeansList(int length) {
        ObservableList<PersonBean>  persons = FXCollections.observableArrayList(PersonBean.extractor());
        for (int i = 0; i < length; i++) {
            persons.add(new PersonBean(getRandomName(), getRandomLastname(), getRandomLocalDate()));
        }
return persons;
}

This Extrator is basically a Callback containing an array of Obvervables which are then observed by the Obervablelist (more precicely: ObservableListWrapper):

My PersonBean already provides it’s extrator callback:

public static Callback<PersonBean, Observable[]> extractor() {
   return (PersonBean p) -> new Observable[]{p.lastNameProperty(), p.firstNameProperty(), p.birthdayProperty(), p.ageBinding()};
}

Following this pattern all controls are updated instantly after applying the change.

Edit data…
PropertiesExtractor Demo3

and commit:
PropertiesExtractor Demo4

THE CODE PLEASE!
You can find the complete code at my BitBucket Repo

First steps with JavaFX

This tutorial will introduce

  • Clean separation of application UI and logic
  • Using Builders supporting Fluent API
  • Effects
  • Layouts
  • Property Bindings
  • EventHandler

Abstract

The Golden Ratio (Golden MeanGolden Section) is defined as φ = (√5 + 1) / 2.

Objective

Create an UI to find the golden section by using a slider. Add value labels and an indicator to get green when golden action is hit.

The Model

Create observable double a and b values and a boolean value to indicate whether the golden section has been hit:

private DoubleProperty aValue = new SimpleDoubleProperty(0.0);
private DoubleProperty bValue = new SimpleDoubleProperty(0.0);
private BooleanProperty sectioAurea = new SimpleBooleanProperty(false);

Method to calculate the golden section:

private void reCalculate(){

double b = aValue.substract(100).multiply(-1).get();
setBValue(b);

double result1 = aValue.get()/bValue.get();
result1 = Math.round(result1*100.0)/100.0;

double result2 = (aValue.get()+bValue.get())/aValue.get();
result2 = Math.round(result2*100.0)/100.0;

sectioAurea.set(result1 == result2);
}

Please note: get() returns the primitive double value, getValue() respectively returns the Double instance.

Next attach listeners to re-caculate the golden section indicator on value changes:

private void attachListener(){ChangeListener onChangeListener = new ChangeListener(){
@Override
public void changed(ObservableValue arg0, Number arg1, Number arg2) {
reCalculate();
}
};

getAValueProperty().addListener(onChangeListener);
getBValueProperty().addListener(onChangeListener);
}

Here is the complete model code width appropriate getter and setter methods:

package de.jensd.javafx.aurea;

import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;

/**
*
* @author Jens Deters
*/
public class SectioModel {

private DoubleProperty aValue = new SimpleDoubleProperty(0.0);
private DoubleProperty bValue = new SimpleDoubleProperty(0.0);
private BooleanProperty sectioAurea = new SimpleBooleanProperty(false);

public SectioModel() {
attachListener();
}

private void attachListener(){

ChangeListener onChangeListener = new ChangeListener(){
@Override
public void changed(ObservableValue arg0, Number arg1, Number arg2) {
reCalculate();
}
};

getAValueProperty().addListener(onChangeListener);
getBValueProperty().addListener(onChangeListener);
}

public DoubleProperty getAValueProperty() {
return aValue;
}

public double getAValue() {
return aValue.get();
}

public void setAValue(double d) {
aValue.set(d);
}

public DoubleProperty getBValueProperty() {
return bValue;
}

public double getBValue() {
return bValue.get();
}

public void setBValue(double d) {
bValue.set(d);
}

public BooleanProperty getSectioAureaProperty() {
return sectioAurea;
}

public boolean isSectioAurea() {
return sectioAurea.get();
}

private void reCalculate(){

double b = aValue.substract(100).multiply(-1).get();
setBValue(b);

double result1 = aValue.get()/bValue.get();
result1 = Math.round(result1*100.0)/100.0;

double result2 = (aValue.get()+bValue.get())/aValue.get();
result2 = Math.round(result2*100.0)/100.0;

sectioAurea.set(result1 == result2);
}
}

The UI 

Title Label and usage hint

Text titleText = TextBuilder.create().
text("Sectio Aurea").
effect(ds).
font(Font.font(null, FontWeight.BOLD, 32)).
build();

Text descriptionText = TextBuilder.create().
text("Move the slider until your hit the golden section.\nHint: use the cursor keys for exact control.").
textAlignment(TextAlignment.CENTER).
build();

Indicator

Stop[] falseStops = new Stop[]{new Stop(0.0, Color.WHITE), new Stop(0.3, Color.RED), new Stop(1.0, Color.DARKRED)};
falsePaint = new RadialGradient(0.0, 0.0, -10.0, -10.0, 50, false, CycleMethod.NO_CYCLE, falseStops);

Stop[] trueStops = new Stop[]{new Stop(0.0, Color.WHITE), new Stop(0.3, Color.GREENYELLOW), new Stop(1.0, Color.DARKGREEN)};
truePaint = new RadialGradient(0.0, 0.0, -10.0, -10.0, 50, false, CycleMethod.NO_CYCLE, trueStops);

ellipse = EllipseBuilder.create().
radiusX(30).
radiusY(30).
fill(falsePaint).
build();

DropShadow ds = new DropShadow();
ds.setOffsetY(3.0f);
ds.setColor(Color.color(0.4f, 0.4f, 0.4f));

ellipse.setEffect(ds);

Slider

slider = SliderBuilder.create().
min(model.getAValue()).
max(model.getBValue()).
showTickMarks(true).
showTickLabels(true).
majorTickUnit(20).
minorTickCount(3).
prefWidth(300).
blockIncrement(0.1f).
build();

Status Labels

aValueText = TextBuilder.create().build();
bValueText = TextBuilder.create().build();
aureaText = TextBuilder.create().build();

Solve Button

solveButton = ButtonBuilder.create().text("Solve").build();

Add the solve action

solveButton.setOnAction(new EventHandler() {
@Override
public void handle(ActionEvent arg0) {
model.setAValue(61.8);
}
});

Put it all together

Using the GridPane layout:

GridPane root = new GridPane();
root.setPadding(new Insets(20, 20, 20, 20));
root.setGridLinesVisible(true);</h2>
RowConstraints rowInfo = new RowConstraints(100);
ColumnConstraints colInfo = new ColumnConstraints(100);

root.getRowConstraints().
add(rowInfo);

for (int j = 0; j &lt;= 2; j++) {
root.getColumnConstraints().
add(colInfo);
}

GridPane.setHalignment(titleText, HPos.CENTER);
GridPane.setHalignment(descriptionText, HPos.CENTER);
GridPane.setHalignment(slider, HPos.CENTER);
GridPane.setHalignment(aValueText, HPos.CENTER);
GridPane.setHalignment(ellipse, HPos.CENTER);
GridPane.setHalignment(bValueText, HPos.CENTER);
GridPane.setHalignment(solveButton, HPos.CENTER);

GridPane.setConstraints(titleText, 0, 0, 3, 1);
GridPane.setConstraints(descriptionText, 0, 1, 3, 1);
GridPane.setConstraints(slider, 0, 2, 3, 1);
GridPane.setConstraints(aValueText, 0, 3);
GridPane.setConstraints(ellipse, 1, 3);
GridPane.setConstraints(bValueText, 2, 3);
GridPane.setConstraints(solveButton, 2, 4);

Connect UI to Model

model.getAValueProperty().
bindBidirectional(slider.valueProperty());

aValueText.textProperty().
bind(model.getAValueProperty().
asString("%.1f"));
bValueText.textProperty().
bind(model.getBValueProperty().
asString("%.1f"));

aureaText.textProperty().
bind(model.getSectioAureaProperty().
asString());
<h2>Connect listener to refresh the indicator:</h2>
<pre>model.getSectioAureaProperty().
            addListener(new ChangeListener() {</pre>
@Override
public void changed(ObservableValue arg0, Boolean arg1, Boolean arg2) {
refreshIndicator();
}
});

The complete UI code

package de.jensd.javafx.aurea;

import javafx.application.Application;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBuilder;
import javafx.scene.control.Slider;
import javafx.scene.control.SliderBuilder;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.RowConstraints;
import javafx.scene.paint.Color;
import javafx.scene.paint.CycleMethod;
import javafx.scene.paint.Paint;
import javafx.scene.paint.RadialGradient;
import javafx.scene.paint.Stop;
import javafx.scene.shape.Ellipse;
import javafx.scene.shape.EllipseBuilder;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import javafx.scene.text.TextBuilder;
import javafx.stage.Stage;

/**
*
* @author Jens Deters
*/
public class SectioAureaApplication extends Application {

private SectioModel model = new SectioModel();
private Slider slider;
private Text aValueText;
private Text bValueText;
private Text aureaText;
private Ellipse ellipse;
private Paint falsePaint;
private Paint truePaint;
private Button solveButton;

public static void main(String[] args) {
Application.launch(SectioAureaApplication.class, args);
}

@Override
public void start(Stage stage) throws Exception {
stage.setTitle("Sectio Aurea");

model = new SectioModel();
model.setAValue(0);
model.setBValue(100.9);

slider = SliderBuilder.create().
min(model.getAValue()).
max(model.getBValue()).
showTickMarks(true).
showTickLabels(true).
majorTickUnit(20).
minorTickCount(3).
prefWidth(300).
blockIncrement(0.1f).
build();

aValueText = TextBuilder.create().
build();

bValueText = TextBuilder.create().
build();

aureaText = TextBuilder.create().
build();

solveButton = ButtonBuilder.create().
text("Solve").
build();

Stop[] falseStops = new Stop[]{new Stop(0.0, Color.WHITE), new Stop(0.3, Color.RED), new Stop(1.0, Color.DARKRED)};
falsePaint = new RadialGradient(0.0, 0.0, -10.0, -10.0, 50, false, CycleMethod.NO_CYCLE, falseStops);

Stop[] trueStops = new Stop[]{new Stop(0.0, Color.WHITE), new Stop(0.3, Color.GREENYELLOW), new Stop(1.0, Color.DARKGREEN)};
truePaint = new RadialGradient(0.0, 0.0, -10.0, -10.0, 50, false, CycleMethod.NO_CYCLE, trueStops);

ellipse = EllipseBuilder.create().
radiusX(30).
radiusY(30).
fill(falsePaint).
build();

DropShadow ds = new DropShadow();
ds.setOffsetY(3.0f);
ds.setColor(Color.color(0.4f, 0.4f, 0.4f));

ellipse.setEffect(ds);

Text titleText = TextBuilder.create().
text("Sectio Aurea").
effect(ds).
font(Font.font(null, FontWeight.BOLD, 32)).
build();

Text descriptionText = TextBuilder.create().
text("Move the slider until your hit the golden section.\nHint: use the cursor keys for exact control.").
textAlignment(TextAlignment.CENTER).
build();

// Put it all together
GridPane root = new GridPane();
root.setPadding(new Insets(20, 20, 20, 20));
root.setGridLinesVisible(false);

RowConstraints rowInfo = new RowConstraints(100);
ColumnConstraints colInfo = new ColumnConstraints(100);

root.getRowConstraints().
add(rowInfo);
root.getRowConstraints().
add(rowInfo);

for (int j = 0; j <= 2; j++) {
root.getColumnConstraints().
add(colInfo);
}

GridPane.setHalignment(titleText, HPos.CENTER);
GridPane.setHalignment(descriptionText, HPos.CENTER);
GridPane.setHalignment(slider, HPos.CENTER);
GridPane.setHalignment(aValueText, HPos.CENTER);
GridPane.setHalignment(ellipse, HPos.CENTER);
GridPane.setHalignment(bValueText, HPos.CENTER);
GridPane.setHalignment(solveButton, HPos.CENTER);

GridPane.setConstraints(titleText, 0, 0, 3, 1);
GridPane.setConstraints(descriptionText, 0, 1, 3, 1);
GridPane.setConstraints(slider, 0, 2, 3, 1);
GridPane.setConstraints(aValueText, 0, 3);
GridPane.setConstraints(ellipse, 1, 3);
GridPane.setConstraints(bValueText, 2, 3);
GridPane.setConstraints(solveButton, 2, 4);

root.getChildren().
addAll(titleText, descriptionText, slider, aValueText, ellipse, bValueText, solveButton);

bindProperties();

stage.setScene(new Scene(root, 350, 380));
stage.show();

}

private void refreshIndicator() {
if (model.getSectioAureaProperty().
get()) {
ellipse.fillProperty().
set(truePaint);

} else {
ellipse.fillProperty().
set(falsePaint);
}
}

private void bindProperties() {

model.getAValueProperty().
bindBidirectional(slider.valueProperty());

aValueText.textProperty().
bind(model.getAValueProperty().
asString("%.1f"));
bValueText.textProperty().
bind(model.getBValueProperty().
asString("%.1f"));

aureaText.textProperty().
bind(model.getSectioAureaProperty().
asString());

model.getSectioAureaProperty().
addListener(new ChangeListener() {

@Override
public void changed(ObservableValue arg0, Boolean arg1, Boolean arg2) {
refreshIndicator();
}
});

solveButton.setOnAction(new EventHandler() {

@Override
public void handle(ActionEvent arg0) {
model.setAValue(61.8);
}
});

}
}

_____________________________________________________

here’s the NetBeans project: SectioAurea.zip