Advanced Data Controls
This section will primarily address more advanced features you can leverage with data controls, particulary with the TableView
and ListView
.
TableView Advanced Column Resizing
The SmartResize policy brings the ability to intuitively resize columns by providing sensible defaults combined with powerful and dynamic configuration options.
To apply the resize policy to a TableView
we configure the columnResizePolicy
. For this exercise we will use a list of hotel rooms. This is our initial table with the SmartResize
policy activated:
tableview(rooms) {
column("#", Room::id)
column("Number", Room::number)
column("Type", Room::type)
column("Bed", Room::bed)
smartResize()
}
Here is a picture of the table with the SmartResize policy activated (Figure 13.1):
Figure 13.1
The default settings gave each column the space it needs based on its content, and gave the remaining width to the last column. When you resize a column by dragging the divider between column headers, only the column immediately to the right will be affected, which avoids pushing the columns to the right outside the viewport of the TableView
.
While this often presents a pleasant default, there is a lot more we can do to improve the user experience in this particular case. It is evident that our table did not need the full 800 pixels it was provided, but it gives us a nice chance to elaborate on the configuration options of the SmartResize
policy.
The bed column is way too big, and it seems more sensible to give the extra space to the Type column, since it might contain arbitrary long descriptions of the room. To give the extra space to the Type column, we change its column definition (Figure 13.2):
column("Type", Room::type).remainingWidth()
Figure 13.2
Now it is apparent the Bed column looks cramped, being pushed all the way to the left. We configure it to keep its desired width based on the content plus 50 pixels padding:
column("Bed", Room:bed).contentWidth(padding = 50.0)
The result is a much more pleasant visual impression (Figure 13.3) :
Figure 13.3
This fine-tuning may not seem like a big deal, but it means a lot to people who are forced to stare at your software all day! It is the little things that make software pleasant to use.
If the user increases the width of the Number column, the Type column will gradually decrease in width, until it reaches its default width of 10 pixels (the JavaFX default). After that, the Bed column must start giving away its space. We don't ever want the Bed column to be smaller that what we configured, so we tell it to use its content-based width plus the padding we added as its minimum width:
column("Bed", Room:bed).contentWidth(padding = 50.0, useAsMin = true)
Trying to decrease the Bed column either by explicitly expanding the Type column or implicitly by expanding the Number column will simply be denied by the resize policy. It is worth noting that there is also a useAsMax
choice for the contentWidth
resize type. This would effectively result in a hard-coded, unresizable column, based on the required content width plus any configured padding. This would be a good policy for the # column:
column("#", Room::id).contentWidth(useAsMin = true, useAsMax = true)
The rest of the examples will probably not benefit the user, but there are still other options at your disposal. Try to make the Number column 25% of the total table width:
column("Number", Room::number).pctWidth(25.0)
When you resize the TableView
, the Number column will gradually expand to keep up with our 25% width requirement, while the Type column gets the remaining extra space.
Figure 13.4
An alternative approach to percentage width is to specify a weight. This time we add weights to both Number and Type:
column("Number", Room::number).weigthedWidth(1.0)
column("Type", Room::type).weigthedWidth(3.0)
The two weighted columns share the remaining space after the other columns have received their fair share. Since the Type column has a weight that is three times bigger than the Number column, its size will be three times bigger as well. This will be reevaluated as the TableView
itself is resized.
Figure 13.5
This setting will make sure we keep the mentioned ratio between the two columns, but it might become problematic if the TableView
is resized to be very small. The the Number column would not have space to show all of its content, so we guard against that by specifying that it should never grow below the space it needs to show its content, plus some padding, for good measure:
column("Number", Room::number).weigthedWidth(1.0, minContentWidth = true, padding = 10.0)
This makes sure our table behaves nicely also under constrained width conditions.
Dynamic content resizing
Since some of the resizing modes are based on the actual content of the columns, they might need to be reevaluated even when the table or it's columns aren't resized. For example, if you add or remove content items from the backing list, the required content measurements might need to be updated. For this you can call the requestResize
function after you have manipulated the items:
SmartResize.POLICY.requestResize(tableView)
In fact, you can ask the TableView to ask the policy for you:
tableView.requestResize()
Statically setting the content width
In most cases you probably want to configure your column widths based on either the total available space or the content of the columns. In some cases you might want to configure a specific width, that that can be done with the prefWidth
function:
column("Bed", Room::bed).prefWidth(200.0)
A column with a preferred width can be resized, so to make it non-resizable, use the fixedWidth
function instead:
column("Bed", Room::bed).fixedWidth(200.0)
When you hard-code the width of the columns you will most likely end up with some extra space. This space will be awarded to the right most resizable column, unless you specify remainingWidth()
for one or more column. In that case, these columns will divide the extra space between them.
In the case where not all columns can be afforded their preferred width, all resizable columns must give away some of their space, but the SmartResize
Policy makes sure that the column with the biggest reduction potential will give away its space first. The reduction potential is the difference between the current width of the column and its defined minimum width.
Custom Cell Formatting in ListView
Even though the default look of a ListView
is rather boring (because it calls toString()
and renders it as text) you can modify it so that every cell is a custom Node
of your choosing. By calling cellCache()
, TornadoFX provides a convenient way to override what kind of Node
is returned for each item in your list (Figure 13.6).
class MyView: View() {
val persons = listOf(
Person("John Marlow", LocalDate.of(1982,11,2)),
Person("Samantha James", LocalDate.of(1973,2,4))
).observable()
override val root = listview(persons) {
cellFormat {
graphic = cache {
form {
fieldset {
field("Name") {
label(it.name)
}
field("Birthday") {
label(it.birthday.toString())
}
label("${it.age} years old") {
alignment = Pos.CENTER_RIGHT
style {
fontSize = 22.px
fontWeight = FontWeight.BOLD
}
}
}
}
}
}
}
}
class Person(val name: String, val birthday: LocalDate) {
val age: Int get() = Period.between(birthday, LocalDate.now()).years
}
Figure 13.6 - A custom cell rendering for ListView
The cellFormat
function lets you configure the text
and/or graphic
property of the cell whenever it comes into view on the screen. The cells themselves are reused, but whenever the ListView
asks the cell to update its content, the cellFormat
function is called. In our example we only assign to graphic
, but if you just want to change the string representation you should assign it to text
. It is completely legitimate to assign it to both text
and graphic
. The values will automatically be cleared by the cellFormat
function when a certain list cell is not showing an active item.
Note that assigning new nodes to the graphic
property every time the list cell is asked to update can be expensive. It might
be fine for many use cases, but for heavy node graphs, or node graphs where you utilize binding towards the UI components inside the cell, you should cache the resulting node so the Node graph will only be created once per node. This is done using the cache
wrapper in the above example.
Assign If Null
If you have a reason for wanting to recreate the graphic property for a list cell, you can use the assignIfNull
helper,
which will assign a value to any given property if the property doesn't already contain a value. This will make sure that
you avoid creating new nodes if updateItem
is called on a cell that already has a graphic property assigned.
cellFormat {
graphicProperty().assignIfNull {
label("Hello")
}
}
ListCellFragment
The ListCellFragment
is a special fragment which can help you manage ListView
cells. It extends Fragment
, and
includes some extra ListView
specific fields and helpers. You never instantiate these fragments manually, instead you
instruct the ListView
to create them as needed. There is a one-to-one correlation between ListCell
and ListCellFragment
instances. Only one ListCellFragment
instance will over its lifecycle be used to represent different items.
To understand how this works, let's consider a manually implemented ListCell
, essentially the way you would do in vanilla JavaFX. The updateItem
function will be called when the ListCell
should represent a new item, no item, or just an update to the same item. When you use a ListCellFragment
, you do not need to implement something akin to updateItem
, but the itemProperty
inside it will update to represent the new item automatically. You can listen to changes to the itemProperty
, or better yet, bind it directly to a ViewModel
. That way your UI can bind directly to the ViewModel
and no longer need to care about changes to the underlying item.
Let's recreate the form from the cellFormat
example using a ListCellFragment
. We need a ViewModel
which we will
call PersonModel
(Please see the Editing Models and Validation
chapter for a full explanation of the ViewModel
) For now,
just imagine that the ViewModel
acts as a proxy for an underlying Person
, and that the Person
can be changed while the
observable values in the ViewModel
remain the same. When we have created our PersonCellFragment
, we need to configure
the ListView
to use it:
listview(personlist) {
cellFragment<PersonCellFragment>()
}
Now comes the ListCellFragment
itself.
class PersonListFragment : ListCellFragment<Person>() {
val person = PersonModel().bindTo(this)
override val root = form {
fieldset {
field("Name") {
label(person.name)
}
field("Birthday") {
label(person.birthday)
}
label(stringBinding(person.age) { "$value years old" }) {
alignment = Pos.CENTER_RIGHT
style {
fontSize = 22.px
fontWeight = FontWeight.BOLD
}
}
}
}
}
Because this Fragment will be reused to represent different list items, the easiest approach is to bind the ui elements to the ViewModel's properties.
The name
and birthday
properties are bound directly to the labels inside the fields. The age string in the last label needs to be constructed using a stringBinding
to make sure it updates when the item changes.
While this might seem like slightly more work than the cellFormat
example, this approach makes it possible to leverage
everything the Fragment class has to offer. It also forces you to define the cell node graph outside of the builder hierarchy,
which improves refactoring possibilities and enables code reuse.
Additional helpers and editing support
The ListCellFragment
also have some other helper properties. They include the cellProperty
which will
update whenever the underlying cell changes and the editingProperty
, which will tell you if this the underlying list cell
is in editing mode. There are also editing helper functions called startEdit
, commitEdit
, cancelEdit
plus an onEdit
callback. The ListCellFragment
makes it trivial to utilize the existing editing capabilites of the ListView
. A complete example
can be seen in the TodoMVC demo application.