译自《Data Controls》
数据控件
任何重要的应用程序都会使用数据,并为用户提供查看,操作和修改数据的方法,这对于用户界面开发来说不是一件小事。 幸运的是,TornadoFX简化了许多JavaFX数据控件,如ListView
, TableView
, TreeView
和TreeTableView
。 这些控件以纯面向对象的方式设置起来可能会很麻烦。 但是使用构建器,通过函数性声明(functional declarations),我们可以以更加流畅的方式对所有这些控件进行编码。
ListView
ListView
类似于ComboBox
,但它会显示ScrollView
的所有项目,并具有允许多选的选项,如图5.1所示。
listview<String> {
items.add("Alpha")
items.add("Beta")
items.add("Gamma")
items.add("Delta")
items.add("Epsilon")
selectionModel.selectionMode = SelectionMode.MULTIPLE
}
您还可以直接提供一个ObservableList
的项列表,并省略类型声明,因为它可以被推断。 使用ObservableList
也可以让列表中的更改自动反映在ListView
中。
val greekLetters = listOf("Alpha","Beta",
"Gamma","Delta","Epsilon").observable()
listview(greekLetters) {
selectionModel.selectionMode = SelectionMode.MULTIPLE
}
像大多数数据控件一样,请记住,默认情况下, ListView
将调用toString()
来为你的领域类(domain class)中的每个项目呈现文本。
自定义单元格格式化(Custom Cell formatting)
即使ListView
的默认外观相当无聊(因为它调用toString()
并将其呈现为文本),您还可以修改它,以便每个单元格都是您选择的自定义Node
。 通过调用cellCache()
,TornadoFX提供了一种方便的方式来重载列表中每项返回的Node
类型(图5.2)。
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
}
cellFormat()
函数允许您在单元格从屏幕上进入视图时配置其text
和/或graphic
属性。 单元格本身被重用(reused),但是每当ListView
要求单元格更新它的内容时, 就会调用cellFormat()
函数。 在我们的例子中,我们只赋值给graphic
,但如果您只想更改字符串表示,您应该赋值给text
。 同时赋值给text
和graphic
也是完全合法和正常的。 这些值将在列表单元格未显示活动项时,被cellFormat
函数自动清除。
请注意,每当列表单元被要求更新时,就将新节点分配给graphic
属性可能是昂贵的。 对于许多用例可能会很好,但是对于重节点图(heavy node graphs)或使用绑定到单元内的ui组件的节点图(node graphs where you utilize binding towards the ui components inside the cell),应该缓存结果节点(resulting node),以便每个节点只创建一次节点图(node graph)。 这在上面的例子中使用cache
包装器完成。
如果为空才赋值(Assign If Null)
如果您有想要重新创建列表单元格的graphic
属性的原因,则可以使用assignIfNull
帮助器,如果该属性尚未包含值,则将为任何给定属性分配一个值。 这将确保您在已分配graphic
属性的单元格上调用updateItem()
时避免创建新节点。
cellFormat {
graphicProperty().assignIfNull {
label("Hello")
}
}
ListCellFragment
ListCellFragment
是一个特殊的片段Fragment
,可以帮助您管理ListView
单元格。 它扩展了Fragment
,并包含一些额外的ListView
特定字段和帮助器。 您从不手动实例化这些片段,而是指示ListView
根据需要创建它们。 ListCell
和ListCellFragment
实例之间有一对一的关联。 一个ListCellFragment
实例在其生命周期中,将被用于表示几个不同的项。
为了理解这是如何工作的,让我们考虑一个手动实现的ListCell
,基本上这就是你将如何在vanilla JavaFX中做到这一点的。 当ListCell
应该表示一个新的项,没有项或只是同一项的更新时,将调用updateItem()
函数。 当您使用ListCellFragment
时,您不需要实现类似于updateItem()
的内容,但它内部的itemProperty
将会自动更新以表示新的项。 您可以监听对itemProperty
的更改,或者更好地将其直接绑定到ViewModel
,以便您的UI可以直接绑定到ViewModel
,因此不再需要关心基础项的更改。
让我们使用ListCellFragment
从cellFormat
示例中重新创建表单。 我们需要一个ViewModel
,我们称之为PersonModel
。 有关ViewModel
的完整说明,请参阅“编辑模型和验证(Editing Models and Validation)”一章。 现在,假设ViewModel
作为底层Person
的代理,并且可以更改Person
,而ViewModel
中的可观察值保持不变。 当我们创建了我们的PersonCellFragment
,我们需要配置ListView
来使用它:
listview(personlist) {
cellFragment(PersonCellFragment::class)
}
现在是ListCellFragment
本身。
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
}
}
}
}
}
因为此Fragment将被重用以表示不同的列表项,最简单的方法是将ui元素绑定到ViewModel
的属性。
name
和birthday
属性直接绑定到字段内的标签。 最后一个标签中的age
字符串需要使用stringBinding()
构造,以确保在该项更改时会更新。
虽然这可能看起来比cellFormat()
的例子稍微增加了一些工作,但是这种方法可以利用Fragment
类提供的所有内容。 它还强制您在构建器层次结构之外定义单元格节点图(cell node graph),从而提高了重构的可能性并实现了代码重用。
额外的助手和编辑支持
ListCellFragment
还有一些其他帮助属性。 它们包括cellProperty
,它将在底层单元格更改时更新,而editProperty
将告诉您底层列表单元格是否处于编辑模式。 还有编辑助手函数叫做startEdit
,commitEdit
,cancelEdit
加上一个onEdit
回调。 ListCellFragment
使得利用ListView
的现有编辑功能变得微不足道。 TodoMVC
演示应用程序中可以看到一个完整的例子。
TableView
可能在TornadoFX中最重要的构建器之一是TableView
。 如果您已经与JavaFX合作,您可能已经体验过面向对象的方式构建TableView
。 但是TornadoFX使用扩展函数提供了一个函数性的声明构造模式,大大简化了TableView
的编码。
假设您有领域类型,例如Person
。
class Person(val id: Int, val name: String, val birthday: LocalDate) {
val age: Int get() = Period.between(birthday, LocalDate.now()).years
}
拿几个Person
实例,把它们放在一个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))
).observable()
您可以使用一个函数性结构快速声明一个TableView
,其所有列,并将items
属性指定为ObservableList<Person>
(图5.3)。
tableview(persons) {
column("ID",Person::id)
column("Name", Person::name)
column("Birthday", Person::birthday)
column("Age",Person::age)
}
column()
函数是TableView
扩展函数,它接受header
名称,使用反射语法的映射属性(mapped property using reflection syntax)。 然后,TornadoFX将采用每个映射来渲染给定列中每个单元格的值。
如果要对
TableView
的列大小调整策略(resize policies)进行细粒度控制,有关SmartResize
策略的更多信息,请参阅附录A2。
使用“Property”属性
如果您遵循JavaFX Property
约定设置您的领域类(domain class),它将自动支持值编辑。
您可以以常规方式创建这些Property
对象,也可以使用TornadoFX的property
委托来自动创建这些Property
声明,如下所示。
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
}
您需要为每个属性创建xxxProperty()
函数,以便在使用反射时支持JavaFX的命名约定。 这可以很容易地通过中继他们的调用getProperty()
来检索给定字段的Property
来完成。 有关这些属性委托如何工作的详细信息,请参阅附录A1。
现在在TableView
,您可以使其可编辑,映射到属性,并应用适当的单元格编辑工厂(cell-editing factories)来使值可编辑。
override val root = tableview(persons) {
isEditable = true
column("ID",Person::idProperty).useTextField(IntegerStringConverter())
column("Name", Person::nameProperty).useTextField(DefaultStringConverter())
column("Birthday", Person::birthdayProperty).useTextField(LocalDateStringConverter())
column("Age",Person::age)
}
为了允许编辑和渲染,TornadoFX提供了一些列表的默认单元格工厂,可以通过扩展函数轻松调用。
扩展函数 | 描述 |
---|---|
useTextField() | 使用标准TextField 和其提供的StringConverter 来编辑值 |
useComboBox() | 通过ComboBox 编辑具有指定的ObservableList<T> 单元格的值 |
useChoiceBox() | 使用ChoiceBox 接受对单元格的值的改变 |
useCheckBox() | 为Boolean 值的列渲染可编辑的CheckBox
|
useProgressBar() | 将Double 值列的单元格渲染为的ProgressBar
|
Property语法替代品
如果你不关心在一个函数中暴露Property
(这在实际使用中是常见的),你可以这样表达你的类:
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
val age: Int get() = Period.between(birthday, LocalDate.now()).years
}
此替代模式将Property
作为字段成员公开而不是函数。 如果您喜欢上述语法,但又希望保留该函数,则可以将该属性设置为private并如下添加函数:
private val nameProperty = SimpleStringProperty(name)
fun nameProperty() = nameProperty
var name by nameProperty
从这些模式中选择都是一个品味的问题,您可以使用任何符合您的需求或最佳选择的版本。
您还可以使用TornadoFX插件将普通属性转换为JavaFX属性。 请参阅第13章了解如何做到这一点。
使用cellFormat()
还有其他适用于TableView
的扩展函数,可以帮助声明TableView
的流程。 例如,您可以在给定列上调用cellFormat()
函数来应用格式规则,例如突出显示“Age”值小于18的单元(图5.4)。
tableview(persons) {
column("ID", Person::id)
column("Name", Person::name)
column("Birthday", Person::birthday)
column("Age", Person::age).cellFormat {
text = it.toString()
style {
if (it < 18) {
backgroundColor += c("#8b0000")
textFill = Color.WHITE
} else {
backgroundColor += Color.WHITE
textFill = Color.BLACK
}
}
}
}
函数性地声明列值
如果需要将列的值映射到非属性( non-property)(例如函数),则可以使用非反射方式(non-reflection means)来提取该列的值。
假设你有一个 WeeklyReport
类型,它有一个getTotal()
函数接受DayOfWeek
参数(星期一,星期二...星期日的枚举)。
abstract class WeeklyReport(val startDate: LocalDate) {
abstract fun getTotal(dayOfWeek: DayOfWeek): BigDecimal
}
假设你想为每个DayOfWeek
创建一个列。 您无法映射到属性,但您可以显式映射每个WeeklyReport
项以提取该DayOfWeek
的每个值。
tableview<WeeklyReport> {
for (dayOfWeek in DayOfWeek.values()) {
column<WeeklyReport, BigDecimal>(dayOfWeek.toString()) {
ReadOnlyObjectWrapper(it.value.getTotal(dayOfWeek))
}
}
}
这更接近于JavaFX
TableColumn
的传统setCellValueFactory()
。
行扩展器(Row Expanders)
稍后我们将了解TreeTableView
,它具有 “parent” 和 “child” 行的概念,但是使用该控件的约束是父和子必须具有相同的列。 幸运的是,TornadoFX带有一个非常棒的实用程序,不仅可以显示给定行的“子表(child table)”,而且可以显示任何类型的Node
控件。
假设我们有两种领域类型: Region
和Branch
。 Region
是地理区域,它包含一个或多个Branch
项,它们是特定的业务运营地点(仓库,配送中心等)。 以下是这些类型和某些给定实例的声明。
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")
).observable()),
Region(2,"Alberta", "Canada",listOf(
Branch(3,"W","Calgary","AB")
).observable()),
Region(3,"Midwest", "USA", listOf(
Branch(4,"D","Chicago","IL"),
Branch(5,"D","Frankfort","KY"),
Branch(6, "W","Indianapolis", "IN")
).observable())
).observable()
我们可以创建一个TableView
,其中每一行都定义了一个rowExpander()
函数,我们可以随意创建任意一个Node
控件,该Node
是根据特定行的项目构建的。 在这种情况下,我们可以为给定Region
嵌套另一个TableView
,以显示属于它的所有Branch
项。 它将有一个“+
”按钮列来展开并显示此扩展控件(图5.5)。
有一些可配置性选项,例如“双击展开”行为和访问expanderColumn
(带有“+”按钮的列)以驱动填充(drive a padding)(图5.6)。
override val root = tableview(regions) {
column("ID",Region::id)
column("Name", Region::name)
column("Country", Region::country)
rowExpander(expandOnDoubleClick = true) {
paddingLeft = expanderColumn.width
tableview(it.branches) {
column("ID",Branch::id)
column("Facility Code",Branch::facilityCode)
column("City",Branch::city)
column("State/Province",Branch::stateProvince)
}
}
}
rowExpander()
函数不必返回TableView
而是返回任何类型的Node
,包括Forms
和其他简单或复杂的控件。
访问扩展器列(expander column)
您可能想要在实际的扩展器列(expander column)上操作或调用函数。 如果您使用双击来激活扩展,您可能不想在表中显示展开列。 首先我们需要引用扩展器:
val expander = rowExpander(true) { ... }
如果要隐藏扩展器列,只需调用expander.isVisible = false
。 您还可以通过调用expander.toggleExpanded(rowIndex)
以编程方式切换任何行的展开状态。
TreeView
TreeView
包含元素,其中每个元素又可能包含子元素。 通常,有箭头标志允许您扩展父元素以查看其子元素。 例如,我们可以在部门名称下嵌套员工。
传统上在JavaFX中,填充这些元素是相当麻烦和冗长的。 幸运的是TornadoFX比较简单。
假设你有一个简单的类型Person
和一个包含几个实例的ObservableList
。
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")
)
使用treeview()构建器创建TreeView
可以在功能上完成图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 }
}
}
我们来分解这个过程:
val departments = persons
.map { it.department }
.distinct().map { Person(it, "") }
首先我们收集来自persons
列表的所有departments
的清单。 但是,之后我们将每个department
字符串放在一个Person
对象中,因为TreeView
只接受Person
元素。 虽然这不是很直观,但这是TreeView
的约束和设计。 我们必须让每个department
是一个Person
让其能被接受。
treeview<Person> {
// Create root item
root = TreeItem(Person("Departments", ""))
接下来,我们为TreeView
指定最高层级的root
,所有部门将被嵌套其下,我们给它一个名为 “Departments” 的占位符Person
。
cellFormat { text = it.name }
然后我们指定cellFormat()
来渲染每个单元格上每个Person
(包括部门)的name
。
populate { parent ->
if (parent == root) departments else persons.filter { it.department == parent.value.name }
}
最后,我们调用populate()
函数,并提供一个指示如何向每个parent
提供子级的块。 如果parent
确实是root
,那么我们返回departments
。 否则, parent
是一个department
,我们提供属于该department
的Person
对象的列表。
数据驱动TreeView
如果从populate
返回的子列表是ObservableList
,则该列表的任何更改将自动反映在TreeView
中。 将为任何出现的新子项调用填充函数,删除的项也将导致与之关联的TreeItems被删除。
具有不同的类型的TreeView
使上一个例子中的每个实体都是一个Person
不一定是直观的。 我们让每个部门都是一个Person
,也是root
“部门”。 对于更复杂的TreeView<T>
,其中T
是未知的,可以是任意数量的类型,最好用星型投影(star projection)来使用T
型。
使用星形投影(star projection),您可以安全地填充嵌入到TreeView
的多个类型。
例如,您可以创建一个Department
类型并利用cellFormat()
来利用渲染的类型检查(utilize type-checking for rendering)。 然后,您可以使用一个将遍历每个元素的populate()
函数,并为每个元素指定子元素(如果有)。
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
TreeTableView
操作和功能与TreeView
类似,但它具有多个列,因为它是一个表。 请注意, TreeTableView
中的列对于每个父元素和子元素都是相同的。 如果您希望父子之间的列不同,请使用如本章前面所述的TableView
和rowExpander()
。
假设您有一个Person
类,可选地具有employees
参数,如果没有人向该Person
报告,则该参数默认为空List<Person>
。
class Person(val name: String,
val department: String,
val email: String,
val employees: List<Person> = emptyList())
然后你有一个ObservableList<Person>
持有这个类的实例。
val persons = listOf(
Person("Mary Hanes", "IT Administration", "mary.hanes@contoso.com", listOf(
Person("Jacob Mays", "IT Help Desk", "jacob.mays@contoso.com"),
Person("John Ramsy", "IT Help Desk", "john.ramsy@contoso.com"))),
Person("Erin James", "Human Resources", "erin.james@contoso.com", listOf(
Person("Erlick Foyes", "Customer Service", "erlick.foyes@contoso.com"),
Person("Steve Folley", "Customer Service", "steve.folley@contoso.com"),
Person("Larry Cable", "Customer Service", "larry.cable@contoso.com")))
).observable()
您可以通过将TableView
和TreeView
所需的组件合并在一起来创建TreeTableView
。 您将需要调用populate()
函数并设置根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()
}
还可以使用更多的像Map
这样的临时后备存储。 这样会看起来像这样:
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
类似于GridPane
,因为它以灵活网格的形式显示了行和列项,但相似之处也在那里结束。 GridPane
需要您将子节点添加到子列表中, DataGrid
的数据驱动方式与TableView
和ListView
相同。 您提供一个子项列表,并告诉它如何将这些子项转换为图形表示(graphical representation)。
它支持一次选择单个项目或多个项目,以便它可以用作例如图形查看器或其他组件的显示,您希望对底层数据进行可视化表示。 使用方式接近ListView
,但您可以在每个单元格内创建任意场景图形,因此可以轻松地为每个项可视化多个属性。
val kittens = listOf("http://i.imgur.com/DuFZ6PQb.jpg", "http://i.imgur.com/o2QoeNnb.jpg") // more items here
datagrid(kittens) {
cellCache {
imageview(it)
}
}
cellCache()
函数接收列表中的每个项,并且由于我们在示例中使用了一个Strings
列表,所以我们只需将该字符串传递给imageview()
构建器,即在每个表格单元格内创建一个ImageView
。 调用cellCache()
函数而不是cellFormat()
函数是重要的,以避免每次DataGrid
重绘时重新创建图像。 它将重用(reuse)这些项。
让我们创建一个涉及更多的场景图,并且还可以更改每个单元格的默认大小:
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())
}
}
}
这次给网格提供了一个数字列表。 我们首先指定单元格高度和宽度为75个像素,是默认大小的一半。 我们还可以配置多选,以便能够选择多个单一元素。 这是通过扩展属性编写selectionModel.selectionMode = SelectionMode.MULTIPLE
的快捷方式。 我们创建一个StackPane
,它将一个Label
放在Circle
顶部。
您可能会想知道为什么标签这么大和而本体还是默认大小。 这是从默认样式表(default stylesheet)来的。 样式表是进一步定制的良好起点。 数据网格的所有属性都可以在代码和CSS中配置,样式表列出了所有可能的样式属性。
号码列表展示了如何支持多选。 当选择单元格时,它接收叫做
selected
的CSS伪类(CSS pseudo class)。 默认情况下,它的有关选择样式的行为大体上与ListView行相似。 您可以访问数据网格的selectionModel
以监听选择更改,查看选择的项目等。
总结
函数性构造(Functional constructs)与TableView
, TreeView
以及本章中已经看到的其他数据控件一起工作得很好。 使用构建器模式,您可以快速且函数性地声明数据的显示方式。
在第7章中,我们将在布局中嵌入控件,轻松创建更复杂的UI。