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 simple dependency injections and other means.

You also have the option to utilize FXML which will be discussed much later. 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.

But first, 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 Node 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 to any Pane type, including this VBox.

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

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

While it is pretty clear what's going on from looking at this code, TornadoFX provides a builder syntax that will streamline your UI code further and make it much easier to reason about the resulting UI just by looking at the code. We will gradually move into builder syntax, and finally cover builders in full in the next chapter.

While we introduce you to new concepts you might some times see code that is not using best practices. We do this to introduce you to the concepts and to give you a broader understanding of what's going on under the covers. Gradually we will introduce more powerful constructs to solve the problem at hand in a better way.

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 don't 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 package level 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 used. 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 {
        with(root) {
            this += Button("Press Me")
            this += 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 a separate namespace if you will, resulting in a View only being a singleton inside that scope. This is great for Multiple-Document Interface applications and other advanced use cases. More on this later.

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).

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

    override val root = borderpane {
        top = topView.root
        bottom = bottomView.root
    }
}

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

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

Figure 3.6

If you need Views to communicate to each other, you can create a property in each of the "child" Views that holds the "parent" View.

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

    val topView: TopView by inject()
    val bottomView: BottomView by inject()

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

        topView.parent = this
        bottomView.parent = this
    }
}

class TopView: View() {
    override val root = Label("Top View")
    lateinit var parent: MasterView
}

class BottomView: View() {
    override val root = Label("Bottom View")
    lateinit var parent: MasterView
}

More typically you would use a Controller or a ViewModel to communicate between views, and we will visit this topic later.

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.

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 preferred means to perform dependency injection.

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:

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

In stead 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 automatically. We hope you'll agree that this is just as expressive, with a lot less boiler plate. This is one of the most important principles TornadoFX tries to live by: Reduce boiler plate, 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).

class MyView : View() {
    val controller: MyController by inject()
    var inputField: TextField by singleAssign()

    override val root = vbox {
        label("Input")
        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 references from the onClick event handler of the "Commit" button later. 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 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.

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 perception, so make sure that you 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 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 (which delegates the ActionEvent to setAction method) 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 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.

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

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.

singleAssign() Property Delegate

In the example above we initialized the inputField property with the singleAssign delegate. If you want to guarantee that a value is only assigned once, you can use the singleAssign() delegate instead of the lateinit keyword from Kotlin. This will cause a second assignment to throw an error, and it will also error when it is prematurely accessed before it is assigned.

You can look up more about singleAssign() in detail in Appendix A1, but know for now it guarantees a var can only be assigned once. It is also threadsafe and helps mitigate issues with mutability.

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

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. You will learn how to easily offload work to a worker thread using the runAsync construct later in this chapter.

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 it's prior 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 that can have multiple instances. They are particularly useful for popups or as pieces of a larger UI, or even just a single ListCell. We will look at a specialized fragment called ListCellFragment later.

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

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 pass optional arguments to openModal() as well 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's 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).


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.

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

This works by replacing the root Node on 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.

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 consult the params map:

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

If you don't 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.

Nullable params

Due to a quirk in the Kotlin type system, passing nullable parameters is done via the nullableParam delegate instead of simply reusing the param delegate:

val customer: Customer by nullableParam()

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 for example. Any View or Fragment that were opened via openModal will also have a modalStage property available.

Accessing the scene

Some times 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, there is an even shorter way, just use scene.

Accessing resources

Lot's of JavaFX APIs takes resources as an 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())

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's 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 /.

Shortcuts and key combinations for actions

You can fire actions when certain key combinations are typed. This is done with the shortcut function:

shortcut(KeyCombination.valueOf("Ctrl+Y")) {
    doSomething()
}

There is also a string version of the shortcut function that does the same but is less verbose:

shortcut("Ctrl+Y")) {
    doSomething()
}

You can also add shortcuts to button actions directly:

button("Save") {
    action { doSave() }
    shortcut("Ctrl+S")
}

Touch Support

JavaFX supports touch out of the box, and for now the only place we needed to improve it was to handle shortpress and longpress in a more convenient way. It consists of two functions similar to action, which can be configured on any Node:

shortpress { println("Activated on short press") }
longpress { println("Activated on long press") }

Both functions accepts a consume parameter which by default is false. Setting it to true will prevent event bubbling for the press event. The longpress function additionally supports a threshold parameter which is used to determine when a longpress has accured. It is 700.millis by default.

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 ""