TornadoFX编程指南,第5章,数据控件

译自《Data Controls》

数据控件

任何重要的应用程序都会使用数据,并为用户提供查看,操作和修改数据的方法,这对于用户界面开发来说不是一件小事。 幸运的是,TornadoFX简化了许多JavaFX数据控件,如ListViewTableViewTreeViewTreeTableView 。 这些控件以纯面向对象的方式设置起来可能会很麻烦。 但是使用构建器,通过函数性声明(functional declarations),我们可以以更加流畅的方式对所有这些控件进行编码。

ListView

ListView类似于ComboBox,但它会显示ScrollView的所有项目,并具有允许多选的选项,如图5.1所示。

listview {
    items.add("Alpha")
    items.add("Beta")
    items.add("Gamma")
    items.add("Delta")
    items.add("Epsilon")
    selectionModel.selectionMode = SelectionMode.MULTIPLE
}
TornadoFX编程指南,第5章,数据控件_第1张图片
图5.1

您还可以直接提供一个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
}
TornadoFX编程指南,第5章,数据控件_第2张图片
图5.2 - ListView自定义单元格渲染

cellFormat()函数允许您在单元格从屏幕上进入视图时配置其text和/或graphic属性。 单元格本身被重用(reused),但是每当ListView要求单元格更新它的内容时, 就会调用cellFormat()函数。 在我们的例子中,我们只赋值给graphic ,但如果您只想更改字符串表示,您应该赋值给text 。 同时赋值给textgraphic也是完全合法和正常的。 这些值将在列表单元格未显示活动项时,被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根据需要创建它们。 ListCellListCellFragment实例之间有一对一的关联。 一个ListCellFragment实例在其生命周期中,将被用于表示几个不同的项。

为了理解这是如何工作的,让我们考虑一个手动实现的ListCell,基本上这就是你将如何在vanilla JavaFX中做到这一点的。 当ListCell应该表示一个新的项,没有项或只是同一项的更新时,将调用updateItem()函数。 当您使用ListCellFragment时,您不需要实现类似于updateItem()的内容,但它内部的itemProperty将会自动更新以表示新的项。 您可以监听对itemProperty的更改,或者更好地将其直接绑定到ViewModel,以便您的UI可以直接绑定到ViewModel,因此不再需要关心基础项的更改。

让我们使用ListCellFragmentcellFormat示例中重新创建表单。 我们需要一个ViewModel,我们称之为PersonModel。 有关ViewModel的完整说明,请参阅“编辑模型和验证(Editing Models and Validation)”一章。 现在,假设ViewModel作为底层Person的代理,并且可以更改Person,而ViewModel中的可观察值保持不变。 当我们创建了我们的PersonCellFragment,我们需要配置ListView来使用它:

listview(personlist) {
    cellFragment(PersonCellFragment::class)
}

现在是ListCellFragment本身。

class PersonListFragment : ListCellFragment() {
    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的属性。

namebirthday属性直接绑定到字段内的标签。 最后一个标签中的age字符串需要使用stringBinding()构造,以确保在该项更改时会更新。

虽然这可能看起来比cellFormat()的例子稍微增加了一些工作,但是这种方法可以利用Fragment类提供的所有内容。 它还强制您在构建器层次结构之外定义单元格节点图(cell node graph),从而提高了重构的可能性并实现了代码重用。

额外的助手和编辑支持

ListCellFragment还有一些其他帮助属性。 它们包括cellProperty,它将在底层单元格更改时更新,而editProperty将告诉您底层列表单元格是否处于编辑模式。 还有编辑助手函数叫做startEditcommitEditcancelEdit加上一个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(图5.3)。

tableview(persons) {
    column("ID",Person::id)
    column("Name", Person::name)
    column("Birthday", Person::birthday)
    column("Age",Person::age)
}
TornadoFX编程指南,第5章,数据控件_第3张图片
图5.3

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单元格的值
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            
            }
        }
     }
}
TornadoFX编程指南,第5章,数据控件_第4张图片
图5.4

函数性地声明列值

如果需要将列的值映射到非属性( non-property)(例如函数),则可以使用非反射方式(non-reflection means)来提取该列的值。

假设你有一个 WeeklyReport类型,它有一个getTotal()函数接受DayOfWeek参数(星期一,星期二...星期日的枚举)。

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

假设你想为每个DayOfWeek创建一个列。 您无法映射到属性,但您可以显式映射每个WeeklyReport项以提取该DayOfWeek的每个值。

tableview {
    for (dayOfWeek in DayOfWeek.values()) {
        column(dayOfWeek.toString()) {
            ReadOnlyObjectWrapper(it.value.getTotal(dayOfWeek))
        }
    }
}

这更接近于JavaFX TableColumn的传统setCellValueFactory()

行扩展器(Row Expanders)

稍后我们将了解TreeTableView ,它具有 “parent” 和 “child” 行的概念,但是使用该控件的约束是父和子必须具有相同的列。 幸运的是,TornadoFX带有一个非常棒的实用程序,不仅可以显示给定行的“子表(child table)”,而且可以显示任何类型的Node控件。

假设我们有两种领域类型: RegionBranchRegion是地理区域,它包含一个或多个Branch项,它们是特定的业务运营地点(仓库,配送中心等)。 以下是这些类型和某些给定实例的声明。

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

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

TornadoFX编程指南,第5章,数据控件_第5张图片
图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)
            }
        }
    }
TornadoFX编程指南,第5章,数据控件_第6张图片
图5.6

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 {
    // 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 }
    }
}
TornadoFX编程指南,第5章,数据控件_第7张图片
图5.7

我们来分解这个过程:

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

首先我们收集来自persons列表的所有departments的清单。 但是,之后我们将每个department字符串放在一个Person对象中,因为TreeView只接受Person元素。 虽然这不是很直观,但这是TreeView的约束和设计。 我们必须让每个department是一个Person让其能被接受。

treeview {
    // 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,我们提供属于该departmentPerson对象的列表。

数据驱动TreeView

如果从populate返回的子列表是ObservableList,则该列表的任何更改将自动反映在TreeView中。 将为任何出现的新子项调用填充函数,删除的项也将导致与之关联的TreeItems被删除。

具有不同的类型的TreeView

使上一个例子中的每个实体都是一个Person不一定是直观的。 我们让每个部门都是一个Person,也是root “部门”。 对于更复杂的TreeView ,其中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中的列对于每个父元素和子元素都是相同的。 如果您希望父子之间的列不同,请使用如本章前面所述的TableViewrowExpander()

假设您有一个Person类,可选地具有employees参数,如果没有人向该Person报告,则该参数默认为空List

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

然后你有一个ObservableList持有这个类的实例。

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]")))
).observable()

您可以通过将TableViewTreeView所需的组件合并在一起来创建TreeTableView 。 您将需要调用populate()函数并设置根TreeItem

val treeTableView = TreeTableView().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(TreeItem("Items")) {
    column("Type", { it.value.valueProperty() })
    populate {
        if (it.value == "Items") tableData.keys
        else tableData[it.value]?.asList()
    }
}

数据网格

DataGrid类似于GridPane,因为它以灵活网格的形式显示了行和列项,但相似之处也在那里结束。 GridPane需要您将子节点添加到子列表中, DataGrid的数据驱动方式与TableViewListView相同。 您提供一个子项列表,并告诉它如何将这些子项转换为图形表示(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)
    }
}
图5.8

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())
        }
    }
}
TornadoFX编程指南,第5章,数据控件_第8张图片
图5.9

这次给网格提供了一个数字列表。 我们首先指定单元格高度和宽度为75个像素,是默认大小的一半。 我们还可以配置多选,以便能够选择多个单一元素。 这是通过扩展属性编写selectionModel.selectionMode = SelectionMode.MULTIPLE的快捷方式。 我们创建一个StackPane,它将一个Label放在Circle顶部。

您可能会想知道为什么标签这么大和而本体还是默认大小。 这是从默认样式表(default stylesheet)来的。 样式表是进一步定制的良好起点。 数据网格的所有属性都可以在代码和CSS中配置,样式表列出了所有可能的样式属性。

号码列表展示了如何支持多选。 当选择单元格时,它接收叫做selected的CSS伪类(CSS pseudo class)。 默认情况下,它的有关选择样式的行为大体上与ListView行相似。 您可以访问数据网格的selectionModel以监听选择更改,查看选择的项目等。

总结

函数性构造(Functional constructs)与TableViewTreeView以及本章中已经看到的其他数据控件一起工作得很好。 使用构建器模式,您可以快速且函数性地声明数据的显示方式。

在第7章中,我们将在布局中嵌入控件,轻松创建更复杂的UI。

你可能感兴趣的:(TornadoFX编程指南,第5章,数据控件)