譯自《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<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簡介
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<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的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<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
來更改源對象。 ItemViewModel
是ViewModel
的擴展,幾乎所有使用的情況下,您都希望繼承ItemViewModel
而不是ViewModel
類。
ItemViewModel
具有一個名為itemProperty
的屬性,因此我們的PersonModel
現在看起來像這樣:
class PersonModel : ItemViewModel<Person>() {
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<Person>() {
val firstname = bind { item?.firstName?.toProperty() }
val lastName = bind { item?.lastName?.toProperty() }
}
OnCommit回調
有時在模型成功提交后,還想要(desirable)做一個特定的操作。 ViewModel
為此提供了兩個回調onCommit
和onCommit(commits: List<Commit>)
。
第一個函數onCommit
,沒有參數,并在成功提交后被調用, 在可選successFn
被調用之前(請參閱: commit
)。
將以相同的順序調用第二個函數,但是傳遞一個已經提交屬性的列表(passing a list of committed properties)。
列表中的每個Commit
,包含原來的ObservableValue
, 即oldValue
和newValue
以及一個changed
屬性,以提示oldValue
與newValue
是否不同。
我們來看一個例子,演示我們如何只檢索已更改的對象并將它們打印到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
認為對于輸入值沒有任何可報告的,則返回null
。 ValidationMessage
可以可選地添加文本消息,通常由配置于ValidationContext
的Decorator
顯示。 以后我們將會更多地介紹裝飾(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
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<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>>
,映射每個記錄項的臟狀態S。如果您想篩選出并只提交臟的記錄,從而將其持久存儲在某處,你可以使用“提交”Button
執行此操作。
button("COMMIT").action {
tableViewEditModel.items.asSequence()
.filter { it.value.isDirty }
.forEach {
println("Committing ${it.key}")
it.value.commit()
}
}
還有commitSelected()
和rollbackSelected()
,只提交或回滾在TableView
中選定的記錄。