Dienstag, 6. März 2012

TreeView with a data source

When working with JavaFX controls like ListView or TableView, you can populate these controls easily with a setItems method.

However, when working with the TreeView control, you don't have that method and you have to build up your tree directly in the UI layer.

That means, every time, you want to sort the tree or add or remove elements, you have to alter the control directly instead of doing that on an underlying data source.

I wrote some convenient code, which makes this easier. Let's go...

In order to define hierarchical data, we need an interface, which represents the recursive nature of the tree data:

import javafx.collections.ObservableList;

/**
 * Used to mark an object as hierarchical data.
 * This object can then be used as data source for an hierarchical control, like the {@link javafx.scene.control.TreeView}.
 *
 * @author Christian Schudt
 */
public interface HierarchyData<T extends HierarchyData> {
    /**
     * The children collection, which represents the recursive nature of the hierarchy.
     * Each child is again a {@link HierarchyData}.
     *
     * @return A list of children.
     */
    ObservableList<T> getChildren();
}

This is used to mark an object as hierarchical data. (By the way, the name was inspired by .NET's HierarchyData.)

The second step is to provide a TreeView control with a setItems method. We just derive from the JavaFX TreeView and add one public method. The data of the TreeView must implement our above mentioned interface:

import extfx.util.HierarchyData;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.WeakListChangeListener;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;

import java.util.HashMap;
import java.util.Map;

/**
 * This class extends the {@link TreeView} to use items as a data source.
 * <p/>
 * This allows you to treat a {@link TreeView} in a similar way as a {@link javafx.scene.control.ListView} or {@link javafx.scene.control.TableView}.
 * <p/>
 * Each item in the list must implement the {@link HierarchyData} interface, in order to map the recursive nature of the tree data to the tree view.
 * <p/>
 * Each change in the underlying data (adding, removing, sorting) will then be automatically reflected in the UI.
 *
 * @author Christian Schudt
 */
public class TreeViewWithItems<T extends HierarchyData<T>> extends TreeView<T> {

    /**
     * Keep hard references for each listener, so that they don't get garbage collected too soon.
     */
    private final Map<TreeItem<T>, ListChangeListener<T>> hardReferences = new HashMap<TreeItem<T>, ListChangeListener<T>>();

    /**
     * Also store a reference from each tree item to its weak listeners, so that the listener can be removed, when the tree item gets removed.
     */
    private final Map<TreeItem<T>, WeakListChangeListener<T>> weakListeners = new HashMap<TreeItem<T>, WeakListChangeListener<T>>();

    private ObjectProperty<ObservableList<? extends T>> items = new SimpleObjectProperty<ObservableList<? extends T>>(this, "items");

    public TreeViewWithItems() {
        super();
        init();
    }

    /**
     * Creates the tree view.
     *
     * @param root The root tree item.
     * @see TreeView#TreeView(javafx.scene.control.TreeItem)
     */
    public TreeViewWithItems(TreeItem<T> root) {
        super(root);
        init();
    }

    /**
     * Initializes the tree view.
     */
    private void init() {
        rootProperty().addListener(new ChangeListener<TreeItem<T>>() {
            @Override
            public void changed(ObservableValue<? extends TreeItem<T>> observableValue, TreeItem<T> oldRoot, TreeItem<T> newRoot) {
                clear(oldRoot);
                updateItems();
            }
        });

        setItems(FXCollections.<T>observableArrayList());

        // Do not use ChangeListener, because it won't trigger if old list equals new list (but in fact different references).
        items.addListener(new InvalidationListener() {
            @Override
            public void invalidated(Observable observable) {
                clear(getRoot());
                updateItems();
            }
        });
    }

    /**
     * Removes all listener from a root.
     *
     * @param root The root.
     */
    private void clear(TreeItem<T> root) {
        if (root != null) {
            for (TreeItem<T> treeItem : root.getChildren()) {
                removeRecursively(treeItem);
            }

            removeRecursively(root);
            root.getChildren().clear();
        }
    }

    /**
     * Updates the items.
     */
    private void updateItems() {

        if (getItems() != null) {
            for (T value : getItems()) {
                getRoot().getChildren().add(addRecursively(value));
            }

            ListChangeListener<T> rootListener = getListChangeListener(getRoot().getChildren());
            WeakListChangeListener<T> weakListChangeListener = new WeakListChangeListener<T>(rootListener);
            hardReferences.put(getRoot(), rootListener);
            weakListeners.put(getRoot(), weakListChangeListener);
            getItems().addListener(weakListChangeListener);
        }
    }

    /**
     * Gets a {@link javafx.collections.ListChangeListener} for a  {@link TreeItem}. It listens to changes on the underlying list and updates the UI accordingly.
     *
     * @param treeItemChildren The associated tree item's children list.
     * @return The listener.
     */
    private ListChangeListener<T> getListChangeListener(final ObservableList<TreeItem<T>> treeItemChildren) {
        return new ListChangeListener<T>() {
            @Override
            public void onChanged(final Change<? extends T> change) {
                while (change.next()) {
                    if (change.wasUpdated()) {
                        // http://javafx-jira.kenai.com/browse/RT-23434
                        continue;
                    }
                    if (change.wasRemoved()) {
                        for (int i = change.getRemovedSize() - 1; i >= 0; i--) {
                            removeRecursively(treeItemChildren.remove(change.getFrom() + i));
                        }
                    }
                    // If items have been added
                    if (change.wasAdded()) {
                        // Get the new items
                        for (int i = change.getFrom(); i < change.getTo(); i++) {
                            treeItemChildren.add(i, addRecursively(change.getList().get(i)));
                        }
                    }
                    // If the list was sorted.
                    if (change.wasPermutated()) {
                        // Store the new order.
                        Map<Integer, TreeItem<T>> tempMap = new HashMap<Integer, TreeItem<T>>();

                        for (int i = change.getTo() - 1; i >= change.getFrom(); i--) {
                            int a = change.getPermutation(i);
                            tempMap.put(a, treeItemChildren.remove(i));
                        }

                        getSelectionModel().clearSelection();

                        // Add the items in the new order.
                        for (int i = change.getFrom(); i < change.getTo(); i++) {
                            treeItemChildren.add(tempMap.remove(i));
                        }
                    }
                }
            }
        };
    }

    /**
     * Removes the listener recursively.
     *
     * @param item The tree item.
     */
    private TreeItem<T> removeRecursively(TreeItem<T> item) {
        if (item.getValue() != null && item.getValue().getChildren() != null) {

            if (weakListeners.containsKey(item)) {
                item.getValue().getChildren().removeListener(weakListeners.remove(item));
                hardReferences.remove(item);
            }
            for (TreeItem<T> treeItem : item.getChildren()) {
                removeRecursively(treeItem);
            }
        }
        return item;
    }

    /**
     * Adds the children to the tree recursively.
     *
     * @param value The initial value.
     * @return The tree item.
     */
    private TreeItem<T> addRecursively(T value) {

        TreeItem<T> treeItem = new TreeItem<T>();
        treeItem.setValue(value);
        treeItem.setExpanded(true);

        if (value != null && value.getChildren() != null) {
            ListChangeListener<T> listChangeListener = getListChangeListener(treeItem.getChildren());
            WeakListChangeListener<T> weakListener = new WeakListChangeListener<T>(listChangeListener);
            value.getChildren().addListener(weakListener);

            hardReferences.put(treeItem, listChangeListener);
            weakListeners.put(treeItem, weakListener);
            for (T child : value.getChildren()) {
                treeItem.getChildren().add(addRecursively(child));
            }
        }
        return treeItem;
    }

    public ObservableList<? extends T> getItems() {
        return items.get();
    }

    /**
     * Sets items for the tree.
     *
     * @param items The list.
     */
    public void setItems(ObservableList<? extends T> items) {
        this.items.set(items);
    }
}

What's the benefit?

Clearly, the benefit is, that you only need to make modifications in the data source rather doing it on the control directly. I found it quite convenient, if you have a lot of hierarchical data, which often changes.

E.g. you can just call FXCollections.sort() and the TreeView updates automatically (of course only the corresponding TreeItem), just like you are used it from ListView.

Theoretically the same could also be applied to a Menu structure.

Hope you find it useful ;)

36 Kommentare:

  1. This might be something that can be incorporated into DataFX (http://www.javafxdata.org). Would you be interested in that?

    You can contact me at jonathan@jonathangiles.net if you want.

    -- Jonathan

    AntwortenLöschen
  2. Hi Christian

    I have been using your tree with great results and have found/fixed a bug. If the tree updates the tree view because an item has changed then the item will be added multiple items. To fix this replace the loop starting on line 63 with the following:

    for (int i = change.getFrom(); i < change.getTo(); i++) {
    if(i < children.size())
    children.set(i, addRecursively(changeList.get(i)));
    else
    children.add(i, addRecursively(changeList.get(i)));
    }

    This might need to be applied to the sorted loops as well but I haven't tested it

    AntwortenLöschen
    Antworten
    1. Could you please elaborate the problem? I've tested it by setting a new item on the list (list.set(1, new Item()) and it works as expected. Thanks!

      Löschen
  3. If you set an extractor on the ObervableList returned from HierarchyData, the list objects are themselves observable.

    Say I returned a Person object in that list with a name string property, and then edit that property the object will already exist in the tree but because the tree sees the change it will add it again instead of seeing it as a change in the object already being shown.

    I hope this helps?

    AntwortenLöschen
    Antworten
    1. Ah ok, I guess you mean the FXCollections.observableList(java.util.List list, Callback extractor) method.

      Can you tell me how you use it? I never worked with it and I have no idea what to return in the callback.
      Does you Person implement Observable?

      Löschen
    2. I attempted to implement Observable but this didn't work for some reason, maybe I was doing it wrong. It was also a lot of code. To create a collection that observes changes in it's objects I did the following:

      people = FXCollections.observableArrayList(new Callback() {
      @Override
      public Observable[] call(Person person) {
      return new Observable[] { person.name };
      }});

      name is the only JavaFX property but if I had age for example, that would be returned in the array also.

      Löschen
    3. Unfortunately I can't get the extractor to work.
      See here:

      https://forums.oracle.com/forums/thread.jspa?threadID=2412877&tstart=0

      Maybe you can comment there or here.

      Another weirdness is, that according to the JavaDoc the extractor should only trigger the "update" in the change, not the "added". So it shouldn't affect my TreeView at all.

      Löschen
  4. Hi Christian

    Try using the FXCollections.observableArrayList method instead of FXCollections.observableList in the TestApp2.

    I have no idea why this makes a difference!

    AntwortenLöschen
    Antworten
    1. Seems to be a bug in JavaFX 2.1. Good, that you found it ;)
      The wasAdded() method should not return true as well for these kind of updates.
      I reported it here:
      http://javafx-jira.kenai.com/browse/RT-23434

      I think the best workaround is to do this:
      if (change.wasUpdated()) {
      continue;
      }

      Löschen
  5. I updated the TreeViewWithItems class:
    - setItems method didn't clear old tree items.
    - The control had an issue with ObservableList using an extractor (thanks to Andy Till to figuring that out, see comments)
    - The control had an issue, when using the same items in several nodes of the same tree or in different trees.

    AntwortenLöschen
  6. Hi Christian,
    Could you elaborate little bit on the use of the hard/soft reference collections please? Namely at what point in the life/update cycle they prevent the garbage collection.

    Many thanks,
    Adam

    AntwortenLöschen
    Antworten
    1. I use the WeakChangeListeners, in order to prevent memory leaks. If I added normal ChangeListeners to the ObservableList instead, they would still be in memory, even if the TreeView is no longer visible/referenced.
      It's hard to explain in a short comment, I've also had to try it out, how it works. In early versions of this class, I haven't had those. Therefore I didn't talk about it in the post. I only updated the class later, without updating the explanation.

      Löschen
  7. Hi Christian, very nice library. i would like to implement the extfx controls on a tablecell. i am new to javafx and would like to know about creating custom tablecell with your controls.

    AntwortenLöschen
  8. Probably I'm stupid but how exactly your class is going to be used in real application with UI? It's more like a theory that compiles but how real JavaFX control will render that stuff if only type of collection does not represents String for example???
    How can I pass e.g. a String type to display it in UI.

    AntwortenLöschen
    Antworten
    1. It will render the same way as a normal TreeView control. This is only a helper class, so that you can use an ObservableList as a datasource, similar to the ListView.
      Because a TreeView is hierarchical, the elements in the ObservableList must implement the HierarchyData interface in order to reflect the hierarchical structure.

      If you then add or remove an element or sort the ObservableList, the changes are automatically reflected to the UI.

      You need some data class like this:

      public class Data implements HierarchyData<Data> {
      public Data(String name)
      {
      this.name=name;
      }
      private String name;
      private ObservableList<Data> children = FXCollection.observableArrayList();
      @Override
      public ObservableList<Data> getChildren()
      {
      return children;
      }
      public String getName()
      {
      return name;
      }
      }

      put instances of it into an ObservableList<Data> (the "root" collection) and set that list in the setItems method.

      Löschen
    2. I tried the same class as you have mentioned here, but instead of assigning the name to the TreeItem value it is assigning the Class ID and the treeView is having TreeView has TreeItems with names like "application.TreeView.TreeViewModel@7e78ab0f".

      How to assign the value of the variable "name" to the TreeItem instead of the Object ID.

      Löschen
    3. Sorry, I don't understand your question. You use this class similar to ListView or TableView.

      Löschen
  9. Dieser Kommentar wurde vom Autor entfernt.

    AntwortenLöschen
  10. I did it nearly the same in the meanwhile)) thank you. Except that I thought in order to render it I need some overriden toString() method. But anyway, danke schoen:)

    AntwortenLöschen
  11. Dieser Kommentar wurde vom Autor entfernt.

    AntwortenLöschen
  12. I tried to use your code to extend a treetableview Java 8, do I need to do modify anything?

    By simply hacking your code to extend TreeTableView I am getting a null pointer at

    private void updateItems() {

    if (getItems() != null) {
    for (T value : getItems()) {
    getRoot().getChildren().add(addRecursively(value));

    AntwortenLöschen
  13. getRoot() is probably null. There was a similar comment from Graham above.
    Try using the TreeViewWithItems(TreeItem root) constructor and just pass an empty root TreeItem.
    I will modify the code soon.

    AntwortenLöschen
  14. Thanks with your suggestion it works, I realised the hardway that setShowRoot has to be set to false for this to work.

    AntwortenLöschen
  15. Dieser Kommentar wurde vom Autor entfernt.

    AntwortenLöschen
  16. How can I use this tree view with custom TreeCell. My Code is like this

    public class Alert implements HierarchyData{
    private final SimpleStringProperty name;
    private final SimpleStringProperty status;
    private final ObservableList children = FXCollections.observableArrayList();

    public Alert(String name, String department) {
    this.name = new SimpleStringProperty(name);
    this.status = new SimpleStringProperty(department);
    }

    public String getName() {
    return name.get();
    }

    public void setName(String fName) {
    name.set(fName);
    }

    public String getStatus() {
    return status.get();
    }

    public void setStatus(String fName) {
    status.set(fName);
    }

    @Override
    public ObservableList getChildren() {
    return children;
    }
    }

    and

    private final class AlertTreeCell extends TreeCell {

    private final AnchorPane anchorPane;
    private final Label label;
    private final Button button;

    public AlertTreeCell() {
    anchorPane = new AnchorPane();
    label = new Label();
    button = new Button();
    anchorPane.getChildren().addAll(label, button);
    anchorPane.setStyle("-fx-border-color: gray");
    anchorPane.setPadding(new Insets(5));
    AnchorPane.setRightAnchor(button, 10.0);
    AnchorPane.setLeftAnchor(label, 15.0);
    }

    @Override
    public void updateItem(Alert item, boolean empty) {
    super.updateItem(item, empty);
    if (empty) {
    setText(null);
    setGraphic(null);
    } else {
    setText(null);
    label.setText(item.getStatus());
    button.setText(item.getName());
    setGraphic(anchorPane);
    }
    }
    }

    and my tree initialization is like this
    TreeItem rootNode = new TreeItem<>(new Alert("dummy", "dummy"));
    TreeView treeView = new TreeView<>(rootNode);
    treeView.setShowRoot(false);
    treeView.setCellFactory(new Callback, TreeCell>() {
    @Override
    public TreeCell call(TreeView p) {
    return new AlertTreeCell();
    }
    });

    AntwortenLöschen
  17. I mean How can achieve 3 Root Alerts
    ->Alert (has no children)
    ->Grouped Alerts (has 3 children)
    |- Alert 1
    |- Alert 2
    |- Alert 3
    -> Alert (has no children)

    AntwortenLöschen
    Antworten
    1. You need a ObservableList of type Alert, something like:
      ObservableList<Alert> alerts = FXCollections.observableArrayList();
      Alert groupedAlert = new Alert();
      groupedAlert .getChildren().add(new Alert());
      groupedAlert .getChildren().add(new Alert());
      groupedAlert .getChildren().add(new Alert());

      alerts.getChildren().add(new Alert());
      alerts.getChildren().add(groupedAlert);
      alerts.getChildren().add(new Alert());

      treeView.setItems(alerts);

      Löschen
    2. Dieser Kommentar wurde vom Autor entfernt.

      Löschen
  18. first, thank you for this great tutorial. I managed to get your code working but its not exactly what i want, because it only shows the current value (probably hash?) in the cutom treeview. I want to show up the string propertys of each Treeitem instead of this. I tried modyfiing the addRecursivley method to insert strings but this won't work with generic type t.
    Could you give me some hints here?

    regards

    AntwortenLöschen
    Antworten
    1. You will need a cellFactory on the TreeView, which sets the text of the cell to the String of your generic type. There are many examples out there, how to write a cellFactory.

      Löschen
  19. Hallo Christian, ich suche ein Datenmodell für den TreeTableView. Kann man dein Datenmodell auch für den TreeTableView verwenden?
    Grüße
    MS-Tech

    AntwortenLöschen
    Antworten
    1. Hallo, sorry, kann ich dir leider nicht sagen. Habe mit TreeTableView noch nie gearbeitet.

      Löschen