Composing the desktop
Thomas Künneth
Posted on January 31, 2023
Declarative UI frameworks are steadily displacing their imperative predecessors. The Web and React have been pioneering the movement ten years ago. Googles cross platform framework Flutter (the first stable version appeared at the end of 2018) is declarative by design. Apple introduced SwiftUI during WWDC 2019. Android was a little late. It took until summer 2020 to release the first alpha versions to the public.
On macOS, Windows and Linux, things are a little different. Only Apple is strict regarding tools, frameworks, and programming languages to use for Mac apps. The other two platforms let the developers decide. This led and still leads to a style mix. Liking or hating that is a matter of taste. What's important: most of the established desktop frameworks date back many years, even back to the early days of the operating systems they run on. To put it another way: their architecture is several decades old. This is not necessarily bad, on the contrary. It proves that the underlying ideas and concepts are sound, flexible, and sustainable.
Yet, if that's true, why a new paradigm? Even more, is the desktop still relevant? It has been declared dead many times since the Web and the browser became ubiquitous. Instead of painstakingly writing apps for Windows, macOS, and Linux, we could focus on one platform, the browser. Certainly, with Java we could write graphical user interfaces that ran on different platforms. But AWT was too simplistic, and Swing, at least at the beginning, not fast enough. Granted, at some point in time we learnt how to write fast, beautiful and elegant cross platform apps with Swing, but by then the Web was - let's be honest - already triumphant. Consequently, Adobe Air, JavaFX, and the likes, did not stand a chance. So, let me restate my question, why develop for the desktop anyway? Well, we do want to keep a few things local, possibly due to security or performance considerations, or the simple lack of network access. What's more: not being constrained by the chains of the browser often makes development much easier. Local file access, anyone? Finally, rediscovering a well-known platform can be pretty inspiring.
Imperative versus deklarative
Most of the popular desktop UI frameworks are object-oriented and component-based. During runtime, the user interface of an app consists of one or more object trees. Each object represents either a UI element or a container, that also takes portions of the user interface (for example, a window, a menu bar, or a dialog). Practically all frameworks know special containers that arrange and size their children. To add, delete, or change parts of the user interface, the component (object) tree is altered during runtime, either by adding or removing branches, or by modifying leaves (objects). That's why such frameworks are called imperative. Any change, no matter how small or insignificant, must be coded explicitly.
The issue: the more complex the user interface gets, the more complex it becomes to do the right changes. That's because the UI elements of most imperative frameworks live their own life. Now would be the right time to remind you what the term component meant in the late 1990s. But that's an article in its own right. So, let's put up with an example. A text input field not only monitors the keyboard, but also knows about cut, copy, and paste operations. It may be capable of validating and filtering the input, too. The text field stores its state (at least the current text and the cursor position) in properties, which can, well, actually must, be accessed in the app source code.
Here's why: just because the content of a variable changed, the text field is not magically updated. Also, entering or removing characters does not necessarily change the corresponding variable. Both component properties and variables must be synchronized, and it's the developers' job to do that. Each component framework has its favorite tools, techniques, or mechanisms to achieve that, for example callbacks or binding. But the programmer must utilize these tools. It turned out that keeping both component properties ond program variables in sync can quickly become tedious and error-prone. Through the course of time, quite a few design patterns emerged that can significantly enhance readability and maintainability. Still, the fact that data and user interface (that is, the components which represent the UI) live in different worlds (that need to be synced) remains.
This is what deklarative UI frameworks do differently. Instead of painstakingly updating component trees whenever data has changed, we describe (well, declare) in the source code how the user interface should look like (not: change) based on the current data. Practically all declarative UI frameworks know the concept of state. State is data that may change over time. What's important: state changes automatically trigger UI changes. If something needs to be rebuilt or redrawn, is decided by the framework. Not by the developer. Not in the app source code. Data is at the core of development.
In Jetpack Compose, Googles declarative UI Framework for Android, it looks like this:
@Composable
@Preview
fun CounterDemo() {
var counter by remember { mutableStateOf(0) }
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier.padding(16.dp)
) {
Box(
contentAlignment = Center,
modifier = Modifier.height(200.dp)
) {
if (counter == 0) {
Text(
text = "Not yet clicked",
softWrap = true,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h3
)
} else {
Text(
text = "$counter",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h1
)
}
}
Button(
onClick = { counter += 1 }
) {
Text(text = "Klick")
}
}
}
UI elements appear in the source code as Kotlin functions that have been annotated with @Composable
. @Preview
renders a composable function (composable) inside the IDE.
The user interface of an app is created by nesting custom and prebuilt (that is: provided by the framework) composable functions. CounterDemo()
puts a Box()
and a Button
inside a Column()
. The Box()
in the sample has always a child element. Which one, is determined by if (counter == 0)
. If the condition is true, the text Not yet clicked. If it is false
, the content of the variable counter
is shown instead. counter
is a state. In Jetpack Compose state is often created with mutableStateOf
and remembered using remember
. If the value changes (counter += 1
), the framework makes sure that all composables using this state are updated. This is called a recomposition.
Have you noticed that there are no references or pointers to UI elements? Unlike in imperative frameworks, no objects or tree structure need to be manipulated or changed. Therefore, there's no longer the need for keeping references to branches or leafs. This eliminates all those hard to find crashes during runtime caused by wrong pointers. If a state change requires changes to internal data structures, everything is handled inside the framework. The app developers need not bother.
Names of composable functions start with a capital letter, which reminds us of classes or data structures. This clash of convention is intentional. After all, composables represent UI elements (components). So, deklarative does not mean that there are no longer components. However, components are now less heavyweight. There are a lots of small bits and pieces that are meant to be combined. The underlying principle is called Composition over inheritance. Please note that declarative doesn't necessarily mean functional. Flutter, for example, builds upon classes and inheritance. But components are cut differently than you may know from Java Swing or JavaFX. Depending on the declarative framework, there might for example be Align
, Size
, or Padding
components. In the imperative world these would likely be properties.
How do we display CounterDemo()
? On Android it looks like this:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = stringResource(id = R.string.app_name))
}
)
}
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
contentAlignment = Center
) {
CounterDemo()
}
}
}
}
}
}
Here, CounterDemo()
is wrapped inside a few other composable functions, MaterialTheme()
, Scaffold()
, and TopAppBar()
, and is finally displayed inside an Activity (a basic building block for Android apps) using setContent { }
.
The desktop gets declarative
Would you like to try writing a declarative user interface, but don't care about mobile platforms? This has been possible for quite a while. For example, you could build a React app and make it run on the desktop using Electron. Yet, you need to know about JavaScript/TypeScript and React. There's also Flutter. This cross platform framework was released first for mobile development, but nowadays also works for the desktop and the Web. Flutter is very popular. However, you need to learn the Dart programming language. Still pretty new is Compose Multiplatform. Its creator, JetBrains, advertizes it as a fast, reactive Kotlin framework for desktop and Web user interfaces. It aims to simplify and speed-up the UI development for Web and desktop apps.
Conceptually, Compose Multiplatform consists of Compose for Desktop, Compose for Web, and Kotlin Multiplatform. You may be familiar with the latter one from mobile development. The underlying idea is to write business logic in Kotlin and combine it with native user interfaces. Google and JetBrains ported Jetpack Compose to the desktop. This has been possible because Jetpack Compose is only loosely coupled with the Android platform. The UI elements are rendered using the open source 2D graphics library Skia, which, by the way, is also used inside Chrome, ChromeOS, and Flutter. On the desktop, Compose user interfaces are hosted inside Java Swing windows. Compose apps currently run inside the JVM. This is a good thing, as you can not only use all Java and Kotlin libraries, but also the tools to build Java Native Images. To show CounterDemo()
on the desktop just a few lines of code are needed:
fun main() = application {
Window(
title = TITLE,
onCloseRequest = ::exitApplication,
) {
MaterialTheme {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = TITLE)
}
)
}
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Center
) {
CounterDemo()
}
}
}
}
}
Have you noticed that this code fragment is pretty similar to the Android version? We could refactor many parts into one function. Only accessing strings (on Android stringResource()
is used) needs to be done in another way.
Conclusion
You surely recall I said there was a style mix on the desktop. Well, Compose Multiplatform adds yet another flavor - Material Design. If you want to try out Compose Multiplatform, you should also look at the documentation accompanying Googles design system. This will make your journey through the Jetpack Compose APis much easier. To develop the app you will be using IntelliJ as the IDE and Gradle as the build system. Jetpack Compose works only with Kotlin, but all other parts of the app can be implemented using Java (or any other JVM language).
Some aspects of Compose Multiplatform, for example building and running the user interface, already feel stable and mature. Other parts still look like work in progress. Particularly, the integration into the host system needs to grow much stronger. For example, drag and drop and file associations can only be done using workarounds. It will be interesting to see which missing features JetBrains will still deliver in the course of further development. Writing Compose apps for the desktop already is great fun for sure.
Links
- https://www.jetbrains.com/de-de/lp/compose-mpp/
- https://github.com/tkuenneth/imperative_vs_declarative_uis
The German version of this article appeared first at Informatik Aktuell.
Posted on January 31, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.