Skip to content

Latest commit

 

History

History
558 lines (402 loc) · 24.3 KB

5_Data_Controls.md

File metadata and controls

558 lines (402 loc) · 24.3 KB

Data Controls

Any significant application works with data, and providing a means for users to view, manipulate, and modify data is not a trivial task for user interface development. Fortunately, TornadoFX streamlines many JavaFX data controls such as ListView, TableView, TreeView, and TreeTableView. These controls can be cumbersome to set up in a purely object-oriented way. But using builders through functional declarations, we can code all these controls in a much more streamlined way.

ListView

A ListView is similar to a ComboBox but it displays all items within a ScrollView and has the option of allowing multiple selections, as shown in Figure 5.1

listview<String> {
    items.add("Alpha")
    items.add("Beta")
    items.add("Gamma")
    items.add("Delta")
    items.add("Epsilon")
    selectionModel.selectionMode = SelectionMode.MULTIPLE
}

Figure 5.1

You can also provide it an ObservableList of items up front and omit the type declaration since it can be inferred. Using an ObservableList has the benefit that changes to the list will automatically be reflected in the ListView.

val greekLetters = listOf("Alpha","Beta",
        "Gamma","Delta","Epsilon").asObservable()

listview(greekLetters) {
    selectionModel.selectionMode = SelectionMode.MULTIPLE
}

Like most data controls, keep in mind that by default the ListView will call toString() to render the text for each item in your domain class. To render anything else, you will need to create your own custom cell formatting.

To read about custom cell formatting and nodes for a ListView, read the section "Custom Cell Formatting in ListView" in Part 2 - Advanced Data Controls

TableView

Probably one of the most significant builders in TornadoFX is the one for TableView. If you have worked with JavaFX, you might have experienced building a TableView in an object-oriented way. But TornadoFX provides a functional declaration construct pattern using extension functions that greatly simplify the coding of a TableView.

Say you have a domain type, such as Person.

class Person(val id: Int, val name: String, val birthday: LocalDate) {
    val age: Int get() = Period.between(birthday, LocalDate.now()).years
}

Take several instances of Person and put them in an ObservableList.

private val persons = listOf(
        Person(1,"Samantha Stuart",LocalDate.of(1981,12,4)),
        Person(2,"Tom Marks",LocalDate.of(2001,1,23)),
        Person(3,"Stuart Gills",LocalDate.of(1989,5,23)),
        Person(3,"Nicole Williams",LocalDate.of(1998,8,11))
).asObservable()

You can quickly declare a TableView with all of its columns using a functional construct, and specify the items property to an ObservableList<Person> (Figure 5.3).

tableview(persons) {
    readonlyColumn("ID",Person::id)
    readonlyColumn("Name", Person::name)
    readonlyColumn("Birthday", Person::birthday)
    readonlyColumn("Age",Person::age)
}

Notice the use of the readonlyColumn builder instead of the column builder. The latter is used for mutable property references, but for data classes and immutable properties we need to use the readonlyColumn builder.

Figure 5.3

The column() functions are extension functions for TableView accepting a header name and a mapped property using reflection syntax. TornadoFX will then take each mapping to render a value for each cell in that given column. A corresponding readonlyColumn() function is available to map immutable non-JavaFX properties.

If you want granular control over TableView column resize policies, see Appendix A2 for more information on SmartResize policies.

Using "Property" properties

If you follow the JavaFX Property conventions to set up your domain class, it will automatically support value editing.

You can create these Property objects the conventional way, or you can use TornadoFX's property delegates to automatically create these Property declarations as shown below.

import tornadofx.*

// WARNING: This syntax is not recommended, keep reading :)

class Person(id: Int, name: String, birthday: LocalDate) {
    var id by property(id)
    fun idProperty() = getProperty(Person::id)

    var name by property(name)
    fun nameProperty() = getProperty(Person::name)

    var birthday by property(birthday)
    fun birthdayProperty() = getProperty(Person::birthday)

    val age: Int get() = Period.between(birthday, LocalDate.now()).years
}

You need to create xxxProperty() functions for each property to support JavaFX's naming convention when it uses reflection. This can easily be done by relaying their calls to getProperty() to retrieve the Property for a given field. See the "Property Delegates" section in Part 2 for detailed information on how these property delegates work.

Now on the TableView, you can make it editable, map to the properties, and apply the appropriate cell-editing factories to make the values editable.

override val root = tableview(persons) {
    isEditable = true
    column("ID",Person::idProperty).makeEditable()
    column("Name", Person::nameProperty).makeEditable()
    column("Birthday", Person::birthdayProperty).makeEditable()
    readonlyColumn("Age",Person::age)
}

To allow editing and rendering, TornadoFX provides a few default cell factories you can invoke on a column easily through extension functions.

Extension Function Description
useTextField() Uses a standard TextField to edit values with a provided StringConverter
useComboBox() Edits a cell value via a ComboBox with a specified ObservableList<T> of applicable values
useChoiceBox() Accepts value changes to a cell with a ChoiceBox
useCheckBox() Renders an editable CheckBox for a Boolean value column
useProgressBar() Renders the cell as a ProgressBar for a Double value column
Property Syntax Alternatives

If you do not care about exposing the Property in a function (which is common in practial usage) you can express your class like this:

class Person(id: Int, name: String, birthday: LocalDate) {
    val idProperty = SimpleIntegerProperty(id)
    var id by idProperty

    val nameProperty = SimpleStringProperty(name)
    var name by nameProperty

    val birthdayProperty = SimpleObjectProperty(birthday)
    var birthday by birthdayProperty

    // Make age an observable value as well
    val ageProperty = birthdayProperty.objectBinding { Period.between(it, LocalDate.now()).years }
}

This alternative pattern exposes the Property as a field member instead of a function. If you like the above syntax but want to keep the function, you can make the property private and add the function like this:

private val nameProperty = SimpleStringProperty(name)
fun nameProperty() = nameProperty
var name by nameProperty

Choosing from these patterns are all a matter of taste, and you can use whatever version meets your needs or preferences best.

You can also convert plain properties to JavaFX properties using the TornadoFX Plugin. Refer to Chapter 12 to learn how to do this.

Using cellFormat()

There are other extension functions applied to TableView that can assist the flow of declaring a TableView. For instance, you can call a cellFormat() function on a given column to apply formatting rules, such as highlighting "Age" values less than 18 (Figure 5.4).

tableview(persons) {
    column("ID", Person::idProperty)
    column("Name", Person::nameProperty)
    column("Birthday", Person::birthdayProperty)
    column("Age", Person::ageProperty).cellFormat {
        text = it.toString()
        style {
            if (it < 18) {
                backgroundColor += c("#8b0000")
                textFill = Color.WHITE
            } else {
                backgroundColor += Color.WHITE
                textFill = Color.BLACK
            }
        }
     }
}

Figure 5.4

Accessing Nested Properties

Let's assume our Person object has a parent property which is also of type Person. To create a column for the parent name, we have several options. Our first attempt is simply extracting the name property manually:

column<Person, String>("Parent name", { it.value.parentProperty.value.nameProperty })

Notice how we cannot simply reference the property. We need to access the value provided in the callback to get to the actual instance and nest call to the nameProperty. While this works, it has one major drawback. If the parent changes, the TableView will not be updated. We can partially remedy this by defining the value for the property as the parent itself, and formatting its name:

column("Parent name", Person::parentProperty).cellFormat {
    textProperty().bind(it.parentProperty.value.nameProperty)
}

It might still not update right away, even though it would eventually become consistent as the TableView refreshes.

To create a binding that would reflect a change to the parent property immediately, consider using a select binding, which we will cover later.

column<Person, String>("Parent name", { it.value.parentProperty.select(Person::nameProperty) })

Declaring Column Values Functionally

If you need to map a column's value to a non-property (such as a function), you can use a non-reflection means to extract the values for that column.

Say you have a WeeklyReport type that has a getTotal() function accepting a DayOfWeek argument (an enum of Monday, Tuesday... Sunday).

abstract class WeeklyReport(val startDate: LocalDate) {
    abstract fun getTotal(dayOfWeek: DayOfWeek): BigDecimal
}

Let's say you wanted to create a column for each DayOfWeek. You cannot map to properties, but you can map each WeeklyReport item explicitly to extract each value for that DayOfWeek.

tableview<WeeklyReport> {
    for (dayOfWeek in DayOfWeek.values()) {
        column<WeeklyReport, BigDecimal>(dayOfWeek.toString()) {
            ReadOnlyObjectWrapper(it.value.getTotal(dayOfWeek))
        }
    }
}

This more closely resembles the traditional setCellValueFactory() for the JavaFX TableColumn.

Row Expanders

Later we will learn about the TreeTableView which has a notion of "parent" and "child" rows, but the constraint with this control is the parent and child must have the same columns. Fortunately, TornadoFX comes with an awesome utility to not only reveal a "child table" for a given row, but any kind of Node control.

Say we have two domain types: Region and Branch. A Region is a geographical zone, and it contains one or more Branch items which are specific business operation locations (warehouses, distribution centers, etc). Here is a declaration of these types and some given instances.

class Region(val id: Int, val name: String, val country: String, val branches: ObservableList<Branch>)

class Branch(val id: Int, val facilityCode: String, val city: String, val stateProvince: String)

val regions = listOf(
        Region(1,"Pacific Northwest", "USA",listOf(
                Branch(1,"D","Seattle","WA"),
                Branch(2,"W","Portland","OR")
        ).asObservable()),
        Region(2,"Alberta", "Canada",listOf(
                Branch(3,"W","Calgary","AB")
        ).asObservable()),
        Region(3,"Midwest", "USA", listOf(
                Branch(4,"D","Chicago","IL"),
                Branch(5,"D","Frankfort","KY"),
                Branch(6, "W","Indianapolis", "IN")
        ).asObservable())
).asObservable()

We can create a TableView where each row has a rowExpander() function defined, and there we can arbitrarily create any Node control built off that particular row's item. In this case, we can nest another TableView for a given Region to show all the Branch items belonging to it. It will have a "+" button column to expand and show this expanded control (Figure 5.5).

Figure 5.5

There are a few configurability options, like "expand on double-click" behaviors and accessing the expanderColumn (the column with the "+" button) to drive a padding (Figure 5.6).

override val root = tableview(regions) {
        readonlyColumn("ID",Region::id)
        readonlyColumn("Name", Region::name)
        readonlyColumn("Country", Region::country)
        rowExpander(expandOnDoubleClick = true) {
            paddingLeft = expanderColumn.width
            tableview(it.branches) {
                readonlyColumn("ID",Branch::id)
                readonlyColumn("Facility Code",Branch::facilityCode)
                readonlyColumn("City",Branch::city)
                readonlyColumn("State/Province",Branch::stateProvince)
            }
        }
    }

Figure 5.6

The rowExpander() function does not have to return a TableView but any kind of Node, including Forms and other simple or complex controls.

Accessing the Expander Column

You might want to manipulate or call functions on the actual expander column. If you activate expand on double click, you might not want to show the expander column in the table at all. First we need a reference to the expander:

val expander = rowExpander(true) { ... }

If you want to hide the expander column, just call expander.isVisible = false. You can also programmatically toggle the expanded state of any column by calling expander.toggleExpanded(rowIndex).

TreeView

The TreeView contains elements where each element may contain child elements. Typically arrows allow you to expand a parent element to see its children. For instance, we can nest employees under department names

Traditionally in JavaFX, populating these elements is rather cumbersome and verbose. Fortunately TornadoFX makes it relatively simple.

Say you have a simple type Person and an ObservableList containing several instances.

data class Person(val name: String, val department: String)

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")
        )

Creating a TreeView with the treeview() builder can be done functionally Figure 5.7).

// Create Person objects for the departments
// with the department name as Person.name

val departments = persons
    .map { it.department }
    .distinct().map { Person(it, "") }

treeview<Person> {
    // 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 }
    }
}

Figure 5.7

Let's break this down:

val departments = persons
    .map { it.department }
    .distinct().map { Person(it, "") }

First we gather a distinct list of all the departments derived from the persons list. But then we put each department String in a Person object since the TreeView only accepts Person elements. While this is not very intuitive, this is the constraint and design of TreeView. We must make each department a Person for it to be accepted.

treeview<Person> {
    // Create root item
    root = TreeItem(Person("Departments", ""))

Next we specify the highest root for the TreeView that all departments will be nested under, and we give it a placeholder Person called "Departments".

    cellFormat { text = it.name }

Then we specify the cellFormat() to render the name of each Person (including departments) on each cell.

   populate { parent ->
        if (parent == root) departments else persons.filter { it.department == parent.value.name }
    }

Finally, we call the populate() function and provide a block instructing how to provide children to each parent. If the parent is indeed the root, then we return the departments. Otherwise the parent is a department and we provide a list of Person objects belonging to that department.

Data driven TreeView

If the child list you return from populate is an ObservableList, any changes to that list will automatically be reflected in the TreeView. The populate function will be called for any new children that appears, and
removed items will result in removed TreeItems as well.

TreeView with Differing Types

It is not necessarily intuitive to make every entity in the previous example a Person. We made each department a Person as well as the root "Departments". For a more complex TreeView<T> where T is unknown and can be any number of types, it is better to specify type T as Any.

Using star projection, you can safely populate multiple types nested into the TreeView.

For instance, you can create a Department type and leverage cellFormat() to utilize type-checking for rendering. Then you can use a populate() function that will iterate over each element, and you specify the children for each element (if any).

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
}

TreeTableView

The TreeTableView operates and functions similarly to a TreeView, but it has multiple columns since it is a table. Please note that the columns in a TreeTableView are the same for each parent and child element. If you want the columns to be different between parent and child, use a TableView with a rowExpander() as covered earlier in this chapter.

Say you have a Person class that optionally has an employees parameter, which defaults to an empty List<Person> if nobody reports to that Person.

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

Then you have an ObservableList<Person> holding instances of this class.

val persons = listOf(
        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]")))
).asObservable()

You can create a TreeTableView by merging the components needed for a TableView and TreeView together. You will need to call the populate() function as well as set the root TreeItem.

val treeTableView = TreeTableView<Person>().apply {
    column("Name", Person::nameProperty)
    column("Department", Person::departmentProperty)
    column("Email", Person::emailProperty)

    /// 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()
}

It is also possible to work with more of an ad hoc backing store like a Map. That would look something like this:

val tableData = mapOf(
    "Fruit" to arrayOf("apple", "pear", "Banana"),
    "Veggies" to arrayOf("beans", "cauliflower", "cale"),
    "Meat" to arrayOf("poultry", "pork", "beef")
)

treetableview<String>(TreeItem("Items")) {
    column<String, String>("Type", { it.value.valueProperty() })
    populate {
        if (it.value == "Items") tableData.keys
        else tableData[it.value]?.asList()
    }
}

DataGrid

A DataGrid is similar to the GridPane in that it displays items in a flexible grid of rows and columns, but the similarities ends there. While the GridPane requires you to add Nodes to the children list, the DataGrid is data driven in the same way as TableView and ListView. You supply it with a list of items and tell it how to convert those children to a graphical representation.

It supports selection of either a single item or multiple items at a time so it can be used as for example the display of an image viewer or other components where you want a visual representation of the underlying data. Usage wise it is close to a ListView, but you can create an arbitrary scene graph inside each cell so it is easy to visualize multiple properties for each item.

val kittens = listOf("https://i.imgur.com/DuFZ6PQb.jpg", "https://i.imgur.com/o2QoeNnb.jpg") // more items here

datagrid(kittens) {
    cellCache {
         imageview(it)
    }
}

Figure 5.8

The cellCache function receives each item in the list, and since we used a list of Strings in our example, we simply pass that string to the imageview() builder to create an ImageView inside each table cell. It is important to call the cellCache function instead of the cellFormat function to avoid recreating the images every time the DataGrid redraws. It will reuse the items.

Let's create a scene graph that is a little bit more involved, and also change the default size of each cell:

val numbers = (1..10).toList()

datagrid(numbers) {
    cellHeight = 75.0
    cellWidth = 75.0

    multiSelect = true

    cellCache {
        stackpane {
            circle(radius = 25.0) {
                fill = Color.FORESTGREEN
            }
            label(it.toString())
        }
    }
}

Figure 5.9

The grid is supplied with a list of numbers this time. We start by specifying a cell height and width of 75 pixels, half of the default size. We also configure multi select to be able to select more than a single element. This is a shortcut of writing selectionModel.selectionMode = SelectionMode.MULTIPLE via an extension property. We create a StackPane that stacks a Label on top of a Circle.

You might wonder why the label got so big and bold by default. This is coming from the default stylesheet. The stylesheet is a good starting point for further customization. All properties of the data grid can be configured in code as well as in CSS, and the stylesheet lists all possible style properties.

The number list showcased multiple selection. When a cell is selected, it receives the CSS pseudo class of selected. By default it will behave mostly like a ListView row with regards to selection styles. You can access the selectionModel of the data grid to listen for selection changes, see what items are selected etc.

Summary

Functional constructs work well with data controls like TableView, TreeView, and others we have seen in this chapter. Using the builder patterns, you can quickly and functionally declare how data is displayed.

In Chapter 7, we will embed controls in layouts to create more complex UI's easily.