TornadoFX编程指南,第11章,编辑模型和验证

译自《Editing Models and Validation》

编辑模型和验证

作为开发人员,TornadoFX不会对你强制任何特定的架构模式,它对MVC, MVP两者及其衍生模式都工作得很好。

为了帮助实现这些模式,TornadoFX提供了一个名为ViewModel的工具,可帮助您清理您的UI和业务逻辑,为您提供回滚/提交(rollback/commit)脏状态检查(dirty state checking)等功能 。 这些模式是手动实现的难点或麻烦,所以建议在需要时利用ViewModelViewModelItem

通常,您将在大多数情况下使用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之前,我们将创建一个不使用ViewModelView版本。

TornadoFX编程指南,第11章,编辑模型和验证_第1张图片
图11.1

以下是我们第一次尝试构建的代码,它有一些我们将要解决的问题。

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简介

ViewModelTableViewForm之间的调解器。 它作为文本字段中的数据和实际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 }
}

这看起来好多了,但到底究竟发生了什么呢? 我们引入了一个称为PersonModelViewModel的子类。 该模型持有一个Person对象,并具有nametitle字段的属性。 在我们查看其余客户端代码后,我们将进一步讨论该模型。

请注意,我们不会引用TableView或文本字段。 除了很少的代码,第一个大的变化是我们更新模型中的Person的方式:

model.rebindOnChange(this) { selectedPerson ->
    person = selectedPerson ?: Person()
}

rebindOnChange()函数将TableView作为一个参数,以及一个在选择更改时被调用的函数。 这对ListViewTreeViewTreeTableView和任何其他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代理定义了两个看起来奇怪的属性, nametitle。 是的,它看起来很奇怪,但是有一个非常好的理由。 bind函数的{ person.nameProperty() }参数是一个返回属性的lambda。 此返回的属性由ViewModel进行检查,并创建相同类型的新属性。 它被放在ViewModelname属性中。

当我们将文本字段绑定到模型的name属性时,只有当您键入文本字段时才会更新该副本。 ViewModel跟踪哪个实体属性属于哪个界面对象(facade),当您调用commit,将从界面对象(facade)的值刷入实际的后备属性(backing property)。 另一方面,当您调用rollback时会发生恰恰相反的情况:实际属性值被刷入界面对象(facade)。

实际属性包含在函数中的原因在于,这样可以更改person变量,然后从该新的person中提取属性。 您可以在下面阅读更多信息(重新绑定,rebinding)。

脏检查

该模型有一个称为dirtyProperty。 这是一个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的gettersetter,或正常的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来更改源对象。 ItemViewModelViewModel的扩展,几乎所有使用的情况下,您都希望继承ItemViewModel而不是ViewModel类。

ItemViewModel具有一个名为itemProperty的属性,因此我们的PersonModel现在看起来像这样:

class PersonModel : ItemViewModel() {
    val name = bind(Person::nameProperty) 
    val title = bind(Person::titleProperty)
}

你会注意到,我们不再需要传入构造函数中的var person: PersonItemViewModel现在具有一个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为此提供了两个回调onCommitonCommit(commits: List)

第一个函数onCommit,没有参数,并在成功提交后被调用, 在可选successFn被调用之前(请参阅: commit)。

将以相同的顺序调用第二个函数,但是传递一个已经提交属性的列表(passing a list of committed properties)。

列表中的每个Commit,包含原来的ObservableValue, 即oldValuenewValue以及一个changed属性,以提示oldValuenewValue是否不同。

我们来看一个例子,演示我们如何只检索已更改的对象并将它们打印到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认为对于输入值没有任何可报告的,则返回nullValidationMessage可以可选地添加文本消息,通常由配置于ValidationContextDecorator显示。 以后我们将会更多地介绍装饰(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

ValidationContextdecorationProvider负责在将ValidationMessage与输入相关联时提供反馈(feedback)。 默认情况下,这是SimpleMessageDecorator的一个实例,它将在输入字段的顶部左上角显示彩色三角形标记,并在输入获得焦点的同时显示带有消息的弹出窗口。

TornadoFX编程指南,第11章,编辑模型和验证_第2张图片
图11.2 显示必填字段验证消息的默认装饰器

如果您不喜欢默认的装饰器外观,可以通过实现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自己,只需使用一个函数errorwarningsuccessinfo

验证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() }
        }
    }
}
TornadoFX编程指南,第11章,编辑模型和验证_第3张图片
图11.3带有内联ViewModel上下文的对话框

注意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
}
TornadoFX编程指南,第11章,编辑模型和验证_第4张图片
图11.4 TableView脏状态跟踪,用rollback()和commit()功能

还要注意有很多其他有用的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中选定的记录。

你可能感兴趣的:(TornadoFX编程指南,第11章,编辑模型和验证)