Tornadofx: TreeTableView Builder

Created on 10 Mar 2016  路  40Comments  路  Source: edvin/tornadofx

I'd like to explore streamlining the creation of TreeTableView with a builder in a similar manner as TableView. Not quite sure of the details yet but I imagine the nested structure nature would make it a a good candidate.

Enhancement

All 40 comments

Nice! I'll actually need TreeTableView next week, so this is perfect timing. I don't know the API of it well enough yet, but I'll start reading up on it in the meantime.

Yeah me too, although I explored it a little a few months ago. Depending on the complexity this may become a joint effort.

We should probably do both TreeView and TreeTableView. I need to think about how to tackle this though.

https://docs.oracle.com/javafx/2/ui_controls/tree-view.htm
https://docs.oracle.com/javase/8/javafx/user-interface-tutorial/tree-table-view.htm

Man trying to figure out a design for just TreeView is challenging enough. Mind is spinning but I know there is a way to do this...

Trying to visualize a TreeView in its usage.

Have not used TreeView and TreeTableView that much, but I know they are incredibly painful to build because of the imperative grouping process. Maybe we can set an ObservableList as the backing list and functionally group up and nest items somehow. For example, we can group up employees by department, and then show each indvidual. Speaking out loud here...

val employees: ObservableList<Employee> = ...

treeview<Employee>(employees) { 
    treeitem<String>(Employee::department) {
        treeitem<String>(Employee::name) {
        }
    }
}

Okay, I got something.

I had to break a few rules because TreeView is not streamlined at all from a coding perspective. I had to create extensions of TreeView and TreeItem to give the notion of a "backing list" rather than looping and adding raw values. Using functional approaches, we can add functionality that satisfies common use cases for TreeView.

It is still rough but here is a proof-of-concept. Once we leverage some property mapping with reflection, we could have a pretty strong API.

class MyView : View() {

    override val root = VBox()

    init {
        val persons = FXCollections.observableArrayList(
                Person("Mary Hanes","Marketing"),
                Person("Steve Folley","Customer Service"),
                Person("John Ramsy","IT Help Desk"),
                Person("Erlick Foyes","Customer Service"),
                Person("Erin James","Marketing"),
                Person("Jacob Mays","IT Help Desk"),
                Person("Larry Cable","Customer Service")
        )

        with(root) {
            treeview<Person,String>(persons) {
                rootitem("Departments") {
                    groupedtreeitem({ it.dept }) {
                        treeitem({it.name}) {}
                    }
                }
            }
        }
    }
}

treeview_tornado

Backing Builder functions

fun <T,R> BackedTreeView<T,R>.rootitem(value: R, op: (BackedTreeItem<T,R>.() -> Unit)): BackedTreeItem<T,R> {
    val treeItem = BackedTreeItem(data,value)
    treeItem.op()
    this.root = treeItem
    return treeItem
}

fun <T,R> BackedTreeItem<T,R>.groupedtreeitem(groupMapping: (T) -> R, op: (BackedTreeItem<T,R>.() -> Unit)) {
    data.asSequence().map { GroupByItem(it,groupMapping.invoke(it)) }
            .groupBy { it.mapping }
            .forEach {
                children.add(BackedTreeItem(it.value.map { it.item },it.key).apply { this.op() })
            }
}
fun <T,R> BackedTreeItem<T,R>.treeitem(mapping: (T) -> R, op: (BackedTreeItem<T,R>.() -> Unit)) {
    data.forEach { children.add(TreeItem(mapping.invoke(it))) }
}
class BackedTreeView<T,R>(val data: List<T>): TreeView<R>()
class BackedTreeItem<T,R>(val data: List<T>, item: R): TreeItem<R>(item)

private class GroupByItem<T,R>(val item: T, val mapping: R)

I put in a PR #50. Definitely not polished yet but I think it is a start.

Just confirmed we can nest as many layers as we want! I subgrouped by name length under department.

treeview<Person,String>(persons) {
    rootitem("Departments") {
        groupedtreeitem({ it.dept }) {
            groupedtreeitem({it.name.length.toString()}) {
                treeitem({it.name}) {}
            }
        }
    }
}

treeview_tornado_2

Did you get a chance to look at this yet? It pains me that I had to extend and modify the TreeView, but I can't help but contemplate the functionality gained.

I'm sorry, I read the comment "Definitely not polished yet but I think it is a start." and though you were going to do some more work on it, so I didn't even look. Sorry, I'll get right on it :)

Haha sorry, I should have communicated better. If you do like the direction of this I probably will need your help polishing it up since I know little about reflection.

My mistake :) I'll just finish up the HttpClient task and then I'll get right on this :)

No rush, I'd offer to help but I haven't had much practiced experience with HttpClient. I'm going off the grid until this weekend so I'll be back online then.

Enjoy your down time, I just finished HttpClient and started playing with this. I have some ideas :)

I like your general idea, but we should (and will) be able to avoid the subclassing. Also, I would like the backing list to be observable, so if you add or change data, the items should pop up in the correct subnode. Also, need to investigate if TreeTableView supports deeper nesting (I don't know if it does), and then think about how to support that as well. Just thinking out loud for now.

My week is clearing up. I agree with the backing ObservableList. The problem is I haven't found any concept of a "backing list" for TreeView. It's all manual population from what I've seen and that's why it is subclassed. This will take a lot of thought.

And I believe the TreeTableView does support deeper nesting. I haven't thought that one through yet though.

I have an idea about how to do this in a clean way, without subclassing and support for deeper nesting. I'm trying to figure out a way to support update of the hierarchy when the backing list changes. I'm at the cabin most of the week, but at least I have lots of time to think :) Can't wait to get to a computer and test my theories. Will keep you posted!

Please do! Look forward to hearing what you think up.

I just checked in the reflection based builders and column creators, aligned with the TableView stuff. Now it's on to testing some theories about hierarchy/treeitem creation :)

I think I'm going to have to learn about reflection. I've never had a need for it since I'm a very business end-focused developer. But I'm finding myself building API's a lot more and it might come in handy.

I've found a solution that works the same for both TreeView and TreeTableView. It supports deep nesting and doesn't require subclassing :) Basically, it's just three lines of code:

fun <T> populateTree(item: TreeItem<T>, itemFactory: (T) -> TreeItem<T>, childFactory: (TreeItem<T>) -> List<T>?) {
    childFactory.invoke(item)?.map { itemFactory.invoke(it) }?.apply {
        item.children.setAll(this)
        forEach { populateTree(it, itemFactory, childFactory) }
    }
}

I've added extension functions to both TreeView and TreeTableView that will call the above function with the root item as the first argument. This is the extension function for TreeTableView:

fun <T> TreeTableView<T>.populate(itemFactory: (T) -> TreeItem<T> = { TreeItem(it) }, childFactory: (TreeItem<T>) -> List<T>?) =
        populateTree(root, itemFactory, childFactory)

The simplest cases becomes somewhat more verbose, but the intricate cases will be very easy to reason about.

First some context: Looking at JavaFX example for TreeTableView, it is clear that a common use case for the TreeTableView is to group related objects under a common discriminator, like for example "Person in Department". A problem arises becase the TreeTableView can only hold a single value type (Person), and to create the Department TreeItem that should contain the Person items in that department they simply create a "Person that looks like a Department".

I've made sure we support this practice, even though it _should_ be solved in a cleaner way. Let's create this result from a flat list of Person objects:

val persons = listOf(
        Person("Mary Hanes","Marketing"),
        Person("Steve Folley","Customer Service"),
        Person("John Ramsy","IT Help Desk"),
        Person("Erlick Foyes","Customer Service"),
        Person("Erin James","Marketing"),
        Person("Jacob Mays","IT Help Desk"),
        Person("Larry Cable","Customer Service")
)

// Create Person objects for the departments with the department name as Person.name
val departments = persons.map { it.department }.distinct().map { Person(it, "") }

val view = TreeView<Person>().apply {
    // Create root item
    root = TreeItem(Person("Departments", ""))

    // Make sure the text in each TreeItem is the name of the Person
    cellFormat { text = it.name }

    // Generate items. Children of the root item will contain departments
    populate { parent ->
        if (parent == root) departments else persons.filter { it.department == parent.value.name }
    }
}

The populate function is called for each TreeItem. If the TreeItem returns any children, the populate function will again be called for each child. This way, you can keep recursing as deep as you want.

We first check if the parent sent in is the root node. This is how we know to just return the list of departments. For the next iteration the parent will be a TreeItem holding a department. Simply filter out all Person objects that has the same department. This is where it gets ugly when you repurpose the same domain object for both Department and Person. To solve this, one should create a common data class, or simply type the TreeView as TreeView and create a data class for Department as well:

data class Department(val name: String)

// Create Department objects for the departments by getting distinct values from Person.department
val departments = persons.map { it.department }.distinct().map { Department(it) }

// Type safe way of extracting the correct TreeItem text
cellFormat {
    text = when (it) {
        is String -> it
        is Department -> it.name
        is Person -> it.name
        else -> throw IllegalArgumentException("Invalid value type")
    }
}

// Generate items. Children of the root item will contain departments, children of departments are filtered
populate { parent ->
    val value = parent.value
    if (parent == root) departments
    else if (value is Department) persons.filter { it.department == value.name }
    else null
}

Ofcourse this is more code, but it is cleaner, and becomes easier to maintain as complexity grows.

This approach really shines when you have multiple columns in a TreeTableView. I'll add examples later, now I have to walk the dog. What do you think? :)

Btw, I think your example was kind of neat, but the TreeItem did not contain the actual Person object, so when you select a TreeItem you wouldn't know what Person object it represents (other than via the name, which would get ugly quick :) When the TreeItem contains the Person, the need for a cellFormatter arises, but that's a good thing, I think - and inline with how the other List based controls work.

Oh, by the way - I haven't create the actual treeview and treetableview builders yet, but they should be trivial if we go for this approach.

Example with just strings, kind of like your initial example:

val view = TreeView<String>().apply {
    root = TreeItem("Departments")
    populate { item ->             
        if (item == root) persons.map { it.department }.distinct()
        else persons.filter { it.department == item.value }.map { it.name }
    }
}

As you can see, it becomes a bit more verbose, but atleast every case can be expressed in the same way.

Oh wow. This looks pretty effective. Let me play with this today but I like where this is going. I suppose it can support as many items as needed?

Levels I mean...

Sweet! I checked in builders as well so you can play with it. Version number in head is 1.4.0 now.

Yes, you can recurse as deep as you want. If your domain object already include the children, the populate method becomes super easy, something like it.value.children, that's it :) This will probably be the case in many business apps I suspect.

Haven't forgotten this, will test this soon.

I agree this is the better approach. As much as I don't like how TreeView and TreeTableView are designed API-wise, Kotlin makes declaring classes trivial and you can always declare ad-hoc "container" classes so you don't necessarily have to make a department an Employee.

Can you show me a multi-level example, like adding a grouping by birthday month? I really do like this direction, just trying to grasp some of the abstraction...

val view = TreeView<String>().apply {
    root = TreeItem("Departments")
    populate { item ->             
        if (item == root) persons.map { it.department }.distinct()
        else persons.filter { it.department == item.value }.map { it.name }
    }
}

OK, will create an example right now :) Btw, just added TreeTableView.resizeColumnsToFitContent() as well.

I saw that and I liked it a lot. Brilliant! I think I left an approval comment and closed that issue too.

Yes you did, but that was for TableView - just added the identical TreeTableView counterpart :)

Haha I have to stop speed-reading. Awesome, that never occurred to me we needed it there too.

The data for this view is:

val persons = FXCollections.observableArrayList(
    Person("Mary Hanes", "Marketing", "[email protected]", LocalDate.of(1978, SEPTEMBER, 17)),
    Person("Jacob Mays", "IT Help Desk", "[email protected]", LocalDate.of(1978, NOVEMBER, 13)),
    Person("John Ramsy", "IT Help Desk", "[email protected]", LocalDate.of(1978, SEPTEMBER, 17)),
    Person("Erlick Foyes", "Customer Service", "[email protected]", LocalDate.of(1978, MAY, 15)),
    Person("Steve Folley", "Customer Service", "[email protected]", LocalDate.of(1978, SEPTEMBER, 17)),
    Person("Erin James", "Marketing", "[email protected]", LocalDate.of(1978, SEPTEMBER, 17)),
    Person("Larry Cable", "Customer Service", "[email protected]", LocalDate.of(1978, NOVEMBER, 13))
)

EDIT: Just noticed, I didn't sort by birthday month like you asked, just by date, but hopefully it is still illustrative.

I created a common class for the TreeView called TreeEntry that has fields for the two columns in the table (name and email). Then I created three subclasses:

open class TreeEntry(val name: String, val email: String? = null)
class Department(name: String, val birthdays: List<Birthday>): TreeEntry(name)
class Person(name: String, val department: String, email: String, val birthday: LocalDate): TreeEntry(name, email)
class Birthday(val bd: LocalDate, val persons: List<Person>): TreeEntry(bd.toString())

They all call the TreeEntry constructor to populate the name, and the Person object also populates the optional email address column.

The Birthday class holds the persons with birthday on the given date and the Department holds a list of the pertinent Birthday objects.

The TableView defines two columns and the populate function discriminates on the type of object to return the correct children.

val tableview = TreeTableView<TreeEntry>().apply {
    column("Name", TreeEntry::name)
    column("Email", TreeEntry::email)

    root = TreeItem(TreeEntry("People by birthday"))
    root.isExpanded = true

    populate {
        val value = it.value
        when {
            it == root -> departments
            value is Department -> value.birthdays
            value is Birthday -> value.persons
            else -> null
        }
    }

    resizeColumnsToFitContent()
}

Populate extracts the value from the given tree item and then checks:

  1. Is the item the root? If so, return the departments
  2. Is the value a Department? If so, return all birthday objects under this department
  3. Is the value a Birthday? If so, return all the persons in this department with the given birthday
  4. If not, return null

In stead of creating all the subclasses ahead of time, the populate function could perform the aggregation directly. Which solution you choose is a matter of taste, but for large datasets, the aggregation could/should be done on a background thread.

This all sounds (and is) a bit intricate, but so is the problem being solved. The complexity of the solution is solely in aggregating the data - the populate function call is still rather beautiful :)

I think TreeTableView has two distinct use cases. One is like the above, where you mix apples, bananas and pears in the same view. The other is where the type has children, like for example a Person that has children. This use case is much more intuitive, and the other will always introduce som friction that I think is best solved with subclasses like above. There is no magic bullet to this I'm afraid, but I think we have removed verbosity from the API atleast. What's your feeling about this @thomasnield ?

To illustrate that last point, here is a more "natural" hierarchy:

class Person(val name: String, val department: String, val email: String, val employees: List<Person> = emptyList())

val persons = FXCollections.observableArrayList(
        Person("Mary Hanes", "IT Administration", "[email protected]", listOf(
            Person("Jacob Mays", "IT Help Desk", "[email protected]"),
            Person("John Ramsy", "IT Help Desk", "[email protected]"))),
        Person("Erin James", "Human Resources", "[email protected]", listOf(
            Person("Erlick Foyes", "Customer Service", "[email protected]"),
            Person("Steve Folley", "Customer Service", "[email protected]"),
            Person("Larry Cable", "Customer Service", "[email protected]")))
)

val tableview = TreeTableView<Person>().apply {
    column("Name", Person::name)
    column("Department", Person::department)
    column("Email", Person::email)

    /// Create the root item that holds all top level employees
    root = TreeItem(Person("Employees by leader", "", "", persons))

    // Always return employees under the current person
    populate { it.value.employees }

    // Expand the two first levels
    root.isExpanded = true
    root.children.forEach { it.isExpanded = true }

    // Resize to display all elements on the first two levels
    resizeColumnsToFitContent()
}

As you can see, there is no complexity left at all. The populate function merely returns all employees under the parent. Easy peasy :)

Wow nice. This populate() function worked out really well. _Really_ good job on this, this will be useful to me and a lot of other people. I'll need to study how this was done a little more, but it looks slick. Do you think these are ready to release?

I'm glad you liked it! The more I think about it, the more I like it myself as well :) The implementation is super small, 3 lines of code :)

Yes, I think they are ready. I'm adding an issue now for inject() support in the App class, then I'll update the documentation to use this feature in a couple of places and then I think we're ready. Is it anything else you would like to get into the 1.4.0 release?

I don't think so. There are a few obscure JavaFX controls out there that might need to be added as builders later, but I'm really happy with how this is shaping up. This handling of TreeView and TreeTableView might be even more impressive than the TableView builder.

Nice, I'll start preparing for the 1.4.0 release. This is a big one, since it's the first binary incompatible release.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

vlipovetskii picture vlipovetskii  路  6Comments

eibens picture eibens  路  4Comments

AlmasB picture AlmasB  路  8Comments

Leo-Thura picture Leo-Thura  路  6Comments

Mosch0512 picture Mosch0512  路  4Comments