Tornadofx tableview sync two tables

1.2k views Asked by At

Basic newbie question:

I want to sync/bind two tables.
For keeping the example simple, I have used two separate table views. This needs to be done using fragments and scope, which I thought would complicate the question as I am stuck at a basic problem.
Behaviour: On clicking the sync button of table 1 , I want table 1 selected data to override the corresponding table 2 data. and vice-versa

Person Model:

class Person(firstName: String = "", lastName: String = "") {
    val firstNameProperty = SimpleStringProperty(firstName)
    var firstName by firstNameProperty
    val lastNameProperty = SimpleStringProperty(lastName)
    var lastName by lastNameProperty
}

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

Person Controller (dummy data):

class PersonController : Controller(){
    val persons = FXCollections.observableArrayList<Person>()
    val newPersons = FXCollections.observableArrayList<Person>()
    init {
        persons += Person("Dead", "Stark")
        persons += Person("Tyrion", "Lannister")
        persons += Person("Arya", "Stark")
        persons += Person("Daenerys", "Targaryen")

        newPersons += Person("Ned", "Stark")
        newPersons += Person("Tyrion", "Janitor")
        newPersons += Person("Arya", "Stark")
        newPersons += Person("Taenerys", "Dargaryen")
    }
}

Person List View:

class PersonList : View() {
    val ctrl: PersonController by inject()
    val model : PersonModel by inject()
    var personTable : TableView<Person> by singleAssign()
    override val root = VBox()
    init {
        with(root) {
            tableview(ctrl.persons) {
                personTable = this
                column("First Name", Person::firstNameProperty)
                column("Last Name", Person::lastNameProperty)
                columnResizePolicy = SmartResize.POLICY
            }
            hbox {
                button("Sync") {
                    setOnAction {
                        personTable.bindSelected(model)
                        //model.itemProperty.bind(personTable.selectionModel.selectedItemProperty())
                    }
                }
            }
        }
    }

Another Person List View:

class AnotherPersonList : View() {
    val model : PersonModel by inject()
    val ctrl: PersonController by inject()
    override val root = VBox()
    var newPersonTable : TableView<Person> by singleAssign()
    init {
        with(root) {
            tableview(ctrl.newPersons) {
                newPersonTable = this
                column("First Name", Person::firstNameProperty)
                column("Last Name", Person::lastNameProperty)
                columnResizePolicy = SmartResize.POLICY
            }
            hbox {
                button("Sync") {
                    setOnAction {
                        newPersonTable.bindSelected(model)
                    }
                }
            }
        }
    }
}

Sync Two Tables

2

There are 2 answers

3
Edvin Syse On BEST ANSWER

First we need to be able to identify a Person, so include equals/hashCode in the Person object:

class Person(firstName: String = "", lastName: String = "") {
    val firstNameProperty = SimpleStringProperty(firstName)
    var firstName by firstNameProperty
    val lastNameProperty = SimpleStringProperty(lastName)
    var lastName by lastNameProperty

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other?.javaClass != javaClass) return false

        other as Person

        if (firstName != other.firstName) return false
        if (lastName != other.lastName) return false

        return true
    }

    override fun hashCode(): Int {
        var result = firstName.hashCode()
        result = 31 * result + lastName.hashCode()
        return result
    }

}

We want to fire an event when you click the Sync button, so we define an event that can contain both the selected person and the row index:

class SyncPersonEvent(val person: Person, val index: Int) : FXEvent()

You cannot inject the same PersonModel instance and use bindSelected in both views, since that will override each other. Also, bindSelected will react whenever the selection changes, not when you call bindSelected itself, so it doesn't belong in the button handler. We'll use a separate model for each view and bind towards the selection. Then we can easily know what person is selected when the button handler runs, and we don't need to hold on to an instance of the TableView. We'll also use the new root builder syntax to clean up everything. Here is the PersonList view:

class PersonList : View() {
    val ctrl: PersonController by inject()
    val selectedPerson = PersonModel()

    override val root = vbox {
        tableview(ctrl.persons) {
            column("First Name", Person::firstNameProperty)
            column("Last Name", Person::lastNameProperty)
            columnResizePolicy = SmartResize.POLICY
            bindSelected(selectedPerson)
            subscribe<SyncPersonEvent> { event ->
                if (!items.contains(event.person)) {
                    items.add(event.index, event.person)
                }
                if (selectedItem != event.person) {
                    requestFocus()
                    selectionModel.select(event.person)
                }
            }
        }
        hbox {
            button("Sync") {
                setOnAction {
                    selectedPerson.item?.apply {
                        fire(SyncPersonEvent(this, ctrl.persons.indexOf(this)))
                    }
                }

            }
        }
    }
}

The AnotherPersonList view is identical except for the reference to ctrl.newPersons instead of ctrl.persons in two places. (You might use the same fragment and send in the list as a parameter so you don't need to duplicate all this code).

The sync button now fires our event, provided that a person is selected at the time of the button click:

selectedPerson.item?.apply {
    fire(SyncPersonEvent(this, ctrl.persons.indexOf(this)))
}

Inside the TableView we now subscribe to the SyncPersonEvent:

subscribe<SyncPersonEvent> { event ->
    if (!items.contains(event.person)) {
        items.add(event.index, event.person)
    }
    if (selectedItem != event.person) {
        requestFocus()
        selectionModel.select(event.person)
    }
}

The sync event is notified when the event fires. It first checks if the items of for the tableview contains this person, or adds it at the correct index if not. A real application should check that the index is within the bounds of the items list.

Then it checks if this person is selected already and if not it will make the selection and also request focus to this table. The check is important so that the source table doesn't also request focus or perform the (redundant) selection.

As noted, a good optimization would be to send in the items list as a parameter so that you don't need to duplicate the PersonList code.

Also notice the use of the new builder syntax:

override val root = vbox {
}

This is much neater than first declaring the root node as a VBox() and when building the rest of the UI in the init block.

Hope this is what you're looking for :)

Important: This solution requires TornadoFX 1.5.9. It will be released today :) You can build against 1.5.9-SNAPSHOT in the mean time if you like.

2
tmn On

Another option you have is RxJavaFX/RxKotlinFX. I have been writing a companion guide for these libraries just like the TornadoFX one.

When you have to deal with complex event streams and keeping UI components synchronized, reactive programming is effective for these situations.

package org.nield.demo.app


import javafx.beans.property.SimpleStringProperty
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import rx.javafx.kt.actionEvents
import rx.javafx.kt.addTo
import rx.javafx.kt.onChangedObservable
import rx.javafx.sources.CompositeObservable
import rx.lang.kotlin.toObservable
import tornadofx.*

class MyApp: App(MainView::class)

class MainView : View() {
    val personList: PersonList by inject()
    val anotherPersonList: AnotherPersonList by inject()

    override val root = hbox {
        this += personList
        this += anotherPersonList
    }
}

class PersonList : View() {

    val ctrl: PersonController by inject()

    override val root = vbox {
        val table = tableview(ctrl.persons) {
            column("First Name", Person::firstNameProperty)
            column("Last Name", Person::lastNameProperty)

            //broadcast selections
            selectionModel.selectedIndices.onChangedObservable()
                    .addTo(ctrl.selectedLeft)

            columnResizePolicy = SmartResize.POLICY
        }
        button("SYNC").actionEvents()
                .flatMap {
                    ctrl.selectedRight.toObservable()
                            .take(1)
                            .flatMap { it.toObservable() }
                }.subscribe {
                    table.selectionModel.select(it)
                }
    }
}

class AnotherPersonList : View() {
    val ctrl: PersonController by inject()

    override val root = vbox {
        val table = tableview(ctrl.newPersons) {
            column("First Name", Person::firstNameProperty)
            column("Last Name", Person::lastNameProperty)

            //broadcast selections
            selectionModel.selectedIndices.onChangedObservable()
                    .addTo(ctrl.selectedRight)


            columnResizePolicy = SmartResize.POLICY
        }

        button("SYNC").actionEvents()
                .flatMap {
                    ctrl.selectedLeft.toObservable()
                            .take(1)
                            .flatMap { it.toObservable() }
                }.subscribe {
                    table.selectionModel.select(it)
                }
    }
}

class Person(firstName: String = "", lastName: String = "") {
    val firstNameProperty = SimpleStringProperty(firstName)
    var firstName by firstNameProperty
    val lastNameProperty = SimpleStringProperty(lastName)
    var lastName by lastNameProperty
}

class PersonController : Controller(){
    val selectedLeft = CompositeObservable<ObservableList<Int>> { it.replay(1).autoConnect().apply { subscribe() } }
    val selectedRight = CompositeObservable<ObservableList<Int>>  { it.replay(1).autoConnect().apply { subscribe() } }


    val persons = FXCollections.observableArrayList<Person>()
    val newPersons = FXCollections.observableArrayList<Person>()

    init {

        persons += Person("Dead", "Stark")
        persons += Person("Tyrion", "Lannister")
        persons += Person("Arya", "Stark")
        persons += Person("Daenerys", "Targaryen")

        newPersons += Person("Ned", "Stark")
        newPersons += Person("Tyrion", "Janitor")
        newPersons += Person("Arya", "Stark")
        newPersons += Person("Taenerys", "Dargaryen")
    }
}