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 onSmartResize
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 JavaFXTableColumn
.
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 aListView
row with regards to selection styles. You can access theselectionModel
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.