Skip to content

Latest commit

 

History

History
533 lines (372 loc) · 23.2 KB

3_Components.md

File metadata and controls

533 lines (372 loc) · 23.2 KB

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

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:

  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 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 object, 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 a 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.