Thomas Künneth
Posted on October 3, 2022
A few days ago I posted a code snippet on Twitter and promised an article that would explain it. Turns out you will be getting more than one.
Let's start.
The code snippet uses introspection to find a property in a Java Bean. If the bean holds a PropertyChangeSupport
instance, my code registers a listener which updates a Jetpack Compose state whenever the property identified by the passed name changes. The idea of this quickly baked Kotlin function (don't worry, you'll see the code later in this series) is to bridge the JavaBeans component model with the Compose world.
Why would you want to do that?
Compose for Desktop is a fast reactive desktop UI framework for Kotlin, based on Google's modern toolkit. It simplifies UI development for desktop applications and allows extensive UI code sharing between Android and desktop applications. You can use it to
- create new desktop apps using Compose
- migrate existing ones to Compose
The second option is particularly thrilling: start by replacing small parts of the old (Swing) UI with a handful of composable functions. Through the course of time the whole app will get a shiny new look. Doing the migration incrementally by repeatedly applying small changes has several benefits:
- easy to test
- short time to market
- impact easier to predict than large-scale modifications
At this point, you may be thinking Doesn't changing the UI framework have considerable implications regarding the overall app architecture?
That really depends on how well that architecture is. If UI and business logic are deeply entwined, it simply won't work. You would need to separate the mess into layers, which in fact is rewriting the app. Don't do that. Better start fresh.
If, on the other hand, you can identify different, ideally loosely coupled, layers, you can - and should - investigate further. The presence of established patterns like MVC, MVP, or MVVM is a very promising sign. That's because if we want to bridge worlds (Swing and Compose) we need synchronization points. Controller, Presenter, and ViewModels can be that. Let's see how.
Souffleur is an open source remote control for presentations. The client (a Flutter app) sends commands (next page, previous page, first slide, last slide) to a server (a Java Swing app) that emits corresponding key strokes. If the presentation software is running on the same machine, it will show the next, previous, etc. slide.
Below the Start button you can see <
, >
, and so on. These symbols represent the commands. When a command is fired, the corresponding symbol will briefly change its color.
I said that migrating a Swing app to Compose is best done incrementally, so let's start by replacing the indicators with Compose UI elements. Technically we need to host Compose hierarchies inside the existing Swing UI. And we need to pass the data that is consumed by the Swing indicators to the new composables.
Synchronization points
Ideally, both the old Swing UI and the new composables get all data they want to display from, for example, a ViewModel or the equivalent when using another pattern, and update it upon changes. Here's how Souffleur does this:
package eu.thomaskuenneth.souffleur;
import javax.swing.SwingUtilities;
import java.awt.AWTException;
import java.beans.PropertyChangeSupport;
import java.util.function.Consumer;
public class ViewModel {
private final PropertyChangeSupport pcs =
new PropertyChangeSupport(this);
private static final String RUNNING = "running";
private Boolean running = null;
...
private static final String LAST_COMMAND = "lastCommand";
private String lastCommand = null;
...
public String getLastCommand() {
return lastCommand;
}
public void setLastCommand(String newLastCommand) {
String oldLastCommand = getLastCommand();
this.lastCommand = newLastCommand;
pcs.firePropertyChange(LAST_COMMAND,
oldLastCommand,
newLastCommand);
}
public void observeLastCommand(Consumer<String> callback) {
observe(LAST_COMMAND, callback);
}
...
@SuppressWarnings("unchecked")
private <T> void observe(String propertyName, Consumer<T> callback) {
pcs.addPropertyChangeListener(propertyName, evt -> {
if (propertyName.equals(evt.getPropertyName())) {
callback.accept((T) evt.getNewValue());
}
});
}
}
The Souffleur ViewModel is a so-called Java Bean. The JavaBeans specification defines and describes a component architecture, and is, at least partially, used in practically all Swing apps. The underlying idea is that software is built by combining components that exchange data through messages using some protocol (described in the JavaBeans spec). Often, but not necessarily, these components are visible to the user. You can think of a ViewModel as a non-visual component.
Let me emphasize that JavaBeans were not invented to write ViewModels. To repeat, Java Beans are components that adhere to a small set of principles and rules. But they provide all we need to observe and apply changes to their properties, by allowing us to
- read and write properties with Getters and Setters
- get notified upon changes through property change listeners
But what if your desktop (Swing) app doesn't use ViewModels? While the expectations about how a good ViewModel should be implemented vary among platforms, there are two common preconditions:
- properties are observable
- the implementation provides means to change the data it holds
And these preconditions apply to all patterns that separate data, views, and (UI) logic. The nice thing about Java Beans is that they follow these preconditions.
Let me summarize: if your desktop app already uses Java Beans to provide data for the user interface, you just need to make the data available to Jetpack Compose.
Here's how the Swing version of the indicators are integrated into the UI.
private JPanel createIndicators() {
JPanel indicatorsPanel = UIFactory.createFlowPanel(24);
indicatorsPanel.add(createIndicator(Server.HOME));
indicatorsPanel.add(createIndicator(Server.PREVIOUS));
indicatorsPanel.add(createIndicator(Server.NEXT));
indicatorsPanel.add(createIndicator(Server.END));
indicatorsPanel.add(createIndicator(Server.HELLO));
JPanel panel = new JPanel();
panel.add(indicatorsPanel);
return panel;
}
private JLabel createIndicator(String indicator) {
Map<String, String> symbols = Map.of(
Server.HOME, "|<",
Server.PREVIOUS, "<",
Server.NEXT, ">",
Server.END, ">|",
Server.HELLO, ";-)");
JLabel label = new JLabel(symbols.get(indicator));
viewModel.observeLastCommand(value -> label.setForeground(
indicator.equals(value)
? Color.red
: UIManager.getColor("Label.foreground")));
return label;
}
Let's compose them. As we follow the priciple of making small, incremental changes, we keep createIndicators()
for now, but change invocations of createIndicator()
to createComposeIndicator()
.
private JPanel createIndicators() {
indicatorsPanel.add(
ComposeMainKt.createComposeIndicator(Server.HOME, viewModel)
);
indicatorsPanel.add(
ComposeMainKt.createComposeIndicator(Server.PREVIOUS, viewModel)
);
indicatorsPanel.add(
ComposeMainKt.createComposeIndicator(Server.NEXT, viewModel)
);
indicatorsPanel.add(
ComposeMainKt.createComposeIndicator(Server.END, viewModel)
);
indicatorsPanel.add(
ComposeMainKt.createComposeIndicator(Server.HELLO, viewModel)
);
JPanel panel = new JPanel();
panel.add(indicatorsPanel);
return panel;
}
Here's how createComposeIndicator()
looks like. Remember that, while we want to use composable functions, we need to embed them in a Swing component hierarchy.
fun createComposeIndicator(
indicator: String,
viewModel: ViewModel
): ComposePanel {
val panel = ComposePanel()
panel.setContent {
val state by remember {
viewModel.observeAsState<String?>("lastCommand")
}
Icon(
imageVector = when (indicator) {
Server.HOME -> Icons.Default.Home
Server.PREVIOUS -> Icons.Default.ArrowBack
Server.NEXT -> Icons.Default.ArrowForward
Server.END -> Icons.Default.ExitToApp
else -> Icons.Default.Favorite
},
contentDescription = indicator,
tint = if (state == indicator)
MaterialTheme.colors.primary
else
MaterialTheme.colors.onBackground,
modifier = Modifier.background(
color = MaterialTheme.colors.background
)
)
}
panel.preferredSize = Dimension(24, 24)
return panel
}
If you have done Compose View
interop on Android, you know ComposeView
. ComposePanel
is the equivalent in Compose for Desktop. We pass a Compose hierarchy to setContent { ... }
. My example is pretty simple, I won't go into detail here. The only thing worth mentioning is observeAsState()
. This is the function I was teasing in my Twitter post.
Conclusion
You may want to learn more about observeAsState()
. Unfortunately, I need to put you off until the second installment. Firstly, we already covered lots of topics. Secondly, before closing this introductory part, I want to remind you that we agreed on an incremental migration with small steps. That's why we just changed createIndicator()
to createComposeIndicator()
. This works great, but having lots of ComposePanel
s is not a good idea. Moving along with the migration, we must move it up the hierarchy. The next step would be using it in createIndicators()
.
I hope you enjoyed this post. Have you already used Compose for Desktop. Kindly share your thoughts in the comments.
Posted on October 3, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.