TornadoFX編程指南,第11章算行,編輯模型和驗證

譯自《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)的難點或麻煩类少,所以建議在需要時利用ViewModelViewModelItem叙身。

通常,您將在大多數(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)建一個不使用ViewModelView版本活尊。

圖11.1

以下是我們第一次嘗試構(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簡介

ViewModelTableViewForm之間的調(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ā)生了什么呢着饥? 我們引入了一個稱為PersonModelViewModel的子類犀农。 該模型持有一個Person對象,并具有nametitle字段的屬性宰掉。 在我們查看其余客戶端代碼后呵哨,我們將進一步討論該模型赁濒。

請注意,我們不會引用TableView或文本字段孟害。 除了很少的代碼拒炎,第一個大的變化是我們更新模型中的Person的方式:

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

rebindOnChange()函數(shù)將TableView作為一個參數(shù),以及一個在選擇更改時被調(diào)用的函數(shù)挨务。 這對ListView 击你, TreeViewTreeTableView和任何其他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代理定義了兩個看起來奇怪的屬性, nametitle彤蔽。 是的构诚,它看起來很奇怪,但是有一個非常好的理由铆惑。 bind函數(shù)的{ person.nameProperty() }參數(shù)是一個返回屬性的lambda。 此返回的屬性由ViewModel進行檢查送膳,并創(chuàng)建相同類型的新屬性员魏。 它被放在ViewModelname屬性中。

當(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)。

臟檢查

該模型有一個稱為dirtyProperty群发。 這是一個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的gettersetter牛曹,或正常的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來更改源對象画畅。 ItemViewModelViewModel的擴展,幾乎所有使用的情況下宋距,您都希望繼承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)onCommitonCommit(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豆瘫, 即oldValuenewValue以及一個changed屬性,以提示oldValuenewValue是否不同菊值。

我們來看一個例子外驱,演示我們?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可以可選地添加文本消息,通常由配置于ValidationContextDecorator顯示贝室。 以后我們將會更多地介紹裝飾(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

ValidationContextdecorationProvider負責(zé)在將ValidationMessage與輸入相關(guān)聯(lián)時提供反饋(feedback)。 默認情況下堪旧,這是SimpleMessageDecorator的一個實例削葱,它將在輸入字段的頂部左上角顯示彩色三角形標記,并在輸入獲得焦點的同時顯示帶有消息的彈出窗口淳梦。

圖11.2 顯示必填字段驗證消息的默認裝飾器

如果您不喜歡默認的裝飾器外觀佩耳,可以通過實現(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 累驮, warningsuccessinfo 舵揭。

驗證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() }
        }
    }
}
圖11.3帶有內(nèi)聯(lián)ViewModel上下文的對話框

注意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
}
圖11.4 TableView臟狀態(tài)跟蹤,用rollback()和commit()功能

還要注意有很多其他有用的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中選定的記錄黎茎。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市当悔,隨后出現(xiàn)的幾起案子傅瞻,更是在濱河造成了極大的恐慌,老刑警劉巖盲憎,帶你破解...
    沈念sama閱讀 222,183評論 6 516
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件嗅骄,死亡現(xiàn)場離奇詭異,居然都是意外死亡饼疙,警方通過查閱死者的電腦和手機溺森,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,850評論 3 399
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來窑眯,“玉大人屏积,你說我怎么就攤上這事“跛Γ” “怎么了炊林?”我有些...
    開封第一講書人閱讀 168,766評論 0 361
  • 文/不壞的土叔 我叫張陵,是天一觀的道長卷要。 經(jīng)常有香客問我渣聚,道長,這世上最難降的妖魔是什么却妨? 我笑而不...
    開封第一講書人閱讀 59,854評論 1 299
  • 正文 為了忘掉前任饵逐,我火速辦了婚禮括眠,結(jié)果婚禮上彪标,老公的妹妹穿的比我還像新娘。我一直安慰自己掷豺,他們只是感情好捞烟,可當(dāng)我...
    茶點故事閱讀 68,871評論 6 398
  • 文/花漫 我一把揭開白布薄声。 她就那樣靜靜地躺著,像睡著了一般题画。 火紅的嫁衣襯著肌膚如雪默辨。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,457評論 1 311
  • 那天苍息,我揣著相機與錄音缩幸,去河邊找鬼。 笑死竞思,一個胖子當(dāng)著我的面吹牛表谊,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播盖喷,決...
    沈念sama閱讀 40,999評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼爆办,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了课梳?” 一聲冷哼從身側(cè)響起距辆,我...
    開封第一講書人閱讀 39,914評論 0 277
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎暮刃,沒想到半個月后跨算,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,465評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡椭懊,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,543評論 3 342
  • 正文 我和宋清朗相戀三年漂彤,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片灾搏。...
    茶點故事閱讀 40,675評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡挫望,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出狂窑,到底是詐尸還是另有隱情媳板,我是刑警寧澤,帶...
    沈念sama閱讀 36,354評論 5 351
  • 正文 年R本政府宣布泉哈,位于F島的核電站蛉幸,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏丛晦。R本人自食惡果不足惜奕纫,卻給世界環(huán)境...
    茶點故事閱讀 42,029評論 3 335
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望烫沙。 院中可真熱鬧匹层,春花似錦、人聲如沸锌蓄。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,514評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至您访,卻和暖如春铅忿,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背灵汪。 一陣腳步聲響...
    開封第一講書人閱讀 33,616評論 1 274
  • 我被黑心中介騙來泰國打工檀训, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人享言。 一個月前我還...
    沈念sama閱讀 49,091評論 3 378
  • 正文 我出身青樓肢扯,卻偏偏與公主長得像,于是被迫代替她去往敵國和親担锤。 傳聞我的和親對象是個殘疾皇子蔚晨,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,685評論 2 360

推薦閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn)肛循,斷路器铭腕,智...
    卡卡羅2017閱讀 134,708評論 18 139
  • 譯自《Data Controls》 數(shù)據(jù)控件 任何重要的應(yīng)用程序都會使用數(shù)據(jù)夹孔,并為用戶提供查看被盈,操作和修改數(shù)據(jù)的方...
    公子小水閱讀 3,108評論 0 5
  • 1.1 談一談GCD和NSOperation的區(qū)別? 首先二者都是多線程相關(guān)的概念搭伤,當(dāng)然在使用中也是根據(jù)不同情境進...
    John_LS閱讀 1,313評論 0 12
  • 注意:這是一篇譯文只怎,如果你夠裝逼,完全可以瀏覽原文:Sketch Tutorial for iOS Develop...
    Andy矢倉閱讀 17,009評論 10 158
  • 1.『找到有效的“鉤子”』 想要在短時間內(nèi)快速吸引聽眾的注意力怜俐,要下對餌身堡,使其上鉤。把你想要說的內(nèi)容拍鲤,找出你最特別...
    咿呀作語閱讀 133評論 0 2