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.
import tornadofx.*
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.
import tornadofx.*
class MyView: View() {
override val root = vbox {
}
}
However, to actually show something on screen we want to populate this VBox
acting as the root
control. Using the initializer block, let's add a JavaFX Button
and a Label
.
Also take note of the tornadofx.*
import. This is important and should be present in all your TornadoFX related files. The reason this is important is that some of the functionality of the
framework isn't discoverable by the IDE without the import. This import enabled some advanced extension functions that you really don't want to live without :)
import tornadofx.*
class MyView: View() {
override val root = vbox {
button("Press me")
label("Waiting")
}
}
TornadoFX provides a builder syntax that will streamline your UI code. Instead of creating UI elements and manually adding them to the parent element's children list, the builders allow us to express the UI as a hierarchical structure which enables you to visualize the resulting UI very easily. Note that all builders are written in lowercase, so as to not confuse them with manual instantiation of the UI element classes.
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>) {
launch<MyApp>(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 Run→Edit 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 Run→Run '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. A View
contains a hierarchy of JavaFX Nodes, starting from the root.
In the next section we will learn how to leverage the 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 View
s, Fragment
s and Controller
s 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.
Embedding Views
You can also inject one or more Views into another View
. Below we embed a TopView
and BottomView
into a MasterView
. We assign these sub views to sections inside the BorderPane
(Figure 3.6).
import javafx.scene.control.Label
import javafx.scene.layout.BorderPane
import tornadofx.*
class MasterView: View() {
override val root = borderpane {
top<TopView>()
bottom<BottomView>()
}
}
class TopView: View() {
override val root = label("Top View")
}
class BottomView: View() {
override val root = label("Bottom View")
}
Injection Using find() or inject()
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. In the following example
we retrieve an instance of the TopView
and BottomView
using inject()
and find()
respectively, and assign the root element of those sub views to the sections inside the BorderPane
.
This is normally not necessary but might help you understand how the above short hand works under the covers.
import javafx.scene.control.Label
import javafx.scene.layout.BorderPane
import tornadofx.*
class MasterView : View() {
// Explicitly retrieve TopView
val topView = find(TopView::class)
// Create a lazy reference to BottomView
val bottomView: BottomView by inject()
override val root = borderpane {
top = topView.root
bottom = bottomView.root
}
}
You can use either find()
or inject()
, but using inject()
delegates is the most idiomatic means to perform dependency injection and has the advantage of lazy loading.
Controllers
In many cases, it is considered a good practice to separate a UI into three distinct parts:
- Model - The business code layer that holds core logic and data
- View - The visual display with various input and output controls
- 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 bound to an observable string property and later 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()
val input = SimpleStringProperty()
override val root = form {
fieldset {
field("Input") {
textfield(input)
}
button("Commit") {
action {
controller.writeToDb(input.value)
input.value = ""
}
}
}
}
}
class MyController: Controller() {
fun writeToDb(inputValue: String) {
println("Writing $inputValue to database!")
}
}
Notice how the input
property is automatically bound to the textfield just by referencing it in the textfield
builder. As an alternative, you could have created a
reference to the textfield and retrieved its text
property, but that approach would create more complex UI code without much benefit. In rare cases you might
need to reference individual UI elements.
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 a 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>().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 close() . 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>()
}
}
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. |
closeButton | Boolean | Whether there is the close button in window. Default: true |
movable | Boolean | Whether the window is movable. Default: true |
overlayPaint | Paint | The paint for overlay part of internal window over window. Default: c("#000", 0.4) |
Closing Modal Windows
Any Component
opened using openModal()
, openWindow()
or openInternalWindow()
can be closed by calling close()
. 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 MyView2: View() {
override val root = vbox {
button("Go to MyView1") {
action {
replaceWith<MyView1>()
}
}
}
}
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, ViewTransition.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>()
}
}
}
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>()
}
}
}
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
andjsonArray
functions are also available onInputStream
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.