美文网首页 TornadoFX 教程
TornadoFX编程指南,第11章,编辑模型和验证

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

作者: 公子小水 | 来源:发表于2017-08-26 01:39 被阅读369次

    译自《Editing Models and Validation

    编辑模型和验证

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

    为了帮助实现这些模式,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版本。

    图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<Person> 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<T>的扩展名,但只适用于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<Int>,但是它在实际上是IntegerProperty。 如果您需要访问IntegerProperty提供的特殊功能,则必须转换绑定结果:

    val age = bind(Person::ageProperty) as IntegerProperty
    

    同样,您可以通过指定只读类型来公开只读属性:

    val age = bind(Person::ageProperty) as ReadOnlyIntegerProperty
    

    这样做的原因是类型系统的一个不幸的缺点,它阻止编译器对这些特定类型的重载bind函数进行区分,因此ViewModel的单个bind函数检查属性类型并返回最佳匹配,但遗憾的是返回类型签名现在必须是Property<T>

    重新绑定(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<Person>() {
        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<Person>() {
        val firstname = bind { item?.firstName?.toProperty() }
        val lastName = bind { item?.lastName?.toProperty() }
    }
    

    OnCommit回调

    有时在模型成功提交后,还想要(desirable)做一个特定的操作。 ViewModel为此提供了两个回调onCommitonCommit(commits: List<Commit>)

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

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

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

    我们来看一个例子,演示我们如何只检索已更改的对象并将它们打印到stdout

    要找出哪个对象发生了变化,我们定义了一个小的扩展函数,它将会找到给定的属性, 并且如果有改变,则将返回旧值和新值,如果没有改变则返回null

    class PersonModel : ItemViewModel<Person>() {
    
        val firstname = bind(Person::firstName)
        val lastName = bind(Person::lastName)
    
        override val onCommit(commits: List<Commit>) {
           // 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 <T> List<Commit>.findChanged(ref: Property<T>): Pair<T, T>? {
            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<T>,默认触发器只是监听更改。 你可以选择当输入字段失去焦点时,或者当点击保存按钮时进行验证。 可以为每个验证器配置以下ValidationTriggers

    • OnChange - 输入值更改时进行验证,可选择以毫秒为单位的给定延迟
    • OnBlur - 当输入字段失去焦点时进行验证
    • Never - 仅在调用ValidationContext.validate()时才验证

    ValidationContext

    通常您将一次性验证来自多个控件或输入字段的用户输入。 您可以在ValidationContext存放这些验证器,以便您可以检查所有验证器是否有效,或者要求验证上下文(validation context)在任何给定时间对所有字段执行验证。 该上下文(context)还控制什么样的装饰器(decorator)将用于传达验证消息(convey the validation message)给每个字段。 请参阅下面的Ad Hoc验证示例。

    Decorator

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

    图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() }
            }
        }
    }
    
    图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<Customer> by singleAssign()
    
        override val root =  borderpane {
            top = buttonbar {
                button("COMMIT").setOnAction {
                    tableViewEditModel.commit()
                }
                button("ROLLBACK").setOnAction {
                    tableViewEditModel.rollback()
                }
            }
            center = tableview<Customer> {
    
                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
    }
    
    图11.4 TableView脏状态跟踪,用rollback()和commit()功能

    还要注意有很多其他有用的TableViewEditModel的特性和功能。其中items属性是一个ObservableMap<S, TableColumnDirtyState<S>>,映射每个记录项的脏状态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章,编辑模型和验证

        本文链接:https://www.haomeiwen.com/subject/yibkrxtx.html