Skip to content

Components

Edvin Syse edited this page Sep 25, 2016 · 32 revisions

WikiDocumentationComponents

UI Component overview

The visual parts of a Tornado FX application is comprised of UI Components called View and Fragment. They behave exactly the same with one crucial difference: View is a singleton, so there will be only one instance of any given View in your application, while Fragment behaves like a prototype object, meaning that a new instance will be created every time you look one up.

Note: For all other purposes they are the same, so for brevity we will simply refer to UI Components as views from now on.

View

A View will contain your view controller logic, as well as the actual hierarchy of Java FX nodes that comprises the user interface. You can choose to build your UI with Kotlin or FXML.

UI built with Kotlin

class CounterView : View() {
    override val root = BorderPane()
    val counter = SimpleIntegerProperty()

    init {
        title = "Counter"

        with (root) {
            style {
                padding = box(20.px)
            }

            center {
                vbox(10.0) {
                    alignment = Pos.CENTER

                    label() {
                        bind(counter)
                        style { fontSize = 25.px }
                    }

                    button("Click to increment").setOnAction {
                        increment()
                    }
                }
            }
        }
    }

    fun increment() {
        counter.value += 1
    }
}

A Counter app with type safe inline styles, binding and an action

UI built with FXML

Instead of building your UI in Kotlin directly, you can also pull in the root node from an FXML file. You can access the root node created from FXML directly in init.

class CounterView : View() {
    override val root : BorderPane by fxml()
    val counter = SimpleIntegerProperty()
    val counterInfo: Label by fxid()

    init {
        title = "Counter"
        counterInfo.bind(counter)
    }

    fun increment() {
        counter.value += 1
    }
}

The view is loaded from FXML and the Label is injected with the fxid() delegate.

It is also possible to use the @FXML annotation instead of fxid(), but it is not a best practice. If you want to use it, the syntax would be @FXML lateinit var label: Label. It is more verbose, and you get a var instead of a val, so use the fxid() delegate unless you have a very good reason to avoid it.

The corresponding CounterView.fxml would look like this:

<BorderPane xmlns="http://javafx.com/javafx/null" xmlns:fx="http://javafx.com/fxml/1">
    <padding>
        <Insets top="20" right="20" bottom="20" left="20"/>
    </padding>

    <center>
        <VBox alignment="CENTER" spacing="10">
            <Label fx:id="counterInfo">
                <font>
                    <Font size="20"/>
                </font>
            </Label>
            <Button text="Click to increment" onAction="#increment"/>
        </VBox>
    </center>
</BorderPane>

Alternative view location

You can override the default convention and place the FXML anywhere you like by specifying it's location:

override val root: HBox by fxml("/views/view.fxml")

View title

All views have a title property. The title property of the primary view is automatically bound to the primary stage. The same goes for Fragments - if you open a Fragment in a modal using the openModal() function, the title of the modal is bound to the Fragment title.

Embedding views

A View can also contain other views. You do this by adding the root node of a subview somewhere in the node hierarchy of the master view. The views themselves are not automatically linked, but you easily add a reference property to them if you need to. When you embed views, you can either look them up via the find method, or inject them in the parent view.

class MasterView : View() {
   override val root = BorderPane()
   val detail: DetailView by inject()

   init {
      // Enable communication between the views
      detail.master = this

      // Assign the DetailView root node to the center property of the BorderPane
      root.center = detail.root

      // Find the HeaderView and assign it to the BorderPane top (alternative approach)
      root.top = find(HeaderView::class)
   }
}

A Master view with two embedded views. The DetailView has access to the MasterView via the master property.

It is important to note that the master property of DetailView is not a framework feature - it is simply a property you might add to enable communication between views. You can alternatively communicate with events if you don't like the hard coupling between views.

When you add a view as a child node of another Pane, you can use this shorthand syntax to extract the root node:

override val root = HBox()
val subview: MySubView by inject()
init {
    root += subview
}

A subview added using the shorthand syntax to avoid refering to the actual root node inside the view

Note that the += syntax can be used to add both views and arbitrary nodes to any Pane that can contain child nodes. It is actually just an extension function that basically just does pane.children.add(node) for you.

When to use View and when to use Fragment

When a user interface will only be used in one place at a time, a View is the better choice. For popups or other short lived objects, you might consider Fragments instead. A complex view might contain both other Views and Fragments. The Fragment class also has a convenient openModal and a corresponding closeModal function that will open the fragment in a modal window. The openModal function takes optional parameters to configure stageStyle and modality plus other options.

Live reloading

To have the Views reload automatically every time the Stage gains focus, start the app with the program parameter --live-views or call reloadViewsOnFocus() in the init block of your App class. See built in startup parameters for more information.

You can preserve state across reloads by returning a state object of your choosing in the pack method of your View, and recieve this state object in the reloaded View in the unpack method of your View. Example:

class LoginScreen : View() {
    override val root: Parent by fxml()
    val person = Person()
    val username: TextField by fxid()
    val password: TextField by fxid()
​
    init {
        title = "Login"
        username.bind(person.usernameProperty())
        password.bind(person.passwordProperty())
    }
​
    override fun pack() = person

    override fun unpack(state: Any?) {
        state as Person
        person.username = state.username
        person.password = state.password
    }
}

A View that knows how to transfer state to the new instance when it is reloaded

Entering fullscreen

To enter fullscreen you need to get a hold of the current stage and call stage.isFullScreen = true. The primary stage is the active stage unless you opened a modal window via view.openModal() or manually created a stage. The primary stage is available in the variable FX.primaryStage. To open the application in fullscreen on startup you should override start in your app class:

class MyApp : App(MyView::class) {
    override fun start(stage: Stage) {
        super.start(stage)
        stage.isFullScreen = true
    }
}

In the following example we toggle fullscreen mode in a modal window via a button:

button("Toggle fullscreen") {
    setOnAction {
        with (modalStage) { isFullScreen = !isFullScreen }
    }
}

Controllers

Business logic is contained in a Controller. All controllers are singletons, and can be injected into both other controllers and views.

Note: From now on, components refers to any "Controller, View or Fragment". They all extend the Component base class

Controllers might perform long running tasks, and should not run on the Java FX UI thread. Calling code on the right thread can be tedius and error prone, but Tornado FX does all the heavy lifting, leaving you to focus on your business and view logic.

The examples below will use the included Rest controller. Please see the [Rest Client](Rest Client Documentation) for further details.

The framework adds no restrictions or assumptions as to how you use your controllers. They are simply singleton objects that you can access from other controllers and views. However, some patterns have proven extremely useful, so we'll present them here.

class CustomerController : Controller() {
    val api : Rest by inject()

    fun listCustomers(): ObservableList<Customer> = 
        api.get("customers").list().toModel() 
}

Controller that can load a JSON list of customers and convert them to a Customer model object

To access this controller from a view, you can inject it or look it up with the find function. The listCustomers function might take a long time to perform, and should not run on the JavaFX UI Thread. You need to run the call itself in a background thread, and update the UI on the JavaFX UI Thread when the call completes. This can easily be achived with the background helper function:

background {
    customerController.listCustomers()
} ui {
    customerTable.items = it
}

See Async Task Execution for more information.

Next: FXML