Components

JavaFX uses a theatrical analogy to organize an Application with Stage and Scene components.

TornadoFX builds on this by providing View, Controller, and Fragment components. While the Stage, and Scene are used by TornadoFX, the View, Controller, and Fragment introduces new concepts that streamline development. Many of these components are automatically maintained as singletons, and can communicate to each other through TornadoFX's simple dependency injections or direct instantiation.

You also have the option to utilize FXML which will be discussed in Chapter 10. But first, lets extend App to create an entry point that launches a TornadoFX application.

App and View Basics

To create a TornadoFX application, you must have at least one class that extends App. An App is the entry point to the application and specifies the initial View. It does in fact extend JavaFX Application, but you do not necessarily need to specify a start() or main() method.

Extend App to create your own implementation and specify the primary view as the first constructor argument.

class MyApp: App(MyView::class)

A View contains display logic as well as a layout of Nodes, similar to the JavaFX Stage. It is automatically managed as a singleton. When you declare a View you must specify a root property which can be any Parent type, and that will hold the View's content.

In the same Kotlin file or in a new file, extend a class off of View. Override the abstract root property and assign it VBox or any other Node you choose.

class MyView: View() {
    override val root = VBox()
}

However, we might want to populate this VBox acting as the root control. Using the initializer block, let's add a JavaFX Button and a Label. You can use the "plus assign" += operators to add children, such as a Button and Label.

class MyView: View() {
    override val root = VBox()

    init {
        root += Button("Press Me")
        root += Label("Waiting")
    }
}

While it is pretty clear what is going on just looking at this code, TornadoFX provides a builder syntax that will streamline your UI code further. We will gradually move into builder syntax in the next chapter.

Next we will see how to run this application.

Starting a TornadoFX Application

Newer versions of the JVM know how to start JavaFX applications without a main() method. A JavaFX application, and by extension a TornadoFX application, is any class that extends javafx.application.Application. Since tornadofx.App extends javafx.application.Application, TornadoFX apps are no different. Therefore you would start the app by referencing com.example.app.MyApp, and you do not necessarily need a main() function (unless you need to supply command line arguments). In that case you would add a package level main function to the MyApp.kt file:

fun main(args: Array<String>) {
  Application.launch(MyApp::class.java, *args)
}

This main function would be compiled to com.example.app.MyAppKt. Notice the Kt at the end. When you create a packagelevel main function, it will always have a class name of the fully qualified package, plus the file name, appended with Kt.

For launching and testing the App, we will use Intellij IDEA. Navigate to RunEdit Configurations (Figure 3.1).

Figure 3.1

Click the green "+" sign and create a new Application configuration (Figure 3.2).

Figure 3.2

Specify the name of your "Main class" which should be your App class. You will also need to specify the module it resides in. Give the configuration a meaningful name such as "Launcher". After that click "OK" (Figure 3.3).

Figure 3.3

You can run your TornadoFX application by selecting RunRun 'Launcher' or whatever you named the configuration (Figure 3.4).

Figure 3.4

You should now see your application launch (Figure 3.5)

Figure 3.5

Congratulations! You have written your first (albeit simple) TornadoFX application. It may not look like much right now, but as we cover more of TornadoFX's powerful features we will be creating large, impressive user interfaces with little code in no time. But first let's understand a little better what is happening between App and View.

Understanding Views

Let's dive a little deeper into how a View works and how it can be invoked. Take a look at the App and View classes we just built.

class MyApp: App(MyView::class)

class MyView: View() {
    override val root = VBox()

    init {
        root += Button("Press Me")
        root += Label("Waiting")
    }
}

A View contains a hierarchy of JavaFX Nodes and is injected by name wherever it is called. In the next section we will learn how to leverage powerful builders to create these Node hierarchies quickly. There is only one instance of MyView maintained by TornadoFX, effectively making it a singleton. TornadoFX also supports scopes, which can group together a collection of Views, Fragments and Controllers in separate instances, resulting in a View only being a singleton inside that scope. This is great for Multiple-Document Interface applications and other advanced use cases.

Using inject() and Embedding Views

You can also inject one or more Views into another View. Below we embed a TopView and BottomView into a MasterView. Note we use the inject() delegate property to lazily inject the TopView and BottomView instances. Then we call each "child" View's root to assign them to the BorderPane (Figure 3.6).

import javafx.scene.control.Label
import javafx.scene.layout.BorderPane
import tornadofx.*


class MasterView: View() {
    val topView: TopView by inject()
    val bottomView: BottomView by inject()

    override val root = BorderPane()

    init {
        with(root) {
            top = topView.root
            bottom = bottomView.root
        }
    }
}

class TopView: View() {
    override val root = Label("Top View")
}

class BottomView: View() {
    override val root = Label("Bottom View")
}

Injection Using find()

The inject() delegate will lazily assign a given component to a property. The first time that component is called is when it will be retrieved. Alternatively, instead of using the inject() delegate you can use the find() function to retrieve a singleton instance of a View or other components.

import javafx.scene.control.Label
import javafx.scene.layout.BorderPane
import tornadofx.*


class MasterView : View() {
    override val root = BorderPane()

    val topView = find(TopView::class)
    val bottomView = find(BottomView::class)

    init {
        with(root) {
            top = topView.root
            bottom = bottomView.root
        }
    }
}

class TopView: View() {
    override val root = Label("Top View")
}

class BottomView: View() {
    override val root = Label("Bottom View")
}

You can use either find() or inject(), but using inject() delegates is the most idiomatic means to perform dependency injection.

A Brief Intro to Builders

While we will cover builders more in depth in the next chapter, it is time to reveal that the above example can be written in a much more concise and expressive syntax:

import javafx.scene.control.Label
import tornadofx.*


class MasterView : View() {
    override val root = borderpane {
        top(TopView::class)
        bottom(BottomView::class)
    }
}

class TopView: View() {
    override val root = Label("Top View")
}

class BottomView: View() {
    override val root = Label("Bottom View")
}

Instead of injecting the TopView and BottomView and then assigning their respective root nodes to the BorderPanes top and bottom property, we specify the BorderPane with the builder syntax (all lower case) and then declaratively tell TornadoFX to pull in the two subviews and assign them to the top and bottom properties. Hopefully you find this is much more expressive, with a lot less boiler plate. This is one of the most important principles TornadoFX tries to live by: reduce boiler plate and increase readability. The end result is often less code and less bugs.

Controllers

In many cases, it is considered a good practice to separate a UI into three distinct parts:

  1. Model - The business code layer that holds core logic and data
  2. View - The visual display with various input and output controls
  3. Controller - The "middleman" mediating events between the Model and the View

There are other flavors of MVC like MVVM and MVP, all of which can be leveraged in TornadoFX.

While you could put all logic from the Model and Controller right into the view, it is often cleaner to separate these three pieces distinctly to maximize reusability. One commonly used pattern to accomplish this is the MVC pattern. In TornadoFX, a Controller can be injected to support a View.

Here is a simple example. Create a simple View with a TextField whose value is written to a "database" when a Button is clicked. We can inject a Controller that handles interacting with the model that writes to the database. Since this example is simplified, there will be no database but a printed message will serve as a placeholder (Figure 3.7).

import tornadofx.*


class MyView : View() {

    val controller: MyController by inject()

    override val root = vbox {

        label("Input")

        val inputField = textfield()

        button("Commit") {
            action {
                controller.writeToDb(inputField.text)
                inputField.clear()
            }
        }
    }
}


class MyController: Controller() {
    fun writeToDb(inputValue: String) {
        println("Writing $inputValue to database!")
    }
}

Figure 3.7

When we build the UI, we make sure to add a reference to the inputField so that it can be referenced from the onClick event handler of the "Commit" button later, hence why we save it to a variable. When the "Commit" button is clicked, you will see the Controller prints a line to the console.

Writing Alpha to database!

It is important to note that while the above code works, and may even look pretty good, it is a good practice to avoid referencing other UI elements directly. Your code will be much easier to refactor if you bind your UI elements to properties and manipulate the properties instead. We will introduce the ViewModel later, which provides even easier ways to deal with this type of interaction.

You can also use Controllers to provide data to a View (Figure 3.8).

import javafx.collections.FXCollections
import tornadofx.*


class MyView : View() {
    val controller: MyController by inject()

    override val root = vbox {
        label("My items")
        listview(controller.values)
    }
}

class MyController: Controller() {
    val values = FXCollections.observableArrayList("Alpha","Beta","Gamma","Delta")
}

Figure 3.8

The VBox contains a Label and a ListView, and the items property of the ListView is assigned to the values property of our Controller.

Whether they are reading or writing data, Controllers can have long-running tasks and should not perform work on the JavaFX thread. We will learn how to easily offload work to a worker thread using the runAsync construct next.

Long Running Tasks

Whenever you call a function in a controller, you need to determine if that function returns immediately or if it performs potentially long-running tasks. If you call a function on the JavaFX Application Thread, the UI will be unresponsive until the call completes. Unresponsive UI's is a killer for user acceptance, so we need to make sure to run expensive operations in the background. TornadoFX provides the runAsync function to help with this.

Code placed inside a runAsync block will run in the background. If the result of the background call should update your UI, you must make sure that you apply the changes on the JavaFX Application Thread. The ui block, which typically follows, does exactly that.

val textfield = textfield()
button("Update text") {
    action {
        runAsync {
            myController.loadText()
        } ui { loadedText ->
            textfield.text = loadedText
        }
    }
}

When the button is clicked, the action inside the action builder is run. It makes a call out to myController.loadText()and applies the result to the text property of the textfield when it returns. The UI stays responsive while the controller function runs.

Under the covers, runAsync creates a JavaFX Task objects, and spins off a separate thread to run your call inside the Task. You can even assign this Task to a variable and bind it to a UI to show progress while your operation is running.

In fact, this is so common that there is also an default ViewModel called TaskStatus which contains observable values for running, message, title, and progress. You can supply the runAsync call with a specific instance of the TaskStatus object, or use the default. There is also a version of runAsync called runAsyncWithProgress which will cover the current Node with a progress indicator while the long running operation runs.

The TornadoFX sources includes an example usage of this in the AsyncProgressApp.kt file.

If you need to handle a great deal of complex concurrency, you may consider using RxKotlin with RxKotlinFX. Rx also has the ability to handle rapid user inputs and events, and kill previous requests to only chase after the latest.

Fragment

Any View you create is a singleton, which means you typically use it in only one place at a time. The reason for this is that the root node of the View can only have a single parent in a JavaFX application. If you assign it another parent, it will disappear from its previous parent.

However, if you would like to create a piece of UI that is short-lived or can be used in multiple places, consider using a Fragment. A Fragment is a special type of View designed to have multiple instances. They are particularly useful for popups or as pieces of a larger UI (such as ListCells, which we look at via the ListCellFragment later).

Both View and Fragment support openModal(), openWindow(), and openInternalWindow() functions that will open the root node in a separate Window.

import javafx.stage.StageStyle
import tornadofx.*


class MyView : View() {
    override val root = vbox {
        button("Press Me") {
            action {
                find(MyFragment::class).openModal(stageStyle = StageStyle.UTILITY)
            }
        }
    }
}

class MyFragment: Fragment() {
    override val root = label("This is a popup")
}

You can also pass optional arguments to openModal() to modify a few of its behaviors

Optional Arguments for openModal()

Argument Type Description
stageStyle StageStyle Defines one of the possible enum styles for Stage. Default: StageStyle.DECORATED
modality Modality Defines one of the possible enum modality types for Stage. Default: Modality.APPLICATION_MODAL
escapeClosesWindow Boolean Sets the ESC key to call closeModal(). Default: true
owner Window Specify the owner Window for this Stage`
block Boolean Block UI execution until the Window closes. Default: false

InternalWindow

While openModal opens in a new Stage, openInternalWindow opens over the current root node, or any other node if you specify it:

    button("Open editor") {
        action {
            openInternalWindow(Editor::class)
        }
    }

Figure 3.9

A good use case for the internal window is for single stage environments like JPro, or if you want to customize the window trim to make the window appear more in line with the design of your application. The Internal Window can be styled with CSS. Take a look at the InternalWindow.Styles class for more information about styleable properties.

The internal window API differs from modal/window in one important aspect. Since the window opens over an existing node, you typically call openInternalWindow() from within the View you want it to open on top of. You supply the View you want to show, and you can optionally supply what node to open over via the owner parameter.

Optional Arguments for openInternalWindow()

Argument Type Description
view UIComponent The component will be the content of the new window
view KClass Alternatively, you can supply the class of the view instead of an instance
icon Node Optional window icon
scope Scope If you specify the view class, you can also specify the scope used to fetch the view
modal Boolean Defines if the covering node should be disabled while the internal window is active. Default: true
escapeClosesWindow Boolean Sets the ESC key to call close(). Default: true
owner Node Specify the owner Node for this window. The window will by default cover the root node of this view.`

Closing Modal Windows

Any Component opened using openModal(), openWindow() or openInternalWindow() can be closed by calling closeModal(). It is also possible to get to the InternalWindow instance directly if needed using findParentOfType(InternalWindow::class).

Replacing Views and Docking Events

With TornadoFX, is easy to swap your current View with another View using replaceWith(), and optionally add a transition. In the example below, a Button on each View will switch to the other view, which can be MyView1 or MyView2 (Figure 3.10).

import tornadofx.*


class MyView1: View() {
    override val root = vbox {
        button("Go to MyView2") {
            action {
                replaceWith(MyView2::class)
            }
        }
    }
}

class MyView2: View() {
    override val root = vbox {
        button("Go to MyView1") {
            action {
                replaceWith(MyView1::class)
            }
        }
    }
}

Figure 3.10

You also have the option to specify a spiffy animation for the transition between the two Views, as shown below.

replaceWith(MyView1::class, ViewTransition.Slide(0.3.seconds, Direction.LEFT))

This works by replacing the root Node on a given View with another View's root. There are two functions you can override on View to leverage when a View's root Node is connected to a parent (onDock()), and when it is disconnected (onUndock()). You can leverage these two events to connect and "clean up" whenever a View comes in or falls out. You will notice running the code below that whenever a View is swapped, it will undock that previous View and dock the new one. You can leverage these two events to manage initialization and disposal tasks.

import tornadofx.*


class MyView1: View() {
    override val root = vbox {
        button("Go to MyView2") {
            action {
                replaceWith(MyView2::class)
            }
        }
    }

    override fun onDock() {
        println("Docking MyView1!")
    }

    override fun onUndock() {
        println("Undocking MyView1!")
    }
}

class MyView2: View() {
    override val root = vbox {
        button("Go to MyView1") {
            action {
                replaceWith(MyView1::class)
            }
        }
    }

    override fun onDock() {
        println("Docking MyView2!")
    }
    override fun onUndock() {
        println("Undocking MyView2!")
    }
}

Passing Parameters to Views

The best way to pass information between views is often an injected ViewModel. Even so, it can still be convenient to be able to pass parameters to other components. The find and inject functions supports varargs of Pair<String, Any> which can be used for just this purpose. Consider a customer list that opens a customer editor for the selected customer. The action to edit a customer might look like this:

fun editCustomer(customer: Customer) {
    find<CustomerEditor>(mapOf(CustomerEditor::customer to customer)).openWindow()
}

The parameters are passed as a map, where the key is the property in the view and the value is whatever you want the property to be. This gives you a type safe way of configuring parameters for the target View.

Here we use the Kotlin to syntax to create the parameter. This could also have been written as Pair(CustomerEditor::customer, customer) if you prefer. The editor can now access the parameter like this:

class CustomerEditor : Fragment() {
    val customer: Customer by param()
}

If you want to inspect the parameters instead of blindly relying on them to be available, you can either declare them as nullable or consult the params map:

class CustomerEditor : Fragment() {
    init {
        val customer = params["customer"] as? Customer
        if (customer != null) {
            ...
        }
    }
}

If you do not care about type safety you can also pass parameters as mapOf("customer" to customer), but then you miss out on automatic refactoring if you rename a property in the target view.

Accessing the Primary Stage

View has a property called primaryStage that allows you to manipulate properties of the Stage backing it, such as window size. Any View or Fragment that were opened via openModal will also have a modalStage property available.

Accessing the Scene

Sometimes it is necessary to get a hold of the current scene from within a View or Fragment. This can be achieved with root.scene, or if you are within a type safe builder, just call scene.

Accessing Resources

Lots of JavaFX APIs takes resources as a URL or the toExternalForm of an URL. To retrieve a resource url one would typically write something like:

val myAudioClip = AudioClip(MyView::class.java.getResource("mysound.wav").toExternalForm())

However, every Component has a resources object which can retrieve the external form url of a resource like this:

val myAudiClip = AudioClip(resources["mysound.wav"])

If you need an actual URL, it can be retrieved like this:

val myResourceURL = resources.url("mysound.wav")

The resources helper also has several other helpful functions to help you turn files relative to the Component into an object of the type you need:

val myJsonObject = resources.json("myobject.json")
val myJsonArray = resources.jsonArray("myarray.json")
val myStream = resources.stream("somefile")

It is worth mentioning that the json and jsonArray functions are also available on InputStream objects.

Resources are relative to the Component but you can also retrieve a resource by it's full path, starting with a /.

Summary

TornadoFX is filled with simple, streamlined, and powerful injection tools to manage Views and Controllers. It also streamlines dialogs and other small UI pieces using Fragment. While the applications we built so far are pretty simple, hopefully you appreciate the simplified concepts TornadoFX introduces to JavaFX. In the next chapter we will cover what is arguably the most powerful feature of TornadoFX: Type-Safe Builders.

results matching ""

    No results matching ""