UI development

Super-productive UI development with Kotlin

Posted on by Gyula Voros

Context

What I find the most exciting trait of Kotlin is its philosophy of being a unified toolkit (language + set of libraries + tooling), but also relying on the native environment, everywhere it runs. Using Kotlin with the JVM means full interop with Java, in the browser we can access JavaScript libraries and Kotlin/Native has tooling to interop with code written in C. What this means, is that we don't have to reinvent the wheel or spend ages rewriting existing code from scratch, rather we can continue building things on top of what we already have and improve our efficiency at the same time.

When it comes to UI development, Kotlin suggests the same principle, e.g. the 2017 KotlinConf iOS app is written Kotlin, but screens and widgets consist of platform native elements like UIViewControllers and UIViews.

The feasible use-cases of Kotlin are growing, so the question arises: how should you implement UI on each platform? On mobile it's easy, both Android and iOS have a rich ecosystem of design guidelines and widget systems to implement them. Today we are going to take a look at another platform that we are interacting with quite often: desktop.

As we are transitioning to a "web first" and "mobile first" world, many desktop applications basically got implemented as a webpage, pre-packaged with a browser engine (which approach has its own issues). But there are other options as well, like JavaFX and in case of using it with Kotlin, an extremely powerful library called TornadoFX. In this article, I'll give you a whirlwind 🌪 tour of what you can build with TornadoFX and Kotlin (pun intended).

Hello World

Getting started with TornadoFX is very simple. All you have to do is defining a dependency on no.tornado:tornadofx:1.7.15 and you are good to go. For now, TornadoFX only supports Java8, so you should explicitly tell the Kotlin compiler to target 1.8. Using Gradle it would look like this:

compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

From here, "Hello World" is just 4 lines of code away:

class MainView : View() {
    override val root = group { label("Hello World") }
}

class TornadoApp : App(MainView::class)

hello-world

Basics

Coming from e.g. an Android background, you'll feel like home quite easily. There are Views, there is an Application, and if you have ever used the Anko Layout DSL, you'll find a resemblance to how we defined the root variable.

Stage

By extending class TornadoApp : App(MainView::class), TornadoFX takes care of a lot of things for us, but it is also providing ways to customize our application. In JavaFX, the top level UI element is called the stage. We can access its properties by overriding the start method in our App.

class TornadoApp : App(MainView::class) {
    override fun start(stage: Stage) {
        stage.minHeight = 200.0
        stage.minWidth = 400.0
        super.start(stage)
    }
}

Here I enlarged the window of our app a little bit.

Scene

Every stage contains a scene, which is basically a graph of UI widgets (called Nodes in JavaFX). The scene takes care of placing and rendering your widgets on the screen. In TornadoFX, you can get access to the default scene by overriding the createPrimaryScene method.

override fun createPrimaryScene(view: UIComponent) = super.createPrimaryScene(view).apply {
    fill = Color.valueOf("#EDEDED")
}

Now we can enjoy our Hello World example with a nice gray background.

hello-world-large

Controls

The list of built-in JavaFX controls is extensive, so I'm not going to cover all of them. But you will find all kinds of basic controls like labels, text fields, buttons, progress bars, etc. and also some more complex ones like list views or table views. To give you a taste how they look like, here is a screenshot of some of them:

controls

Layouts

One of the grunt works of UI development is placing the controls of our application on screen in various layouts, pixel perfect. JavaFX comes with a handful of layout widgets out of the box, and TornadoFX has its own additions. You can place controls vertically/horizontally with VBox/HBox, put them in a grid with GridPane, create multiple tabs with TabPane, to name a bunch of them.

What TornadoFX brings to the table is its Kotlin DSL for building our UI. It is basically syntactic sugar for making UI programming more ergonomic. We have already seen this DSL in action in the Hello World example when we defined the root variable. The basic concept is to wrap definition of UI controls inside a layout DSL, so the end result will be concise and very easy to read.

Here is a slightly more complex example of using the DSL, a login form:

form {
    fieldset(labelPosition = Orientation.VERTICAL) {
        field("Username") { textfield() }
        field("Password") { passwordfield() }
        button("Log in") {
            action { println("Handle button press") }
        }
    }
}

login-form

This concept scales really well, especially when you are combining it with one more abstraction provided by TornadoFX: View or Fragment (they are basically the same abstraction, it's just Views are singletons and Fragments may have multiple instances). The idea of Views is that you organize larger parts of your UI into self-contained elements and then combine these Views into even larger UI elements (just like playing with Lego 🙂). The code above could be encapsulated into e.g. a class LoginView : View().

Styling

The default look and feel of the controls is a good baseline on each OS (macOS, Linux, Windows), but it's reasonable to expect that you want to apply your own brand identity to your application. In JavaFX there are two fundamental ways of achieving that:

  • modifying properties in code
  • applying CSS stylesheets (wow, didn't see that coming, did you 😅)

Let's see some examples. This is how would you make the labels bold in the login form, by changing its font property.

field("Username") {
    label.font = loadFont("/fonts/SF-Pro-Display-Bold.otf", 16.0)
    textfield()
}

Or you could tint the button by applying some CSS rule:

button("Log in") {
    style = "-fx-base: #57b757;"
    action { println("Handle button press") }
}

Of course, you don't have to inline the CSS rules inside your layout code, rather you can write them in type-safe Kotlin and apply them by calling addClass() on your controls.

login-form-green

Some more advanced concepts

So far so good, now we have the basic building blocks to make user interfaces for desktop applications. When we are working on real-world applications though, there are a couple of questions that we usually have to answer:

  • what design pattern should I use? MVC, MVP, MVVM, Redux or something else?
  • should I use dependency injection?
  • how should my program interact with the outside world over HTTP?
  • etc.

Well, TornadoFX comes with "batteries included" (opinionated ways of implementing things) but doesn't force you to use any of them. Later on, I'll show you some of these features.

Data binding

Data binding is a powerful pattern in UI development, that can save you many keystrokes and help to prevent bugs. With the rise of reactive applications, where data is always in motion, it is getting more important to be able to efficiently keep track of these changes and update our UI accordingly. In JavaFX data binding and "observable properties" are baked inside the framework, they are all over the place. If a Label has a text field, it also has a textProperty() method, that we can use for data binding. In TornadoFX we can define these pairs by property delegation.

Let's continue our user login example and define a Controller capable of logging in the user. First, we want to define a status property to be able to show the progress in our UI.

class LoginController : Controller() {
    val statusProperty = SimpleStringProperty("")
    var status by statusProperty

    fun login(username: String, password: String) {
        runLater { status = "Logging in..." }
        val result: Boolean = TODO()
        runLater { status = if (result) "Login succeed" else "Login failed" }
    }
}

Then we can bind a label in our login screen to this property: label(controller.statusProperty).

Dependency injection

With Kotlin on the JVM you have many options to implement DI. I think Dagger is one of the most popular options, but you can also use more Kotlin-native libraries like Koin or Kodein. These are all valid options when you use TornadoFX, but for simple use-cases, you might want to rely on its built-in solution, that doesn't need any setup and you can start using it right away.

To be able to use our controller, all we have to do is to inject it into our login view.

class LoginView : View() {

    val model = ViewModel()
    val username = model.bind { SimpleStringProperty() }
    val password = model.bind { SimpleStringProperty() }
    
    val controller: LoginController by inject()

    override val root = group {
        form {
            fieldset(labelPosition = Orientation.VERTICAL) {
                field("Username") { textfield(username) }
                field("Password") { passwordfield(password) }
                button("Log in") {
                    action { runAsync { controller.login(username.value, password.value) } }
                }
            }
        }
    }
}

We can inject all kinds of things, not just Controllers, for example we can inject an instance of Rest to implement networking.

Networking

Networking libraries are coming in different flavors, some might prefer OkHttp, others may incorporate additional abstractions like Retrofit. Again, you can use any of your favorite networking libraries, but TornadoFX provides a simple built-in solution.

If we want to use our form to log into GitHub, we can modify our controller like this:

class LoginController : Controller() {
    val statusProperty = SimpleStringProperty("")
    var status by statusProperty

    val api: Rest by inject()

    init {
        api.baseURI = "https://api.github.com/"
    }

    fun login(username: String, password: String) {
        runLater { status = "Logging in..." }
        api.setBasicAuth(username, password)
        val response = api.get("user")
        val result = response.ok()
        runLater { status = if (result) "Login succeed" else "Login failed" }
    }
}

Debug

When working on either mobile or web apps, there are some really powerful UI debuggers like "Inspect Element" in browsers or View Hierarchy Debugger on iOS. These tools are essential when you are trying to figure out where your widget disappeared, or why it doesn't occupy the space that it should be. In the world of JavaFX, this tool comes as a third-party software called Scenic View. It automatically detects any running JavaFX applications and provides a tree-like viewer for inspecting your UI elements.

Our super-simple login screen would look like this in Scenic View:

scenic-view

When you select any widget in Scenic View, it will also get highlighted in your running application. You can inspect and also edit some of its properties, so you can fix UI issues relatively easily.

Packaging

The last obstacle standing in our apps way and its happy users is packaging. We need to find a way to prepare, distribute and be able to update our app. There are multiple options available, I'll show you one, that I find very practical to use: fxlauncher. It comes as a Gradle plugin (Maven is also available), so you can apply it in your project like:

buildscript {
    repositories { mavenCentral() }
    dependencies { classpath "no.tornado:fxlauncher-gradle-plugin:1.0.18" }
}

apply plugin: "no.tornado.fxlauncher"

fxlauncher {
    applicationVendor "My Company"
    applicationUrl "http://host/path"
    applicationMainClass "com.example.Application"
    deployTarget "username@hostname:path"
}

What are applicationUrl and deployTarget you are asking? Well, you can publish your builds to a remote URL (via scp), and your application will auto-update for all of your users on the next restart.

The plugin will add a bunch of tasks to your Gradle build:

FXLauncher tasks
----------------
copyAppDependencies - Copies all application runtime dependencies into working directory
deployApp - Deploy the application artifacts to the remote repository via scp
embedApplicationManifest - Embeds the application manifest in fxlauncher.jar
generateApplicationManifest - Generates the application manifest
generateNativeInstaller - Generate a native installer for your platform using javapackager

Executing gradle generateNativeInstaller gives you a native installer for your app, e.g. on macOS a standard DMG.

JavaFX in the wild

Building a "native" desktop app is probably not the first thing that comes to your mind when it comes to UI development, but I think it definitely has its use-cases. It may be a good fit, if:

  • you are experimenting with some technology and you need something more sophisticated than System.in|out (a good example would be Thomas Nield's Naive Bayes User Input Prediction project)
  • you are working on some tool that you intend to use inside your company (maybe for doing data analysis with krangl or tablesaw and visualize the results)
  • you are working on some B2B project (here are a few examples)

Of course, it heavily depends on your skill set, but I find working with JavaFX much more productive than building a web app. Especially with a mobile background learning JavaFX should be fairly straightforward. I hope you find this overview useful and when you start working on your next UI project, you'll give JavaFX a thought. ✌️

More resources

ensemble8