譯自《Editing Models and Validation》
編輯模型和驗證
作為開發(fā)人員,TornadoFX不會對你強制任何特定的架構(gòu)模式,它對MVC, MVP兩者及其衍生模式都工作得很好。
為了幫助實現(xiàn)這些模式,TornadoFX提供了一個名為ViewModel的工具,可幫助您清理您的UI和業(yè)務(wù)邏輯,為您提供回滾/提交(rollback/commit)和臟狀態(tài)檢查(dirty state checking)等功能 。 這些模式是手動實現(xiàn)的難點或麻煩,所以建議在需要時利用ViewModel和ViewModelItem。
通常,您將在大多數(shù)情況下使用ViewModelItem,而非ViewModel,但是...
典型用例
假設(shè)你有一個給定的領(lǐng)域類型(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之前,我們將創(chuàng)建一個不使用ViewModel的View版本。

以下是我們第一次嘗試構(gòu)建的代碼,它有一些我們將要解決的問題。
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和右側(cè)Form組成的View 。 我們?yōu)楸韱斡蚝捅肀旧矶x一些屬性,以便稍后引用它們。
當我們構(gòu)建表時,我們將一個監(jiān)聽器附加到所選項目,從而當表格的選擇更改時,我們可以調(diào)用editPerson()函數(shù)。 editPerson()函數(shù)將所選人員的屬性綁定到表單中的文本字段。
我們初次嘗試的問題
乍看起來可能還不錯,但是當我們深入挖掘時,有幾個問題。
手動綁定(Manual binding)
每次表中的選擇發(fā)生變化時,我們必須手動取消綁定/重新綁定表單域的數(shù)據(jù)。 除了增加的代碼和邏輯,還有另一個巨大的問題:文本字段中的每個變化都會導(dǎo)致數(shù)據(jù)更新,這種更改甚至將反映在表中。 雖然這可能看起來很酷,在技術(shù)上是正確的,但它提出了一個大問題:如果用戶不想保存更改,該怎么辦? 我們沒有辦法回滾。 所以為了防止這一點,我們必須完全跳過綁定,并手動從文本字段提取值,然后在保存時創(chuàng)建一個新的Person對象。 事實上,這是許多應(yīng)用程序中都能發(fā)現(xiàn)的一種模式,大多數(shù)用戶都希望這樣做。 為此表單實現(xiàn)“重置”按鈕,將意味著使用初始值管理變量,并再次將這些值手動賦值給文本字段。
緊耦合(Tight Coupling)
另一個問題是,當它要保存編輯的人的時候,保存函數(shù)必須再次從表中提取所選項目。 為了能這么做,保存函數(shù)必須知道TableView。 或者,它必須知道文本字段,像editPerson()函數(shù)這樣,并手動提取值來重建一個Person對象。
ViewModel簡介
ViewModel是TableView和Form之間的調(diào)解器。 它作為文本字段中的數(shù)據(jù)和實際Person對象中的數(shù)據(jù)之間的中間人。 如你所見,代碼要短得多,容易理解。 PersonModel的實現(xiàn)代碼將很快顯示出來。 現(xiàn)在只關(guān)注它的用法。
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 }
}
這看起來好多了,但到底究竟發(fā)生了什么呢? 我們引入了一個稱為PersonModel的ViewModel的子類。 該模型持有一個Person對象,并具有name和title字段的屬性。 在我們查看其余客戶端代碼后,我們將進一步討論該模型。
請注意,我們不會引用TableView或文本字段。 除了很少的代碼,第一個大的變化是我們更新模型中的Person的方式:
model.rebindOnChange(this) { selectedPerson ->
person = selectedPerson ?: Person()
}
rebindOnChange()函數(shù)將TableView作為一個參數(shù),以及一個在選擇更改時被調(diào)用的函數(shù)。 這對ListView , TreeView, TreeTableView和任何其他ObservableValue都可以工作。 此函數(shù)在模型上調(diào)用,并將selectedPerson作為其單個參數(shù)。 我們將所選人員賦值給模型的person屬性,或者如果選擇為空/ null,則將其指定為新Person。 這樣,我們確??偸怯心P统尸F(xiàn)的數(shù)據(jù)。
當我們創(chuàng)建TextField時,我們將模型屬性直接綁定給它,因為大多數(shù)Node都可以接受一個ObservableValue來綁定。
field("Name") {
textfield(model.name)
}
即使選擇更改,模型屬性仍然保留,但屬性的值將更新。 我們完全避免了此前嘗試的手動綁定。
該版本的另一個重大變化是,當我們鍵入文本字段時,表中的數(shù)據(jù)不會更新。 這是因為模型已經(jīng)從person對象暴露了屬性的副本,并且在調(diào)用model.commit()之前不會寫回到實際的person對象中。 這正是我們在save函數(shù)中所做的。 一旦commit()被調(diào)用,界面對象(facade)中的數(shù)據(jù)就會被刷新回到我們的person對象中,現(xiàn)在表格將反映我們的變化。
回滾
由于模型持有對實際Person對象的引用,我們可以重置文本字段以反映我們的Person對象中的實際數(shù)據(jù)。 我們可以添加如下所示的重置按鈕:
button("Reset").action {
model.rollback()
}
當按下按鈕時,任何更改將被丟棄,文本字段再次顯示實際的Person對象的值。
PersonModel
我們從來沒有解釋過PersonModel的工作原理,您可能一直在想知道PersonModel如何實現(xiàn)。 這里就是:
class PersonModel(var person: Person) : ViewModel() {
val name = bind { person.nameProperty }
val title = bind { person.titleProperty }
}
它可以容納一個Person對象,它通過bind代理定義了兩個看起來奇怪的屬性, name和title。 是的,它看起來很奇怪,但是有一個非常好的理由。 bind函數(shù)的{ person.nameProperty() }參數(shù)是一個返回屬性的lambda。 此返回的屬性由ViewModel進行檢查,并創(chuàng)建相同類型的新屬性。 它被放在ViewModel的name屬性中。
當我們將文本字段綁定到模型的name屬性時,只有當您鍵入文本字段時才會更新該副本。 ViewModel跟蹤哪個實體屬性屬于哪個界面對象(facade),當您調(diào)用commit,將從界面對象(facade)的值刷入實際的后備屬性(backing property)。 另一方面,當您調(diào)用rollback時會發(fā)生恰恰相反的情況:實際屬性值被刷入界面對象(facade)。
實際屬性包含在函數(shù)中的原因在于,這樣可以更改person變量,然后從該新的person中提取屬性。 您可以在下面閱讀更多信息(重新綁定,rebinding)。
臟檢查
該模型有一個稱為dirty的Property。 這是一個BooleanBinding,您可以監(jiān)視(observe)該屬性,據(jù)此以啟用或禁用某些特性。 例如,我們可以輕松地禁用保存按鈕,直到有實際的更改。 更新的保存按鈕將如下所示:
button("Save") {
enableWhen(model.dirty)
action {
save()
}
}
還有一個簡單的val稱為isDirty,它返回一個Boolean表示整個模型的臟狀態(tài)。
需要注意的一點是,如果在通過UI修改ViewModel的同時修改了后臺對象,則ViewModel中的所有未提交的更改都將被后臺對象中的更改所覆蓋。 這意味著如果發(fā)生后臺對象的外部修改, ViewModel的數(shù)據(jù)可能會丟失。
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
如上所述,當基礎(chǔ)對象被修改時, ViewModel的更改被覆蓋。 而且ViewModel沒被標記為dirty。
臟屬性(Dirty Properties)
您可以檢查特定屬性是否為臟,這意味著它與后備的源對象值相比已更改。
val nameWasChanged = model.isDirty(model.name)
還有一個擴展屬性版本完成相同的任務(wù):
val nameWasChange = model.name.isDirty
速記版本是Property<T>的擴展名,但只適用于ViewModel內(nèi)綁定的屬性。 你會發(fā)現(xiàn)還有model.isNotDirty屬性。
如果您需要根據(jù)ViewModel特定屬性的臟狀態(tài)進行動態(tài)響應(yīng),則可以獲取一個BooleanBinding表示該字段的臟狀態(tài),如下所示:
val nameDirtyProperty = model.dirtyStateFor(PersonModel::name)
提取源對象值
要檢索屬性的后備對象值(backing object value),可以調(diào)用model.backingValue(property)。
val person = model.backingValue(property)
支持沒有暴露JavaFX屬性的對象
您可能想知道如何處理沒有使用JavaFX屬性的領(lǐng)域?qū)ο螅╠omain objects)。 也許你有一個簡單的POJO的getter和setter,或正常的Kotlin var類型屬性。 由于ViewModel需要JavaFX屬性,TornadoFX附帶強大的包裝器,可以將任何類型的屬性轉(zhuǎn)換成可觀察的(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) }
}
您可以看到,很容易將任何屬性類型轉(zhuǎn)換為observable屬性。 當Kotlin 1.1發(fā)布時,上述語法將進一步簡化非基于JavaFX的屬性。
特定屬性子類型(IntegerProperty,BooleanProperty)
例如,如果綁定了一個IntegerProperty ,那么界面對象(facade)屬性的類型將看起來像Property<Int>,但是它在實際上是IntegerProperty。 如果您需要訪問IntegerProperty提供的特殊功能,則必須轉(zhuǎn)換綁定結(jié)果:
val age = bind(Person::ageProperty) as IntegerProperty
同樣,您可以通過指定只讀類型來公開只讀屬性:
val age = bind(Person::ageProperty) as ReadOnlyIntegerProperty
這樣做的原因是類型系統(tǒng)的一個不幸的缺點,它阻止編譯器對這些特定類型的重載bind函數(shù)進行區(qū)分,因此ViewModel的單個bind函數(shù)檢查屬性類型并返回最佳匹配,但遺憾的是返回類型簽名現(xiàn)在必須是Property<T>。
重新綁定(Rebinding)
正如您在上面的TableView示例中看到的,可以更改由ViewModel包裝的領(lǐng)域?qū)ο蟆?這個測試案例說明了以下幾點:
@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")
}
該測試創(chuàng)建兩個Person對象和一個ViewModel。 該模型以第一個person對象初始化。 然后檢查該model.name對應(yīng)于person1的名稱。 現(xiàn)在奇怪的是:
model.rebind { person = person2 }
上面的rebind()塊中的代碼將被執(zhí)行,并且模型的所有屬性都使用新的源對象的值進行更新。 這實際上類似于寫作:
model.person = person2
model.rollback()
您選擇的形式取決于您,但第一種形式可以確保你不會忘記調(diào)用重新綁定(rebind)。 調(diào)用rebind后,模型并不臟,所有的值都將反映形成新的源對象的值(all values will reflect the ones form the new source object or source objects)。 重要的是要注意,您可以將多個源對象傳遞給視圖模型(pass multiple source objects to a view model),并根據(jù)您的需要更新其中的所有或一些。
Rebind Listener
我們的TableView示例調(diào)用了rebindOnChange()函數(shù),并將TableView作為第一個參數(shù)傳遞。 這確保了在更改了TableView的選擇時會調(diào)用rebind。 這實際上只是一個具有相同名稱的函數(shù)的快捷方式,該函數(shù)使用observable,并在每次觀察到更改時調(diào)用重新綁定。 如果您調(diào)用此函數(shù),則不需要手動調(diào)用重新綁定(rebind),只要您具有表示狀態(tài)更改的observable,其應(yīng)導(dǎo)致模型重新綁定(rebind)。
如您所見, TableView具有selectionModel.selectedItemProperty的快捷方式支持(shorthand support)。 如果不是這個快捷函數(shù)調(diào)用,你必須這樣寫:
model.rebindOnChange(table.selectionModel.selectedItemProperty()) {
person = it ?: Person()
}
包括上述示例是用來闡明rebindOnChange()函數(shù)背后的工作原理。 對于涉及TableView的實際用例,您應(yīng)該選擇較短的版本或使用ItemViewModel 。
ItemViewModel
當使用ViewModel時,您會注意到一些重復(fù)的和有些冗長的任務(wù)。 這包括調(diào)用rebind或配置rebindOnChange來更改源對象。 ItemViewModel是ViewModel的擴展,幾乎所有使用的情況下,您都希望繼承ItemViewModel而不是ViewModel類。
ItemViewModel具有一個名為itemProperty的屬性,因此我們的PersonModel現(xiàn)在看起來像這樣:
class PersonModel : ItemViewModel<Person>() {
val name = bind(Person::nameProperty)
val title = bind(Person::titleProperty)
}
你會注意到,我們不再需要傳入構(gòu)造函數(shù)中的var person: Person。 ItemViewModel現(xiàn)在具有一個observable屬性 itemProperty,以及通過item屬性的實現(xiàn)的getter/setter。 每當您為item賦值(或通itemProperty.value),該模型就自動幫你重新綁定(automatically rebound for you)。還有一個可觀察的empty布爾值,可以用來檢查ItemViewModel當前是否持有一個Person。
綁定表達式(binding expressions)需要考慮到它在綁定時可能不代表任何項目。 這就是為什么以上綁定表達式現(xiàn)在使用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)
這將有效地附加我們必須手動編寫的監(jiān)聽器(attach the listener),并確保TableView的選擇在模型中可見。
save()函數(shù)現(xiàn)在也會稍有不同,因為我們的模型中沒有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,您可以如下創(chuàng)建綁定:
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回調(diào)
有時在模型成功提交后,還想要(desirable)做一個特定的操作。 ViewModel為此提供了兩個回調(diào)onCommit和onCommit(commits: List<Commit>) 。
第一個函數(shù)onCommit,沒有參數(shù),并在成功提交后被調(diào)用, 在可選successFn被調(diào)用之前(請參閱: commit)。
將以相同的順序調(diào)用第二個函數(shù),但是傳遞一個已經(jīng)提交屬性的列表(passing a list of committed properties)。
列表中的每個Commit,包含原來的ObservableValue, 即oldValue和newValue以及一個changed屬性,以提示oldValue與newValue是否不同。
我們來看一個例子,演示我們?nèi)绾沃粰z索已更改的對象并將它們打印到stdout 。
要找出哪個對象發(fā)生了變化,我們定義了一個小的擴展函數(shù),它將會找到給定的屬性, 并且如果有改變,則將返回舊值和新值,如果沒有改變則返回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現(xiàn)在變得更簡潔,更容易理解。 在實際應(yīng)用中,人員名單可能來自控制器(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}")
}
}
模型的注入實例將在兩個視圖中完全相同。 再次,在真正的應(yīng)用程序中,保存調(diào)用可能會被卸載異步訪問控制器。
何時使用ViewModel與ItemViewModel
本章從ViewModel的低級實現(xiàn)直到流線化(streamlined)的ItemViewModel 。 你可能會想知道是否有任何用例,需繼承ViewModel而不是ItemViewModel。 答案是,盡管您通常在90%以上的時間會擴展ItemViewModel,總還是會出現(xiàn)一些沒有意義的用例。 由于ViewModels可以被注入,且用于保持導(dǎo)航狀態(tài)和整體UI狀態(tài),所以您可以將它用于沒有單個領(lǐng)域?qū)ο蟮那闆r - 您可以擁有多個領(lǐng)域?qū)ο螅騼H僅是一個松散屬性的集合。 在這種用例中, ItemViewModel沒有任何意義,您可以直接實現(xiàn)ViewModel。 對于常見的情況,ItemViewModel是您最好的朋友。
這種方法有一個潛在的問題。 如果我們要顯示多“對”列表和表單(multiple "pairs" of lists and forms),也許在不同的窗口中,我們需要一種方法,來分離和綁定(separate and bind)屬于一個特定對的列表和表單(specific pair of list and form)的模型(model)。 有很多方法可以解決這個問題,但是一個非常適合這一點的工具就是范圍(scopes)。 有關(guān)此方法的更多信息,請查看范圍(scope)的文檔。
驗證(Validation)
幾乎每個應(yīng)用程序都需要檢查用戶提供的輸入是否符合一組規(guī)則,看是否可以接受。 TornadoFX具有可擴展的驗證和裝飾框架(extensible validation and decoration framework)。
在將其與ViewModel集成之前,我們將首先將驗證(validation)視為獨立功能。
在幕后(Under the Hood)
以下解釋有點冗長,并不反映您在應(yīng)用程序中編寫驗證碼的方式。 本部分將為您提供對驗證(validation)如何工作以及各個部件如何組合在一起的扎實理解。
Validator
Validator知道如何檢查指定類型的用戶輸入,并返回一個ValidationMessage,其中的ValidationSeverity描述輸入如何與特定控件的預(yù)期輸入進行比較。 如果Validator認為對于輸入值沒有任何可報告的,則返回null。 ValidationMessage可以可選地添加文本消息,通常由配置于ValidationContext的Decorator顯示。 以后我們將會更多地介紹裝飾(decorators)。
支持以下嚴重性級別(severity levels):
- Error - 不接受輸入
- Warning - 輸入不理想,但被接受
- Success - 輸入被接受
- Info - 輸入被接受
有多個嚴重性級別(severity levels)都代表成功的輸入,以便在大多數(shù)情況下更容易提供上下文正確的反饋(contextually correct feedback)。 例如,無論輸入值如何,您可能需要給出一個字段的信息性消息(informational message),或者在輸入時特別標記帶有綠色復(fù)選框的字段。 導(dǎo)致無效狀態(tài)(invalid status)的唯一嚴重性是Error級別。
ValidationTrigger
默認情況下,輸入值發(fā)生變化時將進行驗證。 輸入值始終為ObservableValue<T>,默認觸發(fā)器只是監(jiān)聽更改。 你可以選擇當輸入字段失去焦點時,或者當點擊保存按鈕時進行驗證。 可以為每個驗證器配置以下ValidationTriggers:
-
OnChange- 輸入值更改時進行驗證,可選擇以毫秒為單位的給定延遲 -
OnBlur- 當輸入字段失去焦點時進行驗證 -
Never- 僅在調(diào)用ValidationContext.validate()時才驗證
ValidationContext
通常您將一次性驗證來自多個控件或輸入字段的用戶輸入。 您可以在ValidationContext存放這些驗證器,以便您可以檢查所有驗證器是否有效,或者要求驗證上下文(validation context)在任何給定時間對所有字段執(zhí)行驗證。 該上下文(context)還控制什么樣的裝飾器(decorator)將用于傳達驗證消息(convey the validation message)給每個字段。 請參閱下面的Ad Hoc驗證示例。
Decorator
ValidationContext的decorationProvider負責在將ValidationMessage與輸入相關(guān)聯(lián)時提供反饋(feedback)。 默認情況下,這是SimpleMessageDecorator的一個實例,它將在輸入字段的頂部左上角顯示彩色三角形標記,并在輸入獲得焦點的同時顯示帶有消息的彈出窗口。

如果您不喜歡默認的裝飾器外觀,可以通過實現(xiàn)Decorator輕松創(chuàng)建自己的Decorator界面:
interface Decorator {
fun decorate(node: Node)
fun undecorate(node: Node)
}
您可以將您的裝飾器分配給給定的ValidationContext,如下所示:
context.decorationProvider = MyDecorator()
提示:您可以創(chuàng)建一個裝飾器(decorator),將CSS樣式類應(yīng)用于輸入,而不是覆蓋其他節(jié)點以提供反饋。
Ad Hoc驗證(Ad Hoc Validation)
雖然您可能永遠不會在實際應(yīng)用程序中執(zhí)行此操作,但是可以設(shè)置ValidationContext并手動應(yīng)用驗證器。 下面的示例實際上是從本框架的內(nèi)部測試中獲取的。 它說明了這個概念,但不是應(yīng)用程序中的實際模式。
// 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調(diào)用的最后一個參數(shù)。 這是實際的驗證邏輯。 該函數(shù)被傳入待驗證屬性的當前輸入,且在沒有消息時必須返回null,或在對輸入如果有值得注意的情況,則返回ValidationMessage的實例。 具有嚴重性Error的消息將導(dǎo)致驗證失敗。 你可以看到,不需要實例化一個ValidationMessage自己,只需使用一個函數(shù)error , warning , success或info 。
驗證ViewModel
每個ViewModel都包含一個ValidationContext,所以你不需要自己實例化一個。 驗證框架與類型安全的構(gòu)建器集成,甚至提供一些內(nèi)置的驗證器,比如required驗證器。 回到我們的人物編輯器(person editor),我們可以通過簡單的更改使輸入字段成為必需:
field("Name") {
textfield(model.name).required()
}
這就是它的一切。這個required驗證器可選擇接收一個消息,如果驗證失敗將顯示給用戶。 默認文字是“這個字段是必需的(This field is required)”。
除了使用內(nèi)置的驗證器,我們可以手動表達相同的東西:
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
}
}
}
將按鈕綁定到驗證狀態(tài)(Binding buttons to validation state)
當輸入有效時,您可能只想啟用表單中的某些按鈕。 model.valid屬性可用于此目的。因為默認驗證觸發(fā)器是OnChange,只有當您首次嘗試提交模型時,有效狀態(tài)才會準確。 但是,如果你想要將按鈕綁定到模型的valid狀態(tài)的話,您可以調(diào)用model.validate(decorateErrors = false)強制所有驗證器報告其結(jié)果,而不會實際上向用戶顯示任何驗證錯誤。
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)
注意登錄按鈕的啟用狀態(tài)(enabled state)如何通過enableWhen { model.valid }調(diào)用綁定到模式的啟用狀態(tài)(enabled state)。 在配置了字段和驗證器之后, model.validate(decorateErrors = false)確保模型的有效狀態(tài)被更新,卻不會在驗證失敗的字段上觸發(fā)錯誤裝飾(triggering error decorations)。 默認情況下,裝飾器將會在值變動時介入,除非你將trigger參數(shù)覆蓋為validator 。 這里的required()內(nèi)建驗證器也接受此參數(shù)。 例如,為了只有當輸入字段失去焦點時才運行驗證器,可以調(diào)用textfield(username).required(ValidationTrigger.OnBlur) 。
對話框中的驗證
對話框(dialog)構(gòu)建器使用表單(form)和字段集(fieldset)創(chuàng)建一個窗口,然后開始向其添加字段。 有些時候?qū)@樣的情形你沒有ViewModel,但您可能仍然希望使用它提供的功能。 對于這種情況,您可以內(nèi)聯(lián)(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參數(shù)連接到上下文。 這對于進行字段場驗證是至關(guān)重要的。
部分提交
還可以通過提供要提交的字段列表,來避免提交所有內(nèi)容,來進行部分提交(partial commit)。 這可以在您編輯不同視圖的同一個ViewModel實例時提供方便,例如在向?qū)В╓izard)中。 有關(guān)部分提交(partial commit)的更多信息,以及相應(yīng)的部分驗證(partial validation)功能,請參閱向?qū)д拢╓izard chapter)。
TableViewEditModel
如果您屏幕空間有限,從而不具備主/細節(jié)設(shè)置TableView的空間,有效的選擇是直接編輯TableView。通過啟用TornadoFX一些改進的特性,不僅可以使單元容易編輯(enable easy cell editing),也使臟狀態(tài)容易跟蹤,提交和回滾。通過調(diào)用enableCellEditing()和enableDirtyTracking(),以及訪問TableView的tableViewEditModel屬性,就可以輕松啟用此功能。
當您編輯一個單元格,藍色標記將指示其臟狀態(tài)。調(diào)用rollback()將恢復(fù)臟單元到其原始值,而commit()將設(shè)置當前值作為新的基準(并刪除所有臟的狀態(tài)歷史)。
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
}

還要注意有很多其他有用的TableViewEditModel的特性和功能。其中items屬性是一個ObservableMap<S, TableColumnDirtyState<S>>,映射每個記錄項的臟狀態(tài)S。如果您想篩選出并只提交臟的記錄,從而將其持久存儲在某處,你可以使用“提交”Button執(zhí)行此操作。
button("COMMIT").action {
tableViewEditModel.items.asSequence()
.filter { it.value.isDirty }
.forEach {
println("Committing ${it.key}")
it.value.commit()
}
}
還有commitSelected()和rollbackSelected(),只提交或回滾在TableView中選定的記錄。