JavaFX: Data Model for TreeTable
javafxWhen you use a ListView or a TableView in JavaFX you can easily separate the definition of the List/Table and the data that is displayed by the component.
For example when you use a TableView you could create an ObservableList
containing your data objects and set
CellValueFactories
to define what value will be displayed by a specific column. Look at the
code from the Oracle docs for a simple example.
The key point is: When you add/remove an instance of your data in the observable list (your data model) the table will update itself accordingly. You don't need to manually add a new row to the table or anything like that. This is a realy cool way to separate the visualization of the table from the data to be displayed.
TreeView and TreeTableView
When you create an application with the TreeView component or the new TreeTableView that was added in JavaFX 8 you will probably be disappointed because there is no easy way of separation available for these components out of the box.
Think of the following example requirements:
You like to create a simple todo app to structure your tasks. Every task can have several sub-tasks.
public class Task {
StringProperty description = new SimpleStringProperty();
ObservableList<Task> subtasks = FXCollections.observableArrayList();
// getter / setter
}
This is a recursive data-structure and a TreeTableView would be the perfect visualization for this use case.
Ideally you would again try to separate the concerns of visualisation and data management from each other: When you add a sub task to one of your existing task elements the TreeTable should update itself automatically.
Sadly there is no such functionality provided by the TreeView/TreeTableView class out of the box (as far as I know. Let me know if I'm wrong).
Instead you have to create a new TreeItem for every task that is added to your data model by hand.
Look at the following example code:
public void start(Stage stage) throws Exception {
// 1.
TreeTableView<Task> tree = new TreeTableView<>();
tree.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
TreeTableColumn<Task, String> descriptionColumn = new TreeTableColumn<>("Description");
tree.getColumns().add(descriptionColumn);
descriptionColumn.setCellValueFactory(param -> param.getValue().getValue().descriptionProperty());
// 2.
Task root = new Task("ToDo");
final Task shoppingTask = new Task("Shopping");
shoppingTask.getSubtasks().add(new Task("Buy milk"));
shoppingTask.getSubtasks().add(new Task("Buy bread"));
shoppingTask.getSubtasks().add(new Task("Buy lemonade"));
root.getSubtasks().add(shoppingTask);
// 3.
final TreeItem<Task> rootItem = new TreeItem<>(root);
tree.setRoot(rootItem);
// 4.
addTreeItemsRecursive(root, rootItem);
VBox parent = new VBox();
parent.getChildren().add(tree);
stage.setScene(new Scene(parent));
stage.show();
}
// 5.
private void addTreeItemsRecursive(Task task, TreeItem<Task> item){
for (Task subtask : task.getSubtasks()) {
TreeItem<Task> subTaskItem = new TreeItem<>(subtask);
item.getChildren().add(subTaskItem);
addTreeItemsRecursive(subtask, subTaskItem);
}
}
At first we define the TreeTable (1.) with a single column that will show the description
value of the task.
After that we create a task instance that will become the root element of our tree. Additionally we are adding an example subtask ("shopping") which itself contains some subtasks.
Now we need to connect our TreeTable with our data. To do this we need to create a TreeItem
instance for our root task
and use the setRoot()
method (3.). Note that at this point the TreeTable will only show the root task but no sub tasks
at all. Instead we have to create TreeItem
instances for all our Subtasks by hand. To simplyfy this a little I have
created a method addTreeItemsRecursive
that is doing exactly what it's name suggests: Go through all subtasks of the
given task and add a TreeItem for this subtask. Then apply this method to each subtask recursivly (5.).
When you look at this code you will see that it isn't very smart at all and has some disadvantages: What if I add annother subtask later? What if I remove a subtask? In both cases the TreeTable won't update itself automatically.
Recursive TreeItem
To solve this I've created a little helper class RecursiveTreeItem
(maybe someone has a better name for it ;-)) which
extends TreeItem
. With this class you can write the code as follows:
public void start(Stage stage) throws Exception {
// 1. and 2. stays the same
// ...
// 3.
TreeItem<Task> rootItem = new RecursiveTreeItem<Task>(root, Task::getSubtasks);
tree.setRoot(rootItem);
// ..,
}
To use this class you specify the root Task element and a function ('Task:getSubtasks') that can be used to get the
sub-items from an item. The function has to have the signature Callback<T, ObservableList<T>>
: It takes an instance of
T
(in our example Task
) and returns an observable List of T
representing the sub items. The RecursiveTreeItem
will take care for updates of the TreeTable when the data structure is updated.
The code looks like this (notice: This code isn't handling icons correctly. At the end of this blog post I've posted an updated version):
public class RecursiveTreeItem<T> extends TreeItem<T> {
private Callback<T, ObservableList<T>> childrenFactory;
public RecursiveTreeItem(Callback<T, ObservableList<T>> func){
this(null, func);
}
public RecursiveTreeItem(final T value, Callback<T, ObservableList<T>> func){
this(value, (Node) null, func);
}
public RecursiveTreeItem(final T value, Node graphic, Callback<T, ObservableList<T>> func){
super(value, graphic);
this.childrenFactory = func;
if(value != null) {
addChildrenListener(value);
}
valueProperty().addListener((obs, oldValue, newValue)->{
if(newValue != null){
addChildrenListener(newValue);
}
});
}
private void addChildrenListener(T value){
final ObservableList<T> children = childrenFactory.call(value);
children.forEach(child ->
RecursiveTreeItem.this.getChildren().add(
new RecursiveTreeItem<>(child, getGraphic(), childrenFactory)));
children.addListener((ListChangeListener<T>) change -> {
while(change.next()){
if(change.wasAdded()){
change.getAddedSubList()
.forEach(t->
RecursiveTreeItem.this.getChildren().add(
new RecursiveTreeItem<>(t, getGraphic(), childrenFactory)));
}
if(change.wasRemoved()){
change.getRemoved().forEach(t->{
final List<TreeItem<T>> itemsToRemove = RecursiveTreeItem.this.getChildren()
.stream()
.filter(treeItem ->
treeItem.getValue().equals(t)).collect(Collectors.toList());
RecursiveTreeItem.this.getChildren().removeAll(itemsToRemove);
});
}
}
});
}
}
I don't want to describe every line as I think it's pretty straight forward. When the RecursiveTreeItem
is created the
first thing I'm doing is to recursivly go through all sub items and create RecursiveTreeItems
for each of them. After
that I'm adding a listener to the observable list of sub items to react on list changes.
This code isn't battle tested and there are probably some cases that I haven't taken care of (for example I'm only
reacting to wasRemoved
and wasAdded
events). If you have ideas for improvements or are a developer of an (open
source) component library and this could be helpful for you, feel free to use the code as you like (and let me know
:-)).
To see the class in action you can take a look at my toy project
structure-list which is a Todo app prototype. The actual
RecursiveTreeItem
class can be found
here.
A limitation of this approach is that it only works for data models with a single type. In the example I have a Task that has many Sub-Tasks. For more complex models this won't work. Think of a Scrum-like model where a Project consists of many Sprints consisting of many Stories consisting of many Tasks ... you get the point. Maybe there is a clever solution for such situations too?
-- EDIT --
In the previous code of RecursiveTreeItem
the handling of graphics isn't working correctly. The problem is in the
third constructor that takes a Node grpahic
as second argument. This is based on the original constructor of
TreeItem
and therefore the graphics node is immediately passed to the super constructor. After that in the listener
that checks for added items, new RecursiveTreeItem
instances are created recursivly and the same graphics instance
(via getGraphic()
) is used for each new tree item. This doesn't work. In JavaFX a node can only appear once in the
scenegraph. If you add a node a second time to the scenegraph, it will disappear at the previous position. This is
happening here too. With the previous code only the last added tree item will have an icon. I haven't found this bug
earlier because in my example application I wasn't using icons for the tree nodes.
The solution is quite simple. The RecursiveTreeItem
needs an API that allows the user to create a new graphics
instance every time a new tree item is created. So instead of passing a graphics instance to the constructor of
RecursiveTreeItem
, it now takes a (factory-)function as constructor argument. This function has to return a new
graphics instance when applied. It will get the current item value as argument so that different icons can be used for
different types of values. The new code is this:
public class RecursiveTreeItem<T> extends TreeItem<T> {
private Callback<T, ObservableList<T>> childrenFactory;
private Callback<T, Node> graphicsFactory;
public RecursiveTreeItem(Callback<T, ObservableList<T>> childrenFactory){
this(null, childrenFactory);
}
public RecursiveTreeItem(final T value, Callback<T, ObservableList<T>> childrenFactory){
this(value, (item) -> null, childrenFactory);
}
public RecursiveTreeItem(final T value, Callback<T, Node> graphicsFactory, Callback<T, ObservableList<T>> childrenFactory){
super(value, graphicsFactory.call(value));
this.graphicsFactory = graphicsFactory;
this.childrenFactory = childrenFactory;
if(value != null) {
addChildrenListener(value);
}
valueProperty().addListener((obs, oldValue, newValue)->{
if(newValue != null){
addChildrenListener(newValue);
}
});
this.setExpanded(true);
}
private void addChildrenListener(T value){
final ObservableList<T> children = childrenFactory.call(value);
children.forEach(child -> RecursiveTreeItem.this.getChildren().add(
new RecursiveTreeItem<>(child, this.graphicsFactory, childrenFactory)));
children.addListener((ListChangeListener<T>) change -> {
while(change.next()){
if(change.wasAdded()){
change.getAddedSubList().forEach(t-> RecursiveTreeItem.this.getChildren().add(
new RecursiveTreeItem<>(t, this.graphicsFactory, childrenFactory)));
}
if(change.wasRemoved()){
change.getRemoved().forEach(t->{
final List<TreeItem<T>> itemsToRemove = RecursiveTreeItem.this
.getChildren()
.stream()
.filter(treeItem -> treeItem.getValue().equals(t))
.collect(Collectors.toList());
RecursiveTreeItem.this.getChildren().removeAll(itemsToRemove);
});
}
}
});
}
}
Thanks to Gary White for finding and reporting this issue.