Search code examples
javafxobservablelistproperty-binding

JavaFX: Data binding between two observable lists of different type


I have an application that manages (bank) accounts. In my data model, I have defined an observable list for the accounts:

private final ObservableList<Account> accounts = observableArrayList();

Each account has a list of cashflows, which is implemented via a property (also being an observable list):

// in Account class:
private final SimpleListProperty<Cashflow> cashflows = new SimpleListProperty<>(observableArrayList());

In my UI, I have a table containing all accounts, and I am using the cashflow list property to show the number of cashflows for each account, which works fine.

The accounts table also provides checkboxes to select or unselect specific accounts. There's a property for this in my Account class as well:

// in Account class:
private final SimpleBooleanProperty selected = new SimpleBooleanProperty();

Now I want to add another table to the UI, which contains the cashflows, but only for the selected accounts, and preferably I want to implement this via data binding.

But I don't know how to achieve this. I quickly dismissed the idea of using directly the cashflows property of the Account class in some way, because I wouldn't even know where to start here.

So what I tried is define a separate observable list for the cashflows in my data model:

private final ObservableList<Cashflow> cashflowsOfSelectedAccounts = observableArrayList();

I know that I can define extractors for the account list that will notify observers when something changes. So for example, I could extend my account list to something like:

private final ObservableList<Account> accounts = observableArrayList(
        account -> new Observable[]{
                account.selectedProperty(),
                account.cashflowsProperty().sizeProperty()});

This would trigger a notification to a listener on the accounts list on any of the following:

  • an account is added or removed
  • a cashflow is added to or removed from an account
  • an account gets selected or unselected

But now I don't know how I can bring this together with my observable cashflow list, because I have two different data types here: Account, and Cashflow.

The only solution I can think of is to add a custom listener to the account list to react to all of the relevant events listed above, and maintain the cashflowsOfSelectedAccounts manually.

So here's my Question: Is it possible to sync the accounts list with the list of cashflows of selected accounts via data binding, or via some other way that I'm not aware of and that would be more elegant than manually maintaining the cashflow list with a custom listener on the accounts list?

Thanks!


Solution

  • Personally, I wouldn't try to overcomplicate the bindings too much beyond simple applications of built-in support for the high-level binding APIs. Once you add a few bindings things get complicated enough already.

    Alternative 1

    What I suggest you do is:

    1. Create a filtered list of selected accounts.

    2. Use the filtered list of selected accounts as the backing list for the second table.

    3. As the second table is only to display cashflow data and not full account data, for column data, provide custom value factories to access the cashflow data in the account.

    4. Making the second table a TreeTableView may make sense, that way it can group the cashflows by account.

    This may or may not be a valid approach for your app.

    Alternative 2

    Alternately, also working off the filteredlist of accounts, add a list change listener to the filtered list, when it changes, update the content of a separate list of related cashflows which you use as the backing list for the cashflow table.

    Handling your use cases.

    An account is added or removed

    Just add or remove from the account list.

    A cashflow is added to or removed from an account

    An extractor on the account list and a list listener can be triggered when associated cashflows change to trigger an update to the cashflow list.

    an account gets selected or unselected

    See the linked filtered list example, it is based on an extractor combined with a filtered list.

    Alternative 3

    Alternately, you could change your UI. For example, have separate edit and commit pages for cashflow data and account data with user button presses for committing or discarding changes. The commits update the backing database. Then, after committing, navigate back to the original page which just reads the new data from the source database again. That's generally how these things often work rather than a bunch of binding.

    I realize that none of these options are what you are asking about and some of them probably do work you were trying to avoid through a different binding type, but, those are the ideas I came up with.

    Example

    FWIW, here is an example of Alternative 2, which relies on an account list, a filtered account list a separate cashflow list, and extractors and listeners to keep stuff in sync.

    It won't be exactly what you want, but perhaps you can adapt it or learn something from it.

    I would note that the cashflow list doesn't tie a given cashflow to a given account, so, if you want to do that, you might want to add additional functionality to support visual feedback for that association.

    Initial state:

    initial state

    Select only a single account:

    single account selected

    Remove an account and change the cashflow data for a given account:

    change the cashflow data for a given account

    import javafx.application.Application;
    import javafx.beans.Observable;
    import javafx.beans.binding.Bindings;
    import javafx.beans.property.*;
    import javafx.collections.*;
    import javafx.collections.transformation.FilteredList;
    import javafx.geometry.Insets;
    import javafx.scene.Scene;
    import javafx.scene.control.*;
    import javafx.scene.control.cell.CheckBoxTableCell;
    import javafx.scene.layout.HBox;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    
    public class FlowApp extends Application {
    
        private final ObservableList<Account> accounts = FXCollections.observableArrayList(
                account -> new Observable[] { account.selectedProperty(), account.getCashflows() }
        );
    
        private final ObservableList<Account> cashflowAccounts = new FilteredList<>(
                accounts,
                account -> account.selectedProperty().get()
        );
    
        private final ObservableList<Cashflow> cashflows = FXCollections.observableArrayList(
                cashflow -> new Observable[] { cashflow.amountProperty() }
        );
    
        public static void main(String[] args) {
            launch(args);
        }
    
        @Override
        public void start(Stage stage) {
            cashflowAccounts.addListener((ListChangeListener<Account>) c -> updateCashflows());
            initDataStructures();
    
            final TableView<Account> accountSelectionTableView =
                    createAccountSelectionTableView();
    
            final TableView<Cashflow> cashflowView =
                    createCashflowView();
    
            final Button change = new Button("Change");
            change.setOnAction(e -> changeData(change));
    
            final Button reset = new Button("Reset");
            reset.setOnAction(e -> { initDataStructures(); change.setDisable(false); });
    
            final VBox vbox = new VBox(
                    10,
                    new TitledPane("Accounts", accountSelectionTableView),
                    new TitledPane("Cashflows", cashflowView),
                    new HBox(10, change, reset)
            );
            vbox.setPadding(new Insets(10));
    
            stage.setScene(new Scene(vbox));
            stage.show();
        }
    
        private void changeData(Button change) {
            accounts.get(accounts.size() - 1);
    
            // Paul dies.
            accounts.removeIf(
                    account -> account.firstNameProperty().get()
                            .equals("Paul")
            );
    
            // Albert.
            Account albert = accounts.stream()
                    .filter(
                            account -> account.firstNameProperty().get().equals(
                                    "Albert"
                            )
                    ).findFirst().orElse(null);
    
            if (albert == null) {
                return;
            }
    
            // Albert stops receiving alimony.
            albert.getCashflows().removeIf(
                    c -> c.sourceProperty().get().equals(
                                    CashflowSource.ALIMONY
                            )
                    );
    
            // Albert's rent increases.
            Cashflow albertsRent = albert.getCashflows().stream()
                    .filter(
                            cashflow -> cashflow.sourceProperty().get().equals(
                                    CashflowSource.RENT
                            )
                    ).findFirst().orElse(null);
    
            if (albertsRent == null) {
                return;
            }
    
            albertsRent.amountProperty().set(
                    albertsRent.amountProperty().get() + 5
            );
    
            // only allow one change.
            change.setDisable(true);
        }
    
        private void initDataStructures() {
            accounts.setAll(
                    new Account("Ralph", "Alpher", true, "[email protected]",
                            new Cashflow(CashflowSource.RENT, 10),
                            new Cashflow(CashflowSource.ALIMONY, 5)
                    ),
                    new Account("Hans", "Bethe", false, "[email protected]"),
                    new Account("George", "Gammow", true, "[email protected]",
                            new Cashflow(CashflowSource.SALARY, 3)
                    ),
                    new Account("Paul", "Dirac", false, "[email protected]",
                            new Cashflow(CashflowSource.RENT, 17),
                            new Cashflow(CashflowSource.SALARY, 4)
                    ),
                    new Account("Albert", "Einstein", true, "[email protected]",
                            new Cashflow(CashflowSource.RENT, 2),
                            new Cashflow(CashflowSource.ALIMONY, 1),
                            new Cashflow(CashflowSource.DIVIDENDS, 8)
                    )
            );
        }
    
        private void updateCashflows() {
            cashflows.setAll(
                    cashflowAccounts.stream()
                            .flatMap(a ->
                                    a.getCashflows().stream()
                            ).toList()
            );
        }
    
        private TableView<Account> createAccountSelectionTableView() {
            final TableView<Account> selectionTableView = new TableView<>(accounts);
            selectionTableView.setPrefSize(540, 180);
    
            TableColumn<Account, String> firstName = new TableColumn<>("First Name");
            firstName.setCellValueFactory(cd -> cd.getValue().firstNameProperty());
            selectionTableView.getColumns().add(firstName);
    
            TableColumn<Account, String> lastName = new TableColumn<>("Last Name");
            lastName.setCellValueFactory(cd -> cd.getValue().lastNameProperty());
            selectionTableView.getColumns().add(lastName);
    
            TableColumn<Account, Boolean> selected = new TableColumn<>("Selected");
            selected.setCellValueFactory(cd -> cd.getValue().selectedProperty());
            selected.setCellFactory(CheckBoxTableCell.forTableColumn(selected));
            selectionTableView.getColumns().add(selected);
    
            TableColumn<Account, String> email = new TableColumn<>("Email");
            email.setCellValueFactory(cd -> cd.getValue().emailProperty());
            selectionTableView.getColumns().add(email);
    
            TableColumn<Account, Integer> numCashflows = new TableColumn<>("Num Cashflows");
            numCashflows.setCellValueFactory(cd -> Bindings.size(cd.getValue().getCashflows()).asObject());
            numCashflows.setStyle("-fx-alignment: baseline-right;");
            selectionTableView.getColumns().add(numCashflows);
    
            selectionTableView.setEditable(true);
            return selectionTableView;
        }
    
        private TableView<Cashflow> createCashflowView() {
            TableView<Cashflow> cashflowView = new TableView<>();
    
            TableColumn<Cashflow, CashflowSource> source = new TableColumn<>("Source");
            source.setCellValueFactory(cd -> cd.getValue().sourceProperty());
            cashflowView.getColumns().add(source);
    
            TableColumn<Cashflow, Integer> amount = new TableColumn<>("Amount");
            amount.setCellValueFactory(cd -> cd.getValue().amountProperty().asObject());
            amount.setStyle("-fx-alignment: baseline-right;");
            cashflowView.getColumns().add(amount);
    
            cashflowView.setItems(cashflows);
            cashflowView.setPrefHeight(160);
    
            return cashflowView;
        }
    
        private static class Account {
            private final StringProperty firstName;
            private final StringProperty lastName;
            private final BooleanProperty selected;
            private final StringProperty email;
            private final ObservableList<Cashflow> cashflows;
    
            private Account(String fName, String lName, boolean selected, String email, Cashflow... cashflows) {
                this.firstName = new SimpleStringProperty(fName);
                this.lastName = new SimpleStringProperty(lName);
                this.selected = new SimpleBooleanProperty(selected);
                this.email = new SimpleStringProperty(email);
                this.cashflows = FXCollections.observableArrayList(cashflows);
            }
    
            public StringProperty firstNameProperty() {
                return firstName;
            }
    
            public StringProperty lastNameProperty() {
                return lastName;
            }
    
            public BooleanProperty selectedProperty() {
                return selected;
            }
    
            public StringProperty emailProperty() {
                return email;
            }
    
            public ObservableList<Cashflow> getCashflows() {
                return cashflows;
            }
        }
    
        class Cashflow {
            private final ObjectProperty<CashflowSource> source;
            private final IntegerProperty amount;
    
            public Cashflow(CashflowSource source, int amount) {
                this.source = new SimpleObjectProperty<>(source);
                this.amount = new SimpleIntegerProperty(amount);
            }
    
            public ObjectProperty<CashflowSource> sourceProperty() {
                return source;
            }
    
            public IntegerProperty amountProperty() {
                return amount;
            }
        }
    
        enum CashflowSource {
            RENT, SALARY, DIVIDENDS, ALIMONY
        }
    }