译自《Editing Models and Validation》
编辑模型和验证
作为开发人员,TornadoFX不会对你强制任何特定的架构模式,它对MVC, MVP两者及其衍生模式都工作得很好。
为了帮助实现这些模式,TornadoFX提供了一个名为ViewModel
的工具,可帮助您清理您的UI和业务逻辑,为您提供回滚/提交(rollback/commit)和脏状态检查(dirty state checking)等功能 。 这些模式是手动实现的难点或麻烦,所以建议在需要时利用ViewModel
和ViewModelItem
。
通常,您将在大多数情况下使用ViewModelItem
,而非ViewModel
,但是...
典型用例
假设你有一个给定的领域类型(domain type)的Person
。 我们允许其两个属性为空,以便用户稍后输入。
class Person(name: String? = null, title: String? = null) {
val nameProperty = SimpleStringProperty(this, "name", name)
var name by nameProperty
val titleProperty = SimpleStringProperty(this, "title", title)
var title by titleProperty
}
考虑一个Master/Detail视图,其中有一个TableView
显示人员列表,以及可以编辑当前选定的人员信息的Form
。 在讨论ViewModel
之前,我们将创建一个不使用ViewModel
的View
版本。
以下是我们第一次尝试构建的代码,它有一些我们将要解决的问题。
import javafx.scene.control.TableView
import javafx.scene.control.TextField
import javafx.scene.layout.BorderPane
import tornadofx.*
class Person(name: String? = null, title: String? = null) {
val nameProperty = SimpleStringProperty(this, "name", name)
var name by nameProperty
val titleProperty = SimpleStringProperty(this, "title", title)
var title by titleProperty
}
class PersonEditor : View("Person Editor") {
override val root = BorderPane()
var nameField : TextField by singleAssign()
var titleField : TextField by singleAssign()
var personTable : TableView by singleAssign()
// Some fake data for our table
val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()
var prevSelection: Person? = null
init {
with(root) {
// TableView showing a list of people
center {
tableview(persons) {
personTable = this
column("Name", Person::nameProperty)
column("Title", Person::titleProperty)
// Edit the currently selected person
selectionModel.selectedItemProperty().onChange {
editPerson(it)
prevSelection = it
}
}
}
right {
form {
fieldset("Edit person") {
field("Name") {
textfield() {
nameField = this
}
}
field("Title") {
textfield() {
titleField = this
}
}
button("Save").action {
save()
}
}
}
}
}
}
private fun editPerson(person: Person?) {
if (person != null) {
prevSelection?.apply {
nameProperty.unbindBidirectional(nameField.textProperty())
titleProperty.unbindBidirectional(titleField.textProperty())
}
nameField.bind(person.nameProperty())
titleField.bind(person.titleProperty())
prevSelection = person
}
}
private fun save() {
// Extract the selected person from the tableView
val person = personTable.selectedItem!!
// A real application would persist the person here
println("Saving ${person.name} / ${person.title}")
}
}
我们定义一个由BorderPane
中心的TableView
和右侧Form
组成的View
。 我们为表单域和表本身定义一些属性,以便稍后引用它们。
当我们构建表时,我们将一个监听器附加到所选项目,从而当表格的选择更改时,我们可以调用editPerson()
函数。 editPerson()
函数将所选人员的属性绑定到表单中的文本字段。
我们初次尝试的问题
乍看起来可能还不错,但是当我们深入挖掘时,有几个问题。
手动绑定(Manual binding)
每次表中的选择发生变化时,我们必须手动取消绑定/重新绑定表单域的数据。 除了增加的代码和逻辑,还有另一个巨大的问题:文本字段中的每个变化都会导致数据更新,这种更改甚至将反映在表中。 虽然这可能看起来很酷,在技术上是正确的,但它提出了一个大问题:如果用户不想保存更改,该怎么办? 我们没有办法回滚。 所以为了防止这一点,我们必须完全跳过绑定,并手动从文本字段提取值,然后在保存时创建一个新的Person
对象。 事实上,这是许多应用程序中都能发现的一种模式,大多数用户都希望这样做。 为此表单实现“重置”按钮,将意味着使用初始值管理变量,并再次将这些值手动赋值给文本字段。
紧耦合(Tight Coupling)
另一个问题是,当它要保存编辑的人的时候,保存函数必须再次从表中提取所选项目。 为了能这么做,保存函数必须知道TableView
。 或者,它必须知道文本字段,像editPerson()
函数这样,并手动提取值来重建一个Person
对象。
ViewModel简介
ViewModel
是TableView
和Form
之间的调解器。 它作为文本字段中的数据和实际Person
对象中的数据之间的中间人。 如你所见,代码要短得多,容易理解。 PersonModel
的实现代码将很快显示出来。 现在只关注它的用法。
class PersonEditor : View("Person Editor") {
override val root = BorderPane()
val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()
val model = PersonModel(Person())
init {
with(root) {
center {
tableview(persons) {
column("Name", Person::nameProperty)
column("Title", Person::titleProperty)
// Update the person inside the view model on selection change
model.rebindOnChange(this) { selectedPerson ->
person = selectedPerson ?: Person()
}
}
}
right {
form {
fieldset("Edit person") {
field("Name") {
textfield(model.name)
}
field("Title") {
textfield(model.title)
}
button("Save") {
enableWhen(model.dirty)
action {
save()
}
}
button("Reset").action {
model.rollback()
}
}
}
}
}
}
private fun save() {
// Flush changes from the text fields into the model
model.commit()
// The edited person is contained in the model
val person = model.person
// A real application would persist the person here
println("Saving ${person.name} / ${person.title}")
}
}
class PersonModel(var person: Person) : ViewModel() {
val name = bind { person.nameProperty }
val title = bind { person.titleProperty }
}
这看起来好多了,但到底究竟发生了什么呢? 我们引入了一个称为PersonModel
的ViewModel
的子类。 该模型持有一个Person
对象,并具有name
和title
字段的属性。 在我们查看其余客户端代码后,我们将进一步讨论该模型。
请注意,我们不会引用TableView
或文本字段。 除了很少的代码,第一个大的变化是我们更新模型中的Person
的方式:
model.rebindOnChange(this) { selectedPerson ->
person = selectedPerson ?: Person()
}
rebindOnChange()
函数将TableView
作为一个参数,以及一个在选择更改时被调用的函数。 这对ListView
, TreeView
, TreeTableView
和任何其他ObservableValue
都可以工作。 此函数在模型上调用,并将selectedPerson
作为其单个参数。 我们将所选人员赋值给模型的person
属性,或者如果选择为空/ null,则将其指定为新Person
。 这样,我们确保总是有模型呈现的数据。
当我们创建TextField
时,我们将模型属性直接绑定给它,因为大多数Node
都可以接受一个ObservableValue
来绑定。
field("Name") {
textfield(model.name)
}
即使选择更改,模型属性仍然保留,但属性的值将更新。 我们完全避免了此前尝试的手动绑定。
该版本的另一个重大变化是,当我们键入文本字段时,表中的数据不会更新。 这是因为模型已经从person
对象暴露了属性的副本,并且在调用model.commit()
之前不会写回到实际的person
对象中。 这正是我们在save
函数中所做的。 一旦commit()
被调用,界面对象(facade)中的数据就会被刷新回到我们的person
对象中,现在表格将反映我们的变化。
回滚
由于模型持有对实际Person
对象的引用,我们可以重置文本字段以反映我们的Person
对象中的实际数据。 我们可以添加如下所示的重置按钮:
button("Reset").action {
model.rollback()
}
当按下按钮时,任何更改将被丢弃,文本字段再次显示实际的Person
对象的值。
PersonModel
我们从来没有解释过PersonModel
的工作原理,您可能一直在想知道PersonModel
如何实现。 这里就是:
class PersonModel(var person: Person) : ViewModel() {
val name = bind { person.nameProperty }
val title = bind { person.titleProperty }
}
它可以容纳一个Person
对象,它通过bind
代理定义了两个看起来奇怪的属性, name
和title
。 是的,它看起来很奇怪,但是有一个非常好的理由。 bind
函数的{ person.nameProperty() }
参数是一个返回属性的lambda
。 此返回的属性由ViewModel
进行检查,并创建相同类型的新属性。 它被放在ViewModel
的name
属性中。
当我们将文本字段绑定到模型的name
属性时,只有当您键入文本字段时才会更新该副本。 ViewModel
跟踪哪个实体属性属于哪个界面对象(facade),当您调用commit
,将从界面对象(facade)的值刷入实际的后备属性(backing property)。 另一方面,当您调用rollback
时会发生恰恰相反的情况:实际属性值被刷入界面对象(facade)。
实际属性包含在函数中的原因在于,这样可以更改person
变量,然后从该新的person
中提取属性。 您可以在下面阅读更多信息(重新绑定,rebinding)。
脏检查
该模型有一个称为dirty
的Property
。 这是一个BooleanBinding
,您可以监视(observe)该属性,据此以启用或禁用某些特性。 例如,我们可以轻松地禁用保存按钮,直到有实际的更改。 更新的保存按钮将如下所示:
button("Save") {
enableWhen(model.dirty)
action {
save()
}
}
还有一个简单的val
称为isDirty
,它返回一个Boolean
表示整个模型的脏状态。
需要注意的一点是,如果在通过UI修改ViewModel
的同时修改了后台对象,则ViewModel
中的所有未提交的更改都将被后台对象中的更改所覆盖。 这意味着如果发生后台对象的外部修改, ViewModel
的数据可能会丢失。
val person = Person("John", "Manager")
val model = PersonModel(person)
model.name.value = "Johnny" //modify the ViewModel
person.name = "Johan" //modify the underlying object
println(" Person = ${person.name}, ${person.title}") //output: Person = Johan, Manager
println("Is dirty = ${model.isDirty}") //output: Is dirty = false
println(" Model = ${model.name.value}, ${model.title.value}") //output: Model = Johan, Manager
如上所述,当基础对象被修改时, ViewModel
的更改被覆盖。 而且ViewModel
没被标记为dirty
。
脏属性(Dirty Properties)
您可以检查特定属性是否为脏,这意味着它与后备的源对象值相比已更改。
val nameWasChanged = model.isDirty(model.name)
还有一个扩展属性版本完成相同的任务:
val nameWasChange = model.name.isDirty
速记版本是Property
的扩展名,但只适用于ViewModel
内绑定的属性。 你会发现还有model.isNotDirty
属性。
如果您需要根据ViewModel
特定属性的脏状态进行动态响应,则可以获取一个BooleanBinding
表示该字段的脏状态,如下所示:
val nameDirtyProperty = model.dirtyStateFor(PersonModel::name)
提取源对象值
要检索属性的后备对象值(backing object value),可以调用model.backingValue(property)
。
val person = model.backingValue(property)
支持没有暴露JavaFX属性的对象
您可能想知道如何处理没有使用JavaFX属性的领域对象(domain objects)。 也许你有一个简单的POJO的getter
和setter
,或正常的Kotlin var
类型属性。 由于ViewModel
需要JavaFX属性,TornadoFX附带强大的包装器,可以将任何类型的属性转换成可观察的(observable)JavaFX属性。 这里有些例子:
// Java POJO getter/setter property
class JavaPersonViewModel(person: JavaPerson) : ViewModel() {
val name = bind { person.observable(JavaPerson::getName, JavaPerson::setName) }
}
// Kotlin var property
class PersonVarViewModel(person: Person) : ViewModel() {
val name = bind { person.observable(Person::name) }
}
您可以看到,很容易将任何属性类型转换为observable
属性。 当Kotlin 1.1发布时,上述语法将进一步简化非基于JavaFX的属性。
特定属性子类型(IntegerProperty,BooleanProperty)
例如,如果绑定了一个IntegerProperty ,那么界面对象(facade)属性的类型将看起来像Property
,但是它在实际上是IntegerProperty
。 如果您需要访问IntegerProperty
提供的特殊功能,则必须转换绑定结果:
val age = bind(Person::ageProperty) as IntegerProperty
同样,您可以通过指定只读类型来公开只读属性:
val age = bind(Person::ageProperty) as ReadOnlyIntegerProperty
这样做的原因是类型系统的一个不幸的缺点,它阻止编译器对这些特定类型的重载bind
函数进行区分,因此ViewModel
的单个bind
函数检查属性类型并返回最佳匹配,但遗憾的是返回类型签名现在必须是Property
。
重新绑定(Rebinding)
正如您在上面的TableView
示例中看到的,可以更改由ViewModel
包装的领域对象。 这个测试案例说明了以下几点:
@Test fun swap_source_object() {
val person1 = Person("Person 1")
val person2 = Person("Person 2")
val model = PersonModel(person1)
assertEquals(model.name, "Person 1")
model.rebind { person = person2 }
assertEquals(model.name, "Person 2")
}
该测试创建两个Person
对象和一个ViewModel
。 该模型以第一个person
对象初始化。 然后检查该model.name
对应于person1
的名称。 现在奇怪的是:
model.rebind { person = person2 }
上面的rebind()
块中的代码将被执行,并且模型的所有属性都使用新的源对象的值进行更新。 这实际上类似于写作:
model.person = person2
model.rollback()
您选择的形式取决于您,但第一种形式可以确保你不会忘记调用重新绑定(rebind)。 调用rebind
后,模型并不脏,所有的值都将反映形成新的源对象的值(all values will reflect the ones form the new source object or source objects)。 重要的是要注意,您可以将多个源对象传递给视图模型(pass multiple source objects to a view model),并根据您的需要更新其中的所有或一些。
Rebind Listener
我们的TableView
示例调用了rebindOnChange()
函数,并将TableView
作为第一个参数传递。 这确保了在更改了TableView
的选择时会调用rebind
。 这实际上只是一个具有相同名称的函数的快捷方式,该函数使用observable
,并在每次观察到更改时调用重新绑定。 如果您调用此函数,则不需要手动调用重新绑定(rebind),只要您具有表示状态更改的observable
,其应导致模型重新绑定(rebind)。
如您所见, TableView
具有selectionModel.selectedItemProperty
的快捷方式支持(shorthand support)。 如果不是这个快捷函数调用,你必须这样写:
model.rebindOnChange(table.selectionModel.selectedItemProperty()) {
person = it ?: Person()
}
包括上述示例是用来阐明rebindOnChange()
函数背后的工作原理。 对于涉及TableView
的实际用例,您应该选择较短的版本或使用ItemViewModel
。
ItemViewModel
当使用ViewModel
时,您会注意到一些重复的和有些冗长的任务。 这包括调用rebind
或配置rebindOnChange
来更改源对象。 ItemViewModel
是ViewModel
的扩展,几乎所有使用的情况下,您都希望继承ItemViewModel
而不是ViewModel
类。
ItemViewModel
具有一个名为itemProperty
的属性,因此我们的PersonModel
现在看起来像这样:
class PersonModel : ItemViewModel() {
val name = bind(Person::nameProperty)
val title = bind(Person::titleProperty)
}
你会注意到,我们不再需要传入构造函数中的var person: Person
。 ItemViewModel
现在具有一个observable
属性 itemProperty
,以及通过item属性的实现的getter/setter
。 每当您为item
赋值(或通itemProperty.value
),该模型就自动帮你重新绑定(automatically rebound for you)。还有一个可观察的empty
布尔值,可以用来检查ItemViewModel
当前是否持有一个Person
。
绑定表达式(binding expressions)需要考虑到它在绑定时可能不代表任何项目。 这就是为什么以上绑定表达式现在使用null安全运算符(null safe operator)。
我们只是摆脱了一些样板(boiler plate),但是ItemViewModel
给了我们更多的功能。 还记得我们是如何将TableView
选定的person
与之前的模型绑定在一起的吗?
// Update the person inside the view model on selection change
model.rebindOnChange(this) { selectedPerson ->
person = selectedPerson ?: Person()
}
使用ItemViewModel
可以这样重写:
// Update the person inside the view model on selection change
bindSelected(model)
这将有效地附加我们必须手动编写的监听器(attach the listener),并确保TableView
的选择在模型中可见。
save()
函数现在也会稍有不同,因为我们的模型中没有person
属性:
private fun save() {
model.commit()
val person = model.item
println("Saving ${person.name} / ${person.title}")
}
这里的person
是使用来自itemProperty
的item getter`提取的。
从1.7.1开始,当使用ItemViewModel()
和POJO,您可以如下创建绑定:
data class Person(val firstName: String, val lastName: String)
class PersonModel : ItemViewModel() {
val firstname = bind { item?.firstName?.toProperty() }
val lastName = bind { item?.lastName?.toProperty() }
}
OnCommit回调
有时在模型成功提交后,还想要(desirable)做一个特定的操作。 ViewModel
为此提供了两个回调onCommit
和onCommit(commits: List
。
第一个函数onCommit
,没有参数,并在成功提交后被调用, 在可选successFn
被调用之前(请参阅: commit
)。
将以相同的顺序调用第二个函数,但是传递一个已经提交属性的列表(passing a list of committed properties)。
列表中的每个Commit
,包含原来的ObservableValue
, 即oldValue
和newValue
以及一个changed
属性,以提示oldValue
与newValue
是否不同。
我们来看一个例子,演示我们如何只检索已更改的对象并将它们打印到stdout
。
要找出哪个对象发生了变化,我们定义了一个小的扩展函数,它将会找到给定的属性, 并且如果有改变,则将返回旧值和新值,如果没有改变则返回null
。
class PersonModel : ItemViewModel() {
val firstname = bind(Person::firstName)
val lastName = bind(Person::lastName)
override val onCommit(commits: List) {
// The println will only be called if findChanged is not null
commits.findChanged(firstName)?.let { println("First-Name changed from ${it.first} to ${it.second}")}
commits.findChanged(lastName)?.let { println("Last-Name changed from ${it.first} to ${it.second}")}
}
private fun List.findChanged(ref: Property): Pair? {
val commit = find { it.property == ref && it.changed}
return commit?.let { (it.newValue as T) to (it.oldValue as T) }
}
}
可注入模型(Injectable Models)
最常见的是,您将不会在同一View
同时拥有TableView
和编辑器。 那么,我们需要从至少两个不同的视图访问ViewModel
,一个用于TableView
,另一个用于表单(form)。 幸运的是, ViewModel
是可注入的,所以我们可以重写我们的编辑器示例并拆分这两个视图:
class PersonList : View("Person List") {
val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()
val model : PersonModel by inject()
override val root = tableview(persons) {
title = "Person"
column("Name", Person::nameProperty)
column("Title", Person::titleProperty)
bindSelected(model)
}
}
TableView
现在变得更简洁,更容易理解。 在实际应用中,人员名单可能来自控制器(controller)或远程通话(remoting call)。 该模型简单地注入到View
,我们将为编辑器做同样的事情:
class PersonEditor : View("Person Editor") {
val model : PersonModel by inject()
override val root = form {
fieldset("Edit person") {
field("Name") {
textfield(model.name)
}
field("Title") {
textfield(model.title)
}
button("Save") {
enableWhen(model.dirty)
action {
save()
}
}
button("Reset").action {
model.rollback()
}
}
}
private fun save() {
model.commit()
println("Saving ${model.item.name} / ${model.item.title}")
}
}
模型的注入实例将在两个视图中完全相同。 再次,在真正的应用程序中,保存调用可能会被卸载异步访问控制器。
何时使用ViewModel与ItemViewModel
本章从ViewModel
的低级实现直到流线化(streamlined)的ItemViewModel
。 你可能会想知道是否有任何用例,需继承ViewModel
而不是ItemViewModel
。 答案是,尽管您通常在90%以上的时间会扩展ItemViewModel
,总还是会出现一些没有意义的用例。 由于ViewModels
可以被注入,且用于保持导航状态和整体UI状态,所以您可以将它用于没有单个领域对象的情况 - 您可以拥有多个领域对象,或仅仅是一个松散属性的集合。 在这种用例中, ItemViewModel
没有任何意义,您可以直接实现ViewModel
。 对于常见的情况,ItemViewModel
是您最好的朋友。
这种方法有一个潜在的问题。 如果我们要显示多“对”列表和表单(multiple "pairs" of lists and forms),也许在不同的窗口中,我们需要一种方法,来分离和绑定(separate and bind)属于一个特定对的列表和表单(specific pair of list and form)的模型(model)。 有很多方法可以解决这个问题,但是一个非常适合这一点的工具就是范围(scopes)。 有关此方法的更多信息,请查看范围(scope)的文档。
验证(Validation)
几乎每个应用程序都需要检查用户提供的输入是否符合一组规则,看是否可以接受。 TornadoFX具有可扩展的验证和装饰框架(extensible validation and decoration framework)。
在将其与ViewModel
集成之前,我们将首先将验证(validation)视为独立功能。
在幕后(Under the Hood)
以下解释有点冗长,并不反映您在应用程序中编写验证码的方式。 本部分将为您提供对验证(validation)如何工作以及各个部件如何组合在一起的扎实理解。
Validator
Validator
知道如何检查指定类型的用户输入,并返回一个ValidationMessage
,其中的ValidationSeverity
描述输入如何与特定控件的预期输入进行比较。 如果Validator
认为对于输入值没有任何可报告的,则返回null
。 ValidationMessage
可以可选地添加文本消息,通常由配置于ValidationContext
的Decorator
显示。 以后我们将会更多地介绍装饰(decorators)。
支持以下严重性级别(severity levels):
- Error - 不接受输入
- Warning - 输入不理想,但被接受
- Success - 输入被接受
- Info - 输入被接受
有多个严重性级别(severity levels)都代表成功的输入,以便在大多数情况下更容易提供上下文正确的反馈(contextually correct feedback)。 例如,无论输入值如何,您可能需要给出一个字段的信息性消息(informational message),或者在输入时特别标记带有绿色复选框的字段。 导致无效状态(invalid status)的唯一严重性是Error
级别。
ValidationTrigger
默认情况下,输入值发生变化时将进行验证。 输入值始终为ObservableValue
,默认触发器只是监听更改。 你可以选择当输入字段失去焦点时,或者当点击保存按钮时进行验证。 可以为每个验证器配置以下ValidationTriggers
:
-
OnChange
- 输入值更改时进行验证,可选择以毫秒为单位的给定延迟 -
OnBlur
- 当输入字段失去焦点时进行验证 -
Never
- 仅在调用ValidationContext.validate()
时才验证
ValidationContext
通常您将一次性验证来自多个控件或输入字段的用户输入。 您可以在ValidationContext
存放这些验证器,以便您可以检查所有验证器是否有效,或者要求验证上下文(validation context)在任何给定时间对所有字段执行验证。 该上下文(context)还控制什么样的装饰器(decorator)将用于传达验证消息(convey the validation message)给每个字段。 请参阅下面的Ad Hoc验证示例。
Decorator
ValidationContext
的decorationProvider
负责在将ValidationMessage
与输入相关联时提供反馈(feedback)。 默认情况下,这是SimpleMessageDecorator
的一个实例,它将在输入字段的顶部左上角显示彩色三角形标记,并在输入获得焦点的同时显示带有消息的弹出窗口。
如果您不喜欢默认的装饰器外观,可以通过实现Decorator
轻松创建自己的Decorator
界面:
interface Decorator {
fun decorate(node: Node)
fun undecorate(node: Node)
}
您可以将您的装饰器分配给给定的ValidationContext
,如下所示:
context.decorationProvider = MyDecorator()
提示:您可以创建一个装饰器(decorator),将CSS样式类应用于输入,而不是覆盖其他节点以提供反馈。
Ad Hoc验证(Ad Hoc Validation)
虽然您可能永远不会在实际应用程序中执行此操作,但是可以设置ValidationContext
并手动应用验证器。 下面的示例实际上是从本框架的内部测试中获取的。 它说明了这个概念,但不是应用程序中的实际模式。
// Create a validation context
val context = ValidationContext()
// Create a TextField we can attach validation to
val input = TextField()
// Define a validator that accepts input longer than 5 chars
val validator = context.addValidator(input, input.textProperty()) {
if (it!!.length < 5) error("Too short") else null
}
// Simulate user input
input.text = "abc"
// Validation should fail
assertFalse(validator.validate())
// Extract the validation result
val result = validator.result
// The severity should be error
assertTrue(result is ValidationMessage && result.severity == ValidationSeverity.Error)
// Confirm valid input passes validation
input.text = "longvalue"
assertTrue(validator.validate())
assertNull(validator.result)
特别注意addValidator
调用的最后一个参数。 这是实际的验证逻辑。 该函数被传入待验证属性的当前输入,且在没有消息时必须返回null,或在对输入如果有值得注意的情况,则返回ValidationMessage
的实例。 具有严重性Error
的消息将导致验证失败。 你可以看到,不需要实例化一个ValidationMessage
自己,只需使用一个函数error
, warning
, success
或info
。
验证ViewModel
每个ViewModel
都包含一个ValidationContext
,所以你不需要自己实例化一个。 验证框架与类型安全的构建器集成,甚至提供一些内置的验证器,比如required
验证器。 回到我们的人物编辑器(person editor),我们可以通过简单的更改使输入字段成为必需:
field("Name") {
textfield(model.name).required()
}
这就是它的一切。这个required
验证器可选择接收一个消息,如果验证失败将显示给用户。 默认文字是“这个字段是必需的(This field is required)”。
除了使用内置的验证器,我们可以手动表达相同的东西:
field("Name") {
textfield(model.name).validator {
if (it.isNullOrBlank()) error("The name field is required") else null
}
}
如果要进一步自定义文本字段,可能需要添加另一组花括号:
field("Name") {
textfield(model.name) {
// Manipulate the text field here
validator {
if (it.isNullOrBlank()) error("The name field is required") else null
}
}
}
将按钮绑定到验证状态(Binding buttons to validation state)
当输入有效时,您可能只想启用表单中的某些按钮。 model.valid
属性可用于此目的。因为默认验证触发器是OnChange
,只有当您首次尝试提交模型时,有效状态才会准确。 但是,如果你想要将按钮绑定到模型的valid
状态的话,您可以调用model.validate(decorateErrors = false)
强制所有验证器报告其结果,而不会实际上向用户显示任何验证错误。
field("username") {
textfield(username).required()
}
field("password") {
passwordfield(password).required()
}
buttonbar {
button("Login", ButtonBar.ButtonData.OK_DONE).action {
enableWhen { model.valid }
model.commit {
doLogin()
}
}
}
// Force validators to update the `model.valid` property
model.validate(decorateErrors = false)
注意登录按钮的启用状态(enabled state)如何通过enableWhen { model.valid }
调用绑定到模式的启用状态(enabled state)。 在配置了字段和验证器之后, model.validate(decorateErrors = false)
确保模型的有效状态被更新,却不会在验证失败的字段上触发错误装饰(triggering error decorations)。 默认情况下,装饰器将会在值变动时介入,除非你将trigger
参数覆盖为validator
。 这里的required()
内建验证器也接受此参数。 例如,为了只有当输入字段失去焦点时才运行验证器,可以调用textfield(username).required(ValidationTrigger.OnBlur)
。
对话框中的验证
对话框(dialog
)构建器使用表单(form)和字段集(fieldset)创建一个窗口,然后开始向其添加字段。 有些时候对这样的情形你没有ViewModel
,但您可能仍然希望使用它提供的功能。 对于这种情况,您可以内联(inline)实例化ViewModel
,并将一个或多个属性连接到它。 这是一个示例对话框,需要用户在textarea
中输入一些输入:
dialog("Add note") {
val model = ViewModel()
val note = model.bind { SimpleStringProperty() }
field("Note") {
textarea(note) {
required()
whenDocked { requestFocus() }
}
}
buttonbar {
button("Save note").action {
model.commit { doSave() }
}
}
}
注意note
属性如何通过指定其bean
参数连接到上下文。 这对于进行字段场验证是至关重要的。
部分提交
还可以通过提供要提交的字段列表,来避免提交所有内容,来进行部分提交(partial commit)。 这可以在您编辑不同视图的同一个ViewModel
实例时提供方便,例如在向导(Wizard)中。 有关部分提交(partial commit)的更多信息,以及相应的部分验证(partial validation)功能,请参阅向导章(Wizard chapter)。
TableViewEditModel
如果您屏幕空间有限,从而不具备主/细节设置TableView
的空间,有效的选择是直接编辑TableView
。通过启用TornadoFX一些改进的特性,不仅可以使单元容易编辑(enable easy cell editing),也使脏状态容易跟踪,提交和回滚。通过调用enableCellEditing()
和enableDirtyTracking()
,以及访问TableView的tableViewEditModel
属性,就可以轻松启用此功能。
当您编辑一个单元格,蓝色标记将指示其脏状态。调用rollback()
将恢复脏单元到其原始值,而commit()
将设置当前值作为新的基准(并删除所有脏的状态历史)。
import tornadofx.*
class MyApp: App(MyView::class)
class MyView : View("My View") {
val controller: CustomerController by inject()
var tableViewEditModel: TableViewEditModel by singleAssign()
override val root = borderpane {
top = buttonbar {
button("COMMIT").setOnAction {
tableViewEditModel.commit()
}
button("ROLLBACK").setOnAction {
tableViewEditModel.rollback()
}
}
center = tableview {
items = controller.customers
isEditable = true
column("ID",Customer::idProperty)
column("FIRST NAME", Customer::firstNameProperty).makeEditable()
column("LAST NAME", Customer::lastNameProperty).makeEditable()
enableCellEditing() //enables easier cell navigation/editing
enableDirtyTracking() //flags cells that are dirty
tableViewEditModel = editModel
}
}
}
class CustomerController : Controller() {
val customers = listOf(
Customer(1, "Marley", "John"),
Customer(2, "Schmidt", "Ally"),
Customer(3, "Johnson", "Eric")
).observable()
}
class Customer(id: Int, lastName: String, firstName: String) {
val lastNameProperty = SimpleStringProperty(this, "lastName", lastName)
var lastName by lastNameProperty
val firstNameProperty = SimpleStringPorperty(this, "firstName", firstName)
var firstName by firstNameProperty
val idProperty = SimpleIntegerProperty(this, "id", id)
var id by idProperty
}
还要注意有很多其他有用的TableViewEditModel
的特性和功能。其中items
属性是一个ObservableMap
,映射每个记录项的脏状态S。如果您想筛选出并只提交脏的记录,从而将其持久存储在某处,你可以使用“提交”>Button
执行此操作。
button("COMMIT").action {
tableViewEditModel.items.asSequence()
.filter { it.value.isDirty }
.forEach {
println("Committing ${it.key}")
it.value.commit()
}
}
还有commitSelected()
和rollbackSelected()
,只提交或回滚在TableView
中选定的记录。