javafxkotlintornadofx

Tornadofx - How to pass parameter to Fragment on every instance


I am a newbie to javafx, kotlin and obviously tornadofx.
Issue:
How to pass parameters to Fragment on every instance?

Lets say I have a table view layout as my fragment. Now this fragment is used at multiple places but with different datasets.

eg. Adding a fragment in:

class SomeView : View() {
... 
root += SomeViewFragment::class
}

class SomeAnotherView : View() {
... 
root += SomeViewFragment::class
}

Declaring Fragment:

class SomeViewFragment : Fragment() {
...
    tableview(someDataSetFromRestApiCall) {
    ...
    }
}

How can I pass different someDataSetFromRestApiCall from SomeView and SomeAnotherView ?


Solution

  • Let's start with the most explicit way to pass data to Fragments. For this TableView example you could expose an observable list inside the Fragment and tie your TableView to this list. Then you can update that list from outside the Fragment and have your changes reflected in the fragment. For the example I created a simple data object with an observable property called SomeItem:

    class SomeItem(name: String) {
        val nameProperty = SimpleStringProperty(name)
        var name by nameProperty
    }
    

    Now we can define the SomeViewFragment with an item property bound to the TableView:

    class SomeViewFragment : Fragment() {
        val items = FXCollections.observableArrayList<SomeItem>()
    
        override val root = tableview(items) {
            column("Name", SomeItem::nameProperty)
        }
    }
    

    If you later update the items content, the changes will be reflected in the table:

    class SomeView : View() {
        override val root = stackpane {
            this += find<SomeViewFragment>().apply {
                items.setAll(SomeItem("Item A"), SomeItem("Item B"))
            }
        }
    }
    

    You can then do the same for SomeOtherView but with other data:

    class SomeOtherView : View() {
        override val root = stackpane {
            this += find<SomeViewFragment>().apply {
                items.setAll(SomeItem("Item B"), SomeItem("Item C"))
            }
        }
    }
    

    While this is easy to understand and very explicit, it creates a pretty strong coupling between your components. You might want to consider using scopes for this instead. We now have two options:

    1. Use injection inside the scope
    2. Let the scope contain the data

    Use injection inside the scope

    We will go with option 1 first, by injecting the data model. We first create a data model that can hold our items list:

    class ItemsModel(val items: ObservableList<SomeItem>) : ViewModel()
    

    Now we inject this ItemsModel into our Fragment and extract the items from that model:

    class SomeViewFragment : Fragment() {
        val model: ItemsModel by inject()
    
        override val root = tableview(model.items) {
            column("Name", SomeItem::nameProperty)
        }
    }
    

    Lastly, we need to define a separate scope for the fragments in each view and prepare the data for that scope:

    class SomeView : View() {
    
        override val root = stackpane {
            // Create the model and fill it with data
            val model= ItemsModel(listOf(SomeItem("Item A"), SomeItem("Item B")).observable())
    
            // Define a new scope and put the model into the scope
            val fragmentScope = Scope()
            setInScope(model, fragmentScope)
    
            // Add the fragment for our created scope
            this += find<SomeViewFragment>(fragmentScope)
        }
    }
    

    Please not that the setInScope function used above will be available in TornadoFX 1.5.9. In the mean time you can use:

    FX.getComponents(fragmentScope).put(ItemsModel::class, model)
    

    Let the scope contain the data

    Another option is to put data directly into the scope. Let's create an ItemsScope instead:

    class ItemsScope(val items: ObservableList<SomeItem>) : Scope()
    

    Now our fragment will expect to get an instance of SomeItemScope so we cast it and extract the data:

    class SomeViewFragment : Fragment() {
        override val scope = super.scope as ItemsScope
    
        override val root = tableview(scope.items) {
            column("Name", SomeItem::nameProperty)
        }
    }
    

    The View needs to do less work now since we don't need the model:

    class SomeView : View() {
    
        override val root = stackpane {
            // Create the scope and fill it with data
            val itemsScope= ItemsScope(listOf(SomeItem("Item A"), SomeItem("Item B")).observable())
    
            // Add the fragment for our created scope
            this += find<SomeViewFragment>(itemsScope)
        }
    }
    

    Passing parameters

    EDIT: As a result of this question, we decided to include support for passing parameters with find and inject. From TornadoFX 1.5.9 you can therefore send the items list as a parameter like this:

    class SomeView : View() {
        override val root = stackpane {
            val params = "items" to listOf(SomeItem("Item A"), SomeItem("Item B")).observable()
            this += find<SomeViewFragment>(params)
        }
    }
    

    The SomeViewFragment can now pick up these parameters and use them directly:

    class SomeViewFragment : Fragment() {
        val items: ObservableList<SomeItem> by param()
    
        override val root = tableview(items) {
            column("Name", SomeItem::nameProperty)
        }
    }
    

    Please not that this involves an unchecked cast inside the Fragment.

    Other options

    You could also pass parameters and data over the EventBus, which will also be in the soon to be released TornadoFX 1.5.9. The EventBus also supports scopes which makes it easy to target your events.

    Further reading

    You can read more about Scopes, EventBus and ViewModel in the guide:

    Scopes

    EventBus

    ViewModel and Validation