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

譯自《Editing Models and Validation

編輯模型和驗證

作為開發人員,TornadoFX不會對你強制任何特定的架構模式,它對MVCMVP兩者及其衍生模式都工作得很好。

為了幫助實現這些模式,TornadoFX提供了一個名為ViewModel的工具,可幫助您清理您的UI和業務邏輯,為您提供回滾/提交(rollback/commit)臟狀態檢查(dirty state checking)等功能 。 這些模式是手動實現的難點或麻煩,所以建議在需要時利用ViewModelViewModelItem

通常,您將在大多數情況下使用ViewModelItem,而非ViewModel,但是...

典型用例

假設你有一個給定的領域類型(domain type)的Person。 我們允許其兩個屬性為空,以便用戶稍后輸入。

class Person(name: String? = null, title: String? = null) {
    val nameProperty = SimpleStringProperty(this, "name", name)
    var name by nameProperty

    val titleProperty = SimpleStringProperty(this, "title", title)
    var title by titleProperty 
}

考慮一個Master/Detail視圖,其中有一個TableView顯示人員列表,以及可以編輯當前選定的人員信息的Form。 在討論ViewModel之前,我們將創建一個不使用ViewModelView版本。

圖11.1

以下是我們第一次嘗試構建的代碼,它有一些我們將要解決的問題。

import javafx.scene.control.TableView
import javafx.scene.control.TextField
import javafx.scene.layout.BorderPane
import tornadofx.*

class Person(name: String? = null, title: String? = null) {
    val nameProperty = SimpleStringProperty(this, "name", name)
    var name by nameProperty

    val titleProperty = SimpleStringProperty(this, "title", title)
    var title by titleProperty 
}

class PersonEditor : View("Person Editor") {
    override val root = BorderPane()
    var nameField : TextField by singleAssign()
    var titleField : TextField by singleAssign()
    var personTable : TableView<Person> by singleAssign()
    // Some fake data for our table
    val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()

    var prevSelection: Person? = null

    init {
        with(root) {
            // TableView showing a list of people
            center {
                tableview(persons) {
                    personTable = this
                    column("Name", Person::nameProperty)
                    column("Title", Person::titleProperty)

                    // Edit the currently selected person
                    selectionModel.selectedItemProperty().onChange {
                        editPerson(it)
                        prevSelection = it
                    }
                }
            }

            right {
                form {
                    fieldset("Edit person") {
                        field("Name") {
                            textfield() {
                                nameField = this
                            }
                        }
                        field("Title") {
                            textfield() {
                                titleField = this
                            }
                        }
                        button("Save").action {
                            save()
                        }
                    }
                }
            }
        }
    }

    private fun editPerson(person: Person?) {
        if (person != null) {
            prevSelection?.apply {
                nameProperty.unbindBidirectional(nameField.textProperty())
                titleProperty.unbindBidirectional(titleField.textProperty())
            }
            nameField.bind(person.nameProperty())
            titleField.bind(person.titleProperty())
            prevSelection = person
        }
    }

    private fun save() {
        // Extract the selected person from the tableView
        val person = personTable.selectedItem!!

        // A real application would persist the person here
        println("Saving ${person.name} / ${person.title}")
    }
}

我們定義一個由BorderPane中心的TableView和右側Form組成的View 。 我們為表單域和表本身定義一些屬性,以便稍后引用它們。

當我們構建表時,我們將一個監聽器附加到所選項目,從而當表格的選擇更改時,我們可以調用editPerson()函數。 editPerson()函數將所選人員的屬性綁定到表單中的文本字段。

我們初次嘗試的問題

乍看起來可能還不錯,但是當我們深入挖掘時,有幾個問題。

手動綁定(Manual binding)

每次表中的選擇發生變化時,我們必須手動取消綁定/重新綁定表單域的數據。 除了增加的代碼和邏輯,還有另一個巨大的問題:文本字段中的每個變化都會導致數據更新,這種更改甚至將反映在表中。 雖然這可能看起來很酷,在技術上是正確的,但它提出了一個大問題:如果用戶不想保存更改,該怎么辦? 我們沒有辦法回滾。 所以為了防止這一點,我們必須完全跳過綁定,并手動從文本字段提取值,然后在保存時創建一個新的Person對象。 事實上,這是許多應用程序中都能發現的一種模式,大多數用戶都希望這樣做。 為此表單實現“重置”按鈕,將意味著使用初始值管理變量,并再次將這些值手動賦值給文本字段。

緊耦合(Tight Coupling)

另一個問題是,當它要保存編輯的人的時候,保存函數必須再次從表中提取所選項目。 為了能這么做,保存函數必須知道TableView。 或者,它必須知道文本字段,像editPerson()函數這樣,并手動提取值來重建一個Person對象。

ViewModel簡介

ViewModelTableViewForm之間的調解器。 它作為文本字段中的數據和實際Person對象中的數據之間的中間人。 如你所見,代碼要短得多,容易理解。 PersonModel的實現代碼將很快顯示出來。 現在只關注它的用法。

class PersonEditor : View("Person Editor") {
    override val root = BorderPane()
    val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()
    val model = PersonModel(Person())

    init {
        with(root) {
            center {
                tableview(persons) {
                    column("Name", Person::nameProperty)
                    column("Title", Person::titleProperty)

                    // Update the person inside the view model on selection change
                    model.rebindOnChange(this) { selectedPerson ->
                        person = selectedPerson ?: Person()
                    }
                }
            }

            right {
                form {
                    fieldset("Edit person") {
                        field("Name") {
                            textfield(model.name)
                        }
                        field("Title") {
                            textfield(model.title)
                        }
                        button("Save") {
                            enableWhen(model.dirty)
                            action {
                                save()
                            }
                        }
                        button("Reset").action {
                            model.rollback()
                        }
                    }
                }
            }
        }
    }

    private fun save() {
        // Flush changes from the text fields into the model
        model.commit()

        // The edited person is contained in the model
        val person = model.person

        // A real application would persist the person here
        println("Saving ${person.name} / ${person.title}")
    }

}
class PersonModel(var person: Person) : ViewModel() {
    val name = bind { person.nameProperty }
    val title = bind { person.titleProperty }
}

這看起來好多了,但到底究竟發生了什么呢? 我們引入了一個稱為PersonModelViewModel的子類。 該模型持有一個Person對象,并具有nametitle字段的屬性。 在我們查看其余客戶端代碼后,我們將進一步討論該模型。

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

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

rebindOnChange()函數將TableView作為一個參數,以及一個在選擇更改時被調用的函數。 這對ListViewTreeViewTreeTableView和任何其他ObservableValue都可以工作。 此函數在模型上調用,并將selectedPerson作為其單個參數。 我們將所選人員賦值給模型的person屬性,或者如果選擇為空/ null,則將其指定為新Person。 這樣,我們確保總是有模型呈現的數據。

當我們創建TextField時,我們將模型屬性直接綁定給它,因為大多數Node都可以接受一個ObservableValue來綁定。

field("Name") {
    textfield(model.name)
}

即使選擇更改,模型屬性仍然保留,但屬性的值將更新。 我們完全避免了此前嘗試的手動綁定。

該版本的另一個重大變化是,當我們鍵入文本字段時,表中的數據不會更新。 這是因為模型已經從person對象暴露了屬性的副本,并且在調用model.commit()之前不會寫回到實際的person對象中。 這正是我們在save函數中所做的。 一旦commit()被調用,界面對象(facade)中的數據就會被刷新回到我們的person對象中,現在表格將反映我們的變化。

回滾

由于模型持有對實際Person對象的引用,我們可以重置文本字段以反映我們的Person對象中的實際數據。 我們可以添加如下所示的重置按鈕:

button("Reset").action {
    model.rollback()
}

當按下按鈕時,任何更改將被丟棄,文本字段再次顯示實際的Person對象的值。

PersonModel

我們從來沒有解釋過PersonModel的工作原理,您可能一直在想知道PersonModel如何實現。 這里就是:

class PersonModel(var person: Person) : ViewModel() {
    val name = bind { person.nameProperty }
    val title = bind { person.titleProperty }
}

它可以容納一個Person對象,它通過bind代理定義了兩個看起來奇怪的屬性, nametitle。 是的,它看起來很奇怪,但是有一個非常好的理由。 bind函數的{ person.nameProperty() }參數是一個返回屬性的lambda。 此返回的屬性由ViewModel進行檢查,并創建相同類型的新屬性。 它被放在ViewModelname屬性中。

當我們將文本字段綁定到模型的name屬性時,只有當您鍵入文本字段時才會更新該副本。 ViewModel跟蹤哪個實體屬性屬于哪個界面對象(facade),當您調用commit,將從界面對象(facade)的值刷入實際的后備屬性(backing property)。 另一方面,當您調用rollback時會發生恰恰相反的情況:實際屬性值被刷入界面對象(facade)。

實際屬性包含在函數中的原因在于,這樣可以更改person變量,然后從該新的person中提取屬性。 您可以在下面閱讀更多信息(重新綁定,rebinding)。

臟檢查

該模型有一個稱為dirtyProperty。 這是一個BooleanBinding,您可以監視(observe)該屬性,據此以啟用或禁用某些特性。 例如,我們可以輕松地禁用保存按鈕,直到有實際的更改。 更新的保存按鈕將如下所示:

button("Save") {
    enableWhen(model.dirty)
    action {
        save()
    }
}

還有一個簡單的val稱為isDirty,它返回一個Boolean表示整個模型的臟狀態。

需要注意的一點是,如果在通過UI修改ViewModel的同時修改了后臺對象,則ViewModel中的所有未提交的更改都將被后臺對象中的更改所覆蓋。 這意味著如果發生后臺對象的外部修改, ViewModel的數據可能會丟失。

val person = Person("John", "Manager")
val model = PersonModel(person)

model.name.value = "Johnny"   //modify the ViewModel
person.name = "Johan"         //modify the underlying object

println("  Person = ${person.name}, ${person.title}")             //output:   Person = Johan, Manager
println("Is dirty = ${model.isDirty}")                            //output: Is dirty = false
println("   Model = ${model.name.value}, ${model.title.value}")   //output:    Model = Johan, Manager

如上所述,當基礎對象被修改時, ViewModel的更改被覆蓋。 而且ViewModel沒被標記為dirty

臟屬性(Dirty Properties)

您可以檢查特定屬性是否為臟,這意味著它與后備的源對象值相比已更改。

val nameWasChanged = model.isDirty(model.name)

還有一個擴展屬性版本完成相同的任務:

val nameWasChange = model.name.isDirty

速記版本是Property<T>的擴展名,但只適用于ViewModel內綁定的屬性。 你會發現還有model.isNotDirty屬性。

如果您需要根據ViewModel特定屬性的臟狀態進行動態響應,則可以獲取一個BooleanBinding表示該字段的臟狀態,如下所示:

val nameDirtyProperty = model.dirtyStateFor(PersonModel::name)

提取源對象值

要檢索屬性的后備對象值(backing object value),可以調用model.backingValue(property)

val person = model.backingValue(property)

支持沒有暴露JavaFX屬性的對象

您可能想知道如何處理沒有使用JavaFX屬性的領域對象(domain objects)。 也許你有一個簡單的POJO的gettersetter,或正常的Kotlin var類型屬性。 由于ViewModel需要JavaFX屬性,TornadoFX附帶強大的包裝器,可以將任何類型的屬性轉換成可觀察的(observable)JavaFX屬性。 這里有些例子:

// Java POJO getter/setter property
class JavaPersonViewModel(person: JavaPerson) : ViewModel() {
    val name = bind { person.observable(JavaPerson::getName, JavaPerson::setName) }
}

// Kotlin var property
class PersonVarViewModel(person: Person) : ViewModel() {
    val name = bind { person.observable(Person::name) }
}

您可以看到,很容易將任何屬性類型轉換為observable屬性。 當Kotlin 1.1發布時,上述語法將進一步簡化非基于JavaFX的屬性。

特定屬性子類型(IntegerProperty,BooleanProperty)

例如,如果綁定了一個IntegerProperty ,那么界面對象(facade)屬性的類型將看起來像Property<Int>,但是它在實際上是IntegerProperty。 如果您需要訪問IntegerProperty提供的特殊功能,則必須轉換綁定結果:

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

同樣,您可以通過指定只讀類型來公開只讀屬性:

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

這樣做的原因是類型系統的一個不幸的缺點,它阻止編譯器對這些特定類型的重載bind函數進行區分,因此ViewModel的單個bind函數檢查屬性類型并返回最佳匹配,但遺憾的是返回類型簽名現在必須是Property<T>

重新綁定(Rebinding)

正如您在上面的TableView示例中看到的,可以更改由ViewModel包裝的領域對象。 這個測試案例說明了以下幾點:

@Test fun swap_source_object() {
    val person1 = Person("Person 1")
    val person2 = Person("Person 2")

    val model = PersonModel(person1)
    assertEquals(model.name, "Person 1")

    model.rebind { person = person2 }
    assertEquals(model.name, "Person 2")
}

該測試創建兩個Person對象和一個ViewModel。 該模型以第一個person對象初始化。 然后檢查該model.name對應于person1的名稱。 現在奇怪的是:

model.rebind { person = person2 }

上面的rebind()塊中的代碼將被執行,并且模型的所有屬性都使用新的源對象的值進行更新。 這實際上類似于寫作:

model.person = person2
model.rollback()

您選擇的形式取決于您,但第一種形式可以確保你不會忘記調用重新綁定(rebind)。 調用rebind后,模型并不臟,所有的值都將反映形成新的源對象的值(all values will reflect the ones form the new source object or source objects)。 重要的是要注意,您可以將多個源對象傳遞給視圖模型(pass multiple source objects to a view model),并根據您的需要更新其中的所有或一些。

Rebind Listener

我們的TableView示例調用了rebindOnChange()函數,并將TableView作為第一個參數傳遞。 這確保了在更改了TableView的選擇時會調用rebind。 這實際上只是一個具有相同名稱的函數的快捷方式,該函數使用observable,并在每次觀察到更改時調用重新綁定。 如果您調用此函數,則不需要手動調用重新綁定(rebind),只要您具有表示狀態更改的observable,其應導致模型重新綁定(rebind)。

如您所見, TableView具有selectionModel.selectedItemProperty的快捷方式支持(shorthand support)。 如果不是這個快捷函數調用,你必須這樣寫:

model.rebindOnChange(table.selectionModel.selectedItemProperty()) {
    person = it ?: Person()
}

包括上述示例是用來闡明rebindOnChange()函數背后的工作原理。 對于涉及TableView的實際用例,您應該選擇較短的版本或使用ItemViewModel

ItemViewModel

當使用ViewModel時,您會注意到一些重復的和有些冗長的任務。 這包括調用rebind或配置rebindOnChange來更改源對象。 ItemViewModelViewModel的擴展,幾乎所有使用的情況下,您都希望繼承ItemViewModel而不是ViewModel類。

ItemViewModel具有一個名為itemProperty的屬性,因此我們的PersonModel現在看起來像這樣:

class PersonModel : ItemViewModel<Person>() {
    val name = bind(Person::nameProperty) 
    val title = bind(Person::titleProperty)
}

你會注意到,我們不再需要傳入構造函數中的var person: PersonItemViewModel現在具有一個observable屬性 itemProperty,以及通過item屬性的實現的getter/setter。 每當您為item賦值(或通itemProperty.value),該模型就自動幫你重新綁定(automatically rebound for you)。還有一個可觀察的empty布爾值,可以用來檢查ItemViewModel當前是否持有一個Person

綁定表達式(binding expressions)需要考慮到它在綁定時可能不代表任何項目。 這就是為什么以上綁定表達式現在使用null安全運算符(null safe operator)。

我們只是擺脫了一些樣板(boiler plate),但是ItemViewModel給了我們更多的功能。 還記得我們是如何將TableView選定的person與之前的模型綁定在一起的嗎?

// Update the person inside the view model on selection change
model.rebindOnChange(this) { selectedPerson ->
    person = selectedPerson ?: Person()
}

使用ItemViewModel可以這樣重寫:

// Update the person inside the view model on selection change
bindSelected(model)

這將有效地附加我們必須手動編寫的監聽器(attach the listener),并確保TableView的選擇在模型中可見。

save()函數現在也會稍有不同,因為我們的模型中沒有person屬性:

private fun save() {
    model.commit()
    val person = model.item
    println("Saving ${person.name} / ${person.title}")
}

這里的person是使用來自itemProperty的item getter`提取的。

從1.7.1開始,當使用ItemViewModel()和POJO,您可以如下創建綁定:

data class Person(val firstName: String, val lastName: String)

class PersonModel : ItemViewModel<Person>() {
    val firstname = bind { item?.firstName?.toProperty() }
    val lastName = bind { item?.lastName?.toProperty() }
}

OnCommit回調

有時在模型成功提交后,還想要(desirable)做一個特定的操作。 ViewModel為此提供了兩個回調onCommitonCommit(commits: List<Commit>)

第一個函數onCommit,沒有參數,并在成功提交后被調用, 在可選successFn被調用之前(請參閱: commit)。

將以相同的順序調用第二個函數,但是傳遞一個已經提交屬性的列表(passing a list of committed properties)。

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

我們來看一個例子,演示我們如何只檢索已更改的對象并將它們打印到stdout

要找出哪個對象發生了變化,我們定義了一個小的擴展函數,它將會找到給定的屬性, 并且如果有改變,則將返回舊值和新值,如果沒有改變則返回null

class PersonModel : ItemViewModel<Person>() {

    val firstname = bind(Person::firstName)
    val lastName = bind(Person::lastName)

    override val onCommit(commits: List<Commit>) {
       // The println will only be called if findChanged is not null 
       commits.findChanged(firstName)?.let { println("First-Name changed from ${it.first} to ${it.second}")}
       commits.findChanged(lastName)?.let { println("Last-Name changed from ${it.first} to ${it.second}")}
    }

    private fun <T> List<Commit>.findChanged(ref: Property<T>): Pair<T, T>? {
        val commit = find { it.property == ref && it.changed}
        return commit?.let { (it.newValue as T) to (it.oldValue as T) }
    }
}

可注入模型(Injectable Models)

最常見的是,您將不會在同一View同時擁有TableView和編輯器。 那么,我們需要從至少兩個不同的視圖訪問ViewModel,一個用于TableView,另一個用于表單(form)。 幸運的是, ViewModel是可注入的,所以我們可以重寫我們的編輯器示例并拆分這兩個視圖:

class PersonList : View("Person List") {
    val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()
    val model : PersonModel by inject()

    override val root = tableview(persons) {
        title = "Person"
        column("Name", Person::nameProperty)
        column("Title", Person::titleProperty)
        bindSelected(model)
    }
}

TableView現在變得更簡潔,更容易理解。 在實際應用中,人員名單可能來自控制器(controller)或遠程通話(remoting call)。 該模型簡單地注入到View,我們將為編輯器做同樣的事情:

class PersonEditor : View("Person Editor") {
    val model : PersonModel by inject()

    override val root = form {
        fieldset("Edit person") {
            field("Name") {
                textfield(model.name)
            }
            field("Title") {
                textfield(model.title)
            }
           button("Save") {
                enableWhen(model.dirty)
                action {
                    save()
                }
            }
            button("Reset").action {
                model.rollback()
            }
        }
    }

    private fun save() {
        model.commit()
        println("Saving ${model.item.name} / ${model.item.title}")
    }
}

模型的注入實例將在兩個視圖中完全相同。 再次,在真正的應用程序中,保存調用可能會被卸載異步訪問控制器。

何時使用ViewModel與ItemViewModel

本章從ViewModel的低級實現直到流線化(streamlined)的ItemViewModel 。 你可能會想知道是否有任何用例,需繼承ViewModel而不是ItemViewModel。 答案是,盡管您通常在90%以上的時間會擴展ItemViewModel,總還是會出現一些沒有意義的用例。 由于ViewModels可以被注入,且用于保持導航狀態和整體UI狀態,所以您可以將它用于沒有單個領域對象的情況 - 您可以擁有多個領域對象,或僅僅是一個松散屬性的集合。 在這種用例中, ItemViewModel沒有任何意義,您可以直接實現ViewModel。 對于常見的情況,ItemViewModel是您最好的朋友。

這種方法有一個潛在的問題。 如果我們要顯示多“對”列表和表單(multiple "pairs" of lists and forms),也許在不同的窗口中,我們需要一種方法,來分離和綁定(separate and bind)屬于一個特定對的列表和表單(specific pair of list and form)的模型(model)。 有很多方法可以解決這個問題,但是一個非常適合這一點的工具就是范圍(scopes)。 有關此方法的更多信息,請查看范圍(scope)的文檔。

驗證(Validation)

幾乎每個應用程序都需要檢查用戶提供的輸入是否符合一組規則,看是否可以接受。 TornadoFX具有可擴展的驗證和裝飾框架(extensible validation and decoration framework)。

在將其與ViewModel集成之前,我們將首先將驗證(validation)視為獨立功能。

在幕后(Under the Hood)

以下解釋有點冗長,并不反映您在應用程序中編寫驗證碼的方式。 本部分將為您提供對驗證(validation)如何工作以及各個部件如何組合在一起的扎實理解。

Validator

Validator知道如何檢查指定類型的用戶輸入,并返回一個ValidationMessage,其中的ValidationSeverity描述輸入如何與特定控件的預期輸入進行比較。 如果Validator認為對于輸入值沒有任何可報告的,則返回nullValidationMessage可以可選地添加文本消息,通常由配置于ValidationContextDecorator顯示。 以后我們將會更多地介紹裝飾(decorators)。

支持以下嚴重性級別(severity levels):

  • Error - 不接受輸入
  • Warning - 輸入不理想,但被接受
  • Success - 輸入被接受
  • Info - 輸入被接受

有多個嚴重性級別(severity levels)都代表成功的輸入,以便在大多數情況下更容易提供上下文正確的反饋(contextually correct feedback)。 例如,無論輸入值如何,您可能需要給出一個字段的信息性消息(informational message),或者在輸入時特別標記帶有綠色復選框的字段。 導致無效狀態(invalid status)的唯一嚴重性是Error級別。

ValidationTrigger

默認情況下,輸入值發生變化時將進行驗證。 輸入值始終為ObservableValue<T>,默認觸發器只是監聽更改。 你可以選擇當輸入字段失去焦點時,或者當點擊保存按鈕時進行驗證。 可以為每個驗證器配置以下ValidationTriggers

  • OnChange - 輸入值更改時進行驗證,可選擇以毫秒為單位的給定延遲
  • OnBlur - 當輸入字段失去焦點時進行驗證
  • Never - 僅在調用ValidationContext.validate()時才驗證

ValidationContext

通常您將一次性驗證來自多個控件或輸入字段的用戶輸入。 您可以在ValidationContext存放這些驗證器,以便您可以檢查所有驗證器是否有效,或者要求驗證上下文(validation context)在任何給定時間對所有字段執行驗證。 該上下文(context)還控制什么樣的裝飾器(decorator)將用于傳達驗證消息(convey the validation message)給每個字段。 請參閱下面的Ad Hoc驗證示例。

Decorator

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

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

如果您不喜歡默認的裝飾器外觀,可以通過實現Decorator輕松創建自己的Decorator界面:

interface Decorator {
    fun decorate(node: Node)
    fun undecorate(node: Node)
}

您可以將您的裝飾器分配給給定的ValidationContext,如下所示:

context.decorationProvider = MyDecorator()

提示:您可以創建一個裝飾器(decorator),將CSS樣式類應用于輸入,而不是覆蓋其他節點以提供反饋。

Ad Hoc驗證(Ad Hoc Validation)

雖然您可能永遠不會在實際應用程序中執行此操作,但是可以設置ValidationContext并手動應用驗證器。 下面的示例實際上是從本框架的內部測試中獲取的。 它說明了這個概念,但不是應用程序中的實際模式。

// Create a validation context
val context = ValidationContext()

// Create a TextField we can attach validation to
val input = TextField()

// Define a validator that accepts input longer than 5 chars
val validator = context.addValidator(input, input.textProperty()) {
    if (it!!.length < 5) error("Too short") else null
}

// Simulate user input
input.text = "abc"

// Validation should fail
assertFalse(validator.validate())

// Extract the validation result
val result = validator.result

// The severity should be error
assertTrue(result is ValidationMessage && result.severity == ValidationSeverity.Error)

// Confirm valid input passes validation
input.text = "longvalue"
assertTrue(validator.validate())
assertNull(validator.result)

特別注意addValidator調用的最后一個參數。 這是實際的驗證邏輯。 該函數被傳入待驗證屬性的當前輸入,且在沒有消息時必須返回null,或在對輸入如果有值得注意的情況,則返回ValidationMessage的實例。 具有嚴重性Error的消息將導致驗證失敗。 你可以看到,不需要實例化一個ValidationMessage自己,只需使用一個函數errorwarningsuccessinfo

驗證ViewModel

每個ViewModel都包含一個ValidationContext,所以你不需要自己實例化一個。 驗證框架與類型安全的構建器集成,甚至提供一些內置的驗證器,比如required驗證器。 回到我們的人物編輯器(person editor),我們可以通過簡單的更改使輸入字段成為必需:

field("Name") {
    textfield(model.name).required()
}

這就是它的一切。這個required驗證器可選擇接收一個消息,如果驗證失敗將顯示給用戶。 默認文字是“這個字段是必需的(This field is required)”。

除了使用內置的驗證器,我們可以手動表達相同的東西:

field("Name") {
    textfield(model.name).validator {
        if (it.isNullOrBlank()) error("The name field is required") else null
    }
}

如果要進一步自定義文本字段,可能需要添加另一組花括號:

field("Name") {
    textfield(model.name) {
        // Manipulate the text field here
        validator {
            if (it.isNullOrBlank()) error("The name field is required") else null
        }
    }
}

將按鈕綁定到驗證狀態(Binding buttons to validation state)

當輸入有效時,您可能只想啟用表單中的某些按鈕。 model.valid屬性可用于此目的。因為默認驗證觸發器是OnChange,只有當您首次嘗試提交模型時,有效狀態才會準確。 但是,如果你想要將按鈕綁定到模型的valid狀態的話,您可以調用model.validate(decorateErrors = false)強制所有驗證器報告其結果,而不會實際上向用戶顯示任何驗證錯誤。

field("username") {
    textfield(username).required()
}
field("password") {
    passwordfield(password).required()
}
buttonbar {
    button("Login", ButtonBar.ButtonData.OK_DONE).action {
        enableWhen { model.valid }
        model.commit {
            doLogin()
        }
    }
}
// Force validators to update the `model.valid` property
model.validate(decorateErrors = false)

注意登錄按鈕的啟用狀態(enabled state)如何通過enableWhen { model.valid }調用綁定到模式的啟用狀態(enabled state)。 在配置了字段和驗證器之后, model.validate(decorateErrors = false)確保模型的有效狀態被更新,卻不會在驗證失敗的字段上觸發錯誤裝飾(triggering error decorations)。 默認情況下,裝飾器將會在值變動時介入,除非你將trigger參數覆蓋為validator 。 這里的required()內建驗證器也接受此參數。 例如,為了只有當輸入字段失去焦點時才運行驗證器,可以調用textfield(username).required(ValidationTrigger.OnBlur)

對話框中的驗證

對話框(dialog)構建器使用表單(form)和字段集(fieldset)創建一個窗口,然后開始向其添加字段。 有些時候對這樣的情形你沒有ViewModel,但您可能仍然希望使用它提供的功能。 對于這種情況,您可以內聯(inline)實例化ViewModel,并將一個或多個屬性連接到它。 這是一個示例對話框,需要用戶在textarea中輸入一些輸入:

dialog("Add note") {
    val model = ViewModel()
    val note = model.bind { SimpleStringProperty() }

    field("Note") {
        textarea(note) {
            required()
            whenDocked { requestFocus() }
        }
    }
    buttonbar {
        button("Save note").action {
            model.commit { doSave() }
        }
    }
}
圖11.3帶有內聯ViewModel上下文的對話框

注意note屬性如何通過指定其bean參數連接到上下文。 這對于進行字段場驗證是至關重要的。

部分提交

還可以通過提供要提交的字段列表,來避免提交所有內容,來進行部分提交(partial commit)。 這可以在您編輯不同視圖的同一個ViewModel實例時提供方便,例如在向導(Wizard)中。 有關部分提交(partial commit)的更多信息,以及相應的部分驗證(partial validation)功能,請參閱向導章(Wizard chapter)。

TableViewEditModel

如果您屏幕空間有限,從而不具備主/細節設置TableView的空間,有效的選擇是直接編輯TableView。通過啟用TornadoFX一些改進的特性,不僅可以使單元容易編輯(enable easy cell editing),也使臟狀態容易跟蹤,提交和回滾。通過調用enableCellEditing()enableDirtyTracking(),以及訪問TableView的tableViewEditModel屬性,就可以輕松啟用此功能。

當您編輯一個單元格,藍色標記將指示其臟狀態。調用rollback()將恢復臟單元到其原始值,而commit()將設置當前值作為新的基準(并刪除所有臟的狀態歷史)。

import tornadofx.*

class MyApp: App(MyView::class)
class MyView : View("My View") {

    val controller: CustomerController by inject()
    var tableViewEditModel: TableViewEditModel<Customer> by singleAssign()

    override val root =  borderpane {
        top = buttonbar {
            button("COMMIT").setOnAction {
                tableViewEditModel.commit()
            }
            button("ROLLBACK").setOnAction {
                tableViewEditModel.rollback()
            }
        }
        center = tableview<Customer> {

            items = controller.customers
            isEditable = true

            column("ID",Customer::idProperty)
            column("FIRST NAME", Customer::firstNameProperty).makeEditable()
            column("LAST NAME", Customer::lastNameProperty).makeEditable()

            enableCellEditing() //enables easier cell navigation/editing
            enableDirtyTracking() //flags cells that are dirty

            tableViewEditModel = editModel
        }
    }
}

class CustomerController : Controller() {
    val customers = listOf(
            Customer(1, "Marley", "John"),
            Customer(2, "Schmidt", "Ally"),
            Customer(3, "Johnson", "Eric")
    ).observable()
}

class Customer(id: Int, lastName: String, firstName: String) {
    val lastNameProperty = SimpleStringProperty(this, "lastName", lastName)
    var lastName by lastNameProperty
    val firstNameProperty = SimpleStringPorperty(this, "firstName", firstName) 
    var firstName by firstNameProperty
    val idProperty = SimpleIntegerProperty(this, "id", id) 
    var id by idProperty
}
圖11.4 TableView臟狀態跟蹤,用rollback()和commit()功能

還要注意有很多其他有用的TableViewEditModel的特性和功能。其中items屬性是一個ObservableMap<S, TableColumnDirtyState<S>>,映射每個記錄項的臟狀態S。如果您想篩選出并只提交臟的記錄,從而將其持久存儲在某處,你可以使用“提交”Button執行此操作。

button("COMMIT").action {
    tableViewEditModel.items.asSequence()
            .filter { it.value.isDirty }
            .forEach {
                println("Committing ${it.key}")
                it.value.commit()
            }
}

還有commitSelected()rollbackSelected(),只提交或回滾在TableView中選定的記錄。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,991評論 19 139
  • 譯自《Data Controls》 數據控件 任何重要的應用程序都會使用數據,并為用戶提供查看,操作和修改數據的方...
    公子小水閱讀 3,170評論 0 5
  • 1.1 談一談GCD和NSOperation的區別? 首先二者都是多線程相關的概念,當然在使用中也是根據不同情境進...
    John_LS閱讀 1,343評論 0 12
  • 注意:這是一篇譯文,如果你夠裝逼,完全可以瀏覽原文:Sketch Tutorial for iOS Develop...
    Andy矢倉閱讀 17,088評論 10 158
  • 1.『找到有效的“鉤子”』 想要在短時間內快速吸引聽眾的注意力,要下對餌,使其上鉤。把你想要說的內容,找出你最特別...
    咿呀作語閱讀 140評論 0 2