譯自《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
顯示人員列表泣侮,以及可以編輯當(dāng)前選定的人員信息的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一些屬性深胳,以便稍后引用它們。
當(dāng)我們構(gòu)建表時铜犬,我們將一個監(jiān)聽器附加到所選項目舞终,從而當(dāng)表格的選擇更改時,我們可以調(diào)用editPerson()
函數(shù)癣猾。 editPerson()
函數(shù)將所選人員的屬性綁定到表單中的文本字段敛劝。
我們初次嘗試的問題
乍看起來可能還不錯,但是當(dāng)我們深入挖掘時纷宇,有幾個問題夸盟。
手動綁定(Manual binding)
每次表中的選擇發(fā)生變化時,我們必須手動取消綁定/重新綁定表單域的數(shù)據(jù)呐粘。 除了增加的代碼和邏輯满俗,還有另一個巨大的問題:文本字段中的每個變化都會導(dǎo)致數(shù)據(jù)更新,這種更改甚至將反映在表中作岖。 雖然這可能看起來很酷唆垃,在技術(shù)上是正確的,但它提出了一個大問題:如果用戶不想保存更改痘儡,該怎么辦辕万? 我們沒有辦法回滾。 所以為了防止這一點沉删,我們必須完全跳過綁定渐尿,并手動從文本字段提取值,然后在保存時創(chuàng)建一個新的Person
對象矾瑰。 事實上砖茸,這是許多應(yīng)用程序中都能發(fā)現(xiàn)的一種模式,大多數(shù)用戶都希望這樣做殴穴。 為此表單實現(xiàn)“重置”按鈕凉夯,將意味著使用初始值管理變量,并再次將這些值手動賦值給文本字段采幌。
緊耦合(Tight Coupling)
另一個問題是劲够,當(dāng)它要保存編輯的人的時候,保存函數(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
。 這樣捍歪,我們確被瑁總是有模型呈現(xiàn)的數(shù)據(jù)。
當(dāng)我們創(chuàng)建TextField
時糙臼,我們將模型屬性直接綁定給它庐镐,因為大多數(shù)Node
都可以接受一個ObservableValue
來綁定。
field("Name") {
textfield(model.name)
}
即使選擇更改变逃,模型屬性仍然保留必逆,但屬性的值將更新。 我們完全避免了此前嘗試的手動綁定揽乱。
該版本的另一個重大變化是名眉,當(dāng)我們鍵入文本字段時,表中的數(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()
}
當(dāng)按下按鈕時映凳,任何更改將被丟棄胆筒,文本字段再次顯示實際的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
屬性中。
當(dāng)我們將文本字段綁定到模型的name
屬性時叠聋,只有當(dāng)您鍵入文本字段時才會更新該副本撕阎。 ViewModel
跟蹤哪個實體屬性屬于哪個界面對象(facade),當(dāng)您調(diào)用commit
碌补,將從界面對象(facade)的值刷入實際的后備屬性(backing property)虏束。 另一方面,當(dāng)您調(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
如上所述,當(dāng)基礎(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
屬性阅虫。 當(dāng)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
當(dāng)使用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
壶唤。 每當(dāng)您為item
賦值(或通itemProperty.value
)雳灵,該模型就自動幫你重新綁定(automatically rebound for you)。還有一個可觀察的empty
布爾值闸盔,可以用來檢查ItemViewModel
當(dāng)前是否持有一個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開始瞬铸,當(dāng)使用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)聽更改琉预。 你可以選擇當(dāng)輸入字段失去焦點時,或者當(dāng)點擊保存按鈕時進行驗證蒿褂。 可以為每個驗證器配置以下ValidationTriggers
:
-
OnChange
- 輸入值更改時進行驗證圆米,可選擇以毫秒為單位的給定延遲 -
OnBlur
- 當(dāng)輸入字段失去焦點時進行驗證 -
Never
- 僅在調(diào)用ValidationContext.validate()
時才驗證
ValidationContext
通常您將一次性驗證來自多個控件或輸入字段的用戶輸入。 您可以在ValidationContext
存放這些驗證器啄栓,以便您可以檢查所有驗證器是否有效娄帖,或者要求驗證上下文(validation context)在任何給定時間對所有字段執(zhí)行驗證。 該上下文(context)還控制什么樣的裝飾器(decorator)將用于傳達驗證消息(convey the validation message)給每個字段昙楚。 請參閱下面的Ad Hoc驗證示例近速。
Decorator
ValidationContext
的decorationProvider
負責(zé)在將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ù)被傳入待驗證屬性的當(dāng)前輸入闲先,且在沒有消息時必須返回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)
當(dāng)輸入有效時,您可能只想啟用表單中的某些按鈕峡钓。 model.valid
屬性可用于此目的妓笙。因為默認驗證觸發(fā)器是OnChange
,只有當(dāng)您首次嘗試提交模型時能岩,有效狀態(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ù)竭业。 例如智润,為了只有當(dāng)輸入字段失去焦點時才運行驗證器,可以調(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
屬性渊季,就可以輕松啟用此功能朋蔫。
當(dāng)您編輯一個單元格罚渐,藍色標記將指示其臟狀態(tài)。調(diào)用rollback()
將恢復(fù)臟單元到其原始值驯妄,而commit()
將設(shè)置當(dāng)前值作為新的基準(并刪除所有臟的狀態(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
中選定的記錄黎茎。