Announcing Nimbus Server-Driven UI Beta

Easy-to-use libraries for building Jetpack Compose and SwiftUI applications driven by the backend

Tiago Peres França
Better Programming

--

White clouds on a blue sky
Photo by Billy Huynh on Unsplash

Server Driven UI (SDUI)

SDUI is the ability to control the looks, the behavior and the flow of a frontend application via the backend. This is very useful for several reasons:

  1. It provides fast updates to the user since there’s no need to go through the Apple Store or the Play Store to alter the user interface (UI).
  2. It allows the app owner to provide different UIs for different users via the backend.
  3. The frontend applications still need to implement every component with a reference in the backend, but all the UI logic programmed in the backend (represented by a JSON) can be shared among the frontend applications, which reduces the total amount of code in the project and its maintenance.

It is not the goal of this article to go into the details of SDUI as a concept. To know more about the subject, we recommend reading the articles below:

Nimbus SDUI

As of 2023, we have very stable versions of both Jetpack Compose and SwiftUI, which are frameworks that already work with the concept of components, states, and reactivity. Implementing Server Driven UI on top of them is a much cleaner approach than building it from Android Views or UIKit (the approach of all current open-source libraries for SDUI).

Furthermore, it’s much easier for a developer familiar with Compose and SwiftUI to understand how SDUI libraries behave since all of them work with states and reactivity.

Since Nimbus depends on Compose and SwiftUI, it requires Android Lollipop (5.1, API 21) and iOS 13.

Nimbus, for the frontend developer, was developed with the idea that, if Jetpack Compose (on Android) and SwiftUI (on iOS) are known, no further knowledge should be required to implement an application compatible with Server Driven Views. This is not an easy task to accomplish, but we can say that we got almost there!

For the frontend developer, it is very easy to start building apps with Nimbus, and the code for the components can be decoupled from the lib itself, which allows them to work for both native views and server-driven views. Let’s see some examples.

A Button component in Jetpack Compose:

@Compose
fun MyButton(text: String, enabled: Boolean?, onPress: (() -> Unit)) {
Button(onClick = onPress, enabled = enabled != false) {
Text(text)
}
}

A Button component that works for both native views and server-driven views:

@Compose
@AutoDeserialize
fun MyButton(text: String, enabled: Boolean?, onPress: (() -> Unit)) {
Button(onClick = onPress, enabled = enabled != false) {
Text(text)
}
}

That’s it! An annotation. Now the component is ready to be registered for server-driven usage!

Now let’s see the same button in SwiftUI:

struct MyButton: View {
var text: String
var onPress: () -> Void

var body: some View {
Button(text) {
onPress()
}
}
}

The button is ready for use on both native views and server-driven views:

struct MyButton: View, Decodable {
var text: String

@Event
var onPress: () -> Void

var body: some View {
Button(text) {
onPress()
}
}
}

For Swift, we need an annotation (property wrapper) to tell Nimbus "onPress" should be treated as an event and protocol conformance: Decodable. These changes to the original component make no difference when the component is used natively but allow it to work with Nimbus.

Using the same logic, we can define the components "Column", "Text" and "TextInput".

On Android:

@Composable
@AutoDeserialize
internal fun Column(content: @Composable () -> Unit) {
Column {
content()
}
}

@OptIn(ExperimentalUnitApi::class)
@Composable
@AutoDeserialize
internal fun MyText(text: String?, size: Float?) {
Text(
text = text ?: "",
fontSize = TextUnit((size ?: 12F), TextUnitType.Sp),
)
}

@Composable
@AutoDeserialize
fun TextInput(
label: String,
value: String?,
onChange: (value: String) -> Unit,
) {
TextField(
value = value ?: "",
onValueChange = onChange,
label = { Text(label) },
)
}

On iOS:

struct Column<Content: View>: View, Decodable {
@Children var children: () -> Content

var body: some View {
VStack() {
children()
}
}
}

struct MyText: View, Decodable {
var text: String?
var size: Double?

var body: some View {
Text(text ?? "")
.font(.system(size: size ?? 12.0))
}
}

struct TextInput: View, Decodable {
var label: String

var value: String?
@StatefulEvent var onChange: (String) -> Void

var body: some View {
let binding = Binding(
get: { value ?? "" },
set: { onChange($0) }
)

TextField(label, text: binding)
}
}

As seen in the examples, we don’t need different components for native views and server-driven views, the fact that we are going to use Server Driven UI in our app, doesn’t make us develop any differently. Nimbus can update the values of the component's properties and interpret functions like "onPress" and "onChange" without any further interaction from the developer.

The Backend (JSON)

Given we have all the components implemented and registered (Android docs | iOS docs) in the front, it’s time to represent a view in the backend.

For the backend development, the learning curve is steeper, we are trying to make it minimal by making it as close as possible to Jetpack Compose, SwitfUI and React, but the developer will need to spend some time on the documentation to master it.

Just like most SDUI solutions, Nimbus uses a JSON to represent a view. Using the components previously implemented, we define a very simple application that requests the user name and age on the first page and, on the second page, presents a button to buy some bears as long as the age was equal to or greater than 18. Check the gif below:

Example of the application we're going to explore

JSON for the first page:

{
"_:component": "sample:column",
"state": {
"name": "",
"age": ""
},
"children": [
{
"_:component": "sample:textInput",
"properties": {
"value": "@{name}",
"label": "Write your name",
"onChange": [
{
"_:action": "setState",
"properties": {
"path": "name",
"value": "@{onChange}"
}
}
]
}
},
{
"_:component": "sample:textInput",
"properties": {
"value": "@{age}",
"label": "Write your age",
"onChange": [
{
"_:action": "setState",
"properties": {
"path": "age",
"value": "@{onChange}"
}
}
]
}
},
{
"_:component": "sample:button",
"properties": {
"text": "Next",
"onPress": [
{
"_:action": "push",
"properties": {
"url": "/second",
"state": {
"name": "@{name}",
"age": "@{age}"
}
}
}
]
}
}
]
}

JSON for the second page:

{
"_:component": "sample:column",
"state": {
"beers": 0
},
"children": [
{
"_:component": "sample:text",
"properties": {
"text": "@{name} has @{beers} bears."
}
},
{
"_:component": "sample:button",
"properties": {
"text": "Buy 1 beer",
"onPress": [
{
"_:action": "condition",
"properties": {
"condition": "@{gte(age, 18)}",
"onTrue": [
{
"_:action": "setState",
"properties": {
"path": "beers",
"value": "@{sum(beers, 1)}"
}
}
],
"onFalse": [
{
"_:action": "log",
"properties": {
"message": "@{name} must be at least 18 years old to drink.",
"level": "Error"
}
}
]
}
}
]
}
}
]
}

Notice that by using the delimiters "@{" and "}" we can write some code to the JSON. This is the Nimbus Script. This script is a very basic programming language that allows complex behavior to be programmed in the backend, meaning that your server-driven views can be as dynamic as you wish without the need of making another request to the backend. This language is not yet final and will be extended to support more use cases before the final release of Nimbus. For details on it, we refer the reader to the documentation.

To make the view dynamic, in addition to the Nimbus Script, Nimbus uses States and Actions. States work similarly to any other framework for reactive UIs. The State is declared for a component and visible to itself and any descendent. When a state has its value changed, every component that depends on it is automatically updated (re-rendered).

Actions are functions in the frontend that implement a given behavior, e.g. "setState": changes the value of a state; "log": writes a message to the console; "push": navigates to another page; "condition" decides between two actions to run based on a condition. Besides the default actions shipped with Nimbus, the developer can implement any custom action for the application.

A backend tool for creating the JSONs

Let's be honest, nobody likes to write raw JSONs. For this reason, Nimbus will have both a Typescript (Node.js) and a Kotlin backend library. Our focus right now is in the frontend, which is where most of the complexity lies. However, we have an alpha implementation of the Backend for Typescript that can already be used.

Let’s see the previous example in JSON using the Nimbus Backend TS lib:

First page:

export const FirstPage: Screen = ({ navigator }) => {
const name = createState('name', '')
const age = createState('age', '')

return (
<Column state={[name, age]}>
<TextInput value={name} label="Write your name" onChange={value => name.set(value)} />
<TextInput value={age} label="Write your age" onChange={value => age.set(value)} />
<Button text="Next" onPress={navigator.push(SecondPage, { state: { name, age }})} />
</Column>
)
}

Second page:

interface SecondPageData extends ScreenRequest {
state: {
name: string,
age: string,
}
}

export const SecondPage: Screen<SecondPageData> = ({ getViewState }) => {
const beers = createState('beers', 0)
const name = getViewState('name')
const age = getViewState('age')
const buy = conditionalAction({
condition: gte(age, 18),
onTrue: beers.set(sum(beers, 1)),
onFalse: log({ message: `${name} must be at least 18 years old to drink.`, level: 'Error' })
})

return (
<Column state={beers}>
<Text>{name} has {beers} bears.</Text>
<Button onPress={buy}>Buy 1 beer</Button>
</Column>
)
}

If you ever worked with React, you’ll notice that this is very similar!

We intend to start the development of a Kotlin version of the backend as soon as we have a stable version of the frontend libraries (1.0.0).

Component libraries

Nimbus, by itself, just enables SDUI in your project, it doesn’t have any UI component built in. However, we believe that the same layout components will be needed by most applications, thus, in addition to Nimbus, we also provide Nimbus Layout as a separate library. This tool brings to Nimbus components like: Column, Row, Stack, Positioned, Text, FlowRow, FlowColum, LazyRow, LazyColumn, among others.

Design System components like buttons, text inputs, toggles, and menus are not provided right now because they will probably differ from one application to another and it is best that each project implement its own. In the case of Design Systems already developed with SwiftUI or Compose, it suffices to expose them to Nimbus. Having said this, we might decide to implement a collection of them in the future to serve as a showcase.

Current development stage

We’re announcing Nimbus as it enters its first beta stage. Right now need some feedback. What can we do better? Which features are missing? What bugs haven’t been found yet?

The beta stage means that we’ll fix bugs and enhance the performance. We’ll also implement some minor features that are still missing. We'll still modify some parts of the API, but the changes will be minor.

Useful links

  • Documentation: the documentation for both the frontend and backend libraries. This is not in a website format yet, but you can read everything through GitHub.
  • Progress: current development progress of Nimbus.
  • Nimbus: the common code between Nimbus SwiftUI and Nimbus Compose. This has been built using Kotlin Multiplatform Mobile (KMM).
  • Nimbus Compose: all modules necessary to run Nimbus in a Jetpack Compose project.
  • Nimbus SwiftUI: all modules necessary to run Nimbus in a SwiftUI project.
  • Nimbus Compose Layout: layout components for Nimbus Compose.
  • Nimbus SwiftUI Layout: layout components for Nimbus SwiftUI.
  • Nimbus Backend TS: modules for the backend in Typescript.

If you have a problem and don’t know where to post it, please use the Nimbus Core repository.

Up next

In the next weeks, we'll be releasing a series of articles exploring how to create a simple To-Do App using Nimbus in both iOS and Android. Until then, you can read the documentation.

Edit: the first part of the tutorial on how to create an app with Nimbus SDUI is now out! Click here to read it.

Conclusion

Nimbus is a new set of libraries for creating applications with Server Driven UI in mind. As a design philosophy, Nimbus doesn’t require much additional knowledge to develop the front end and wants to be as less intrusive as possible in the application code by never coupling the SDUI logic with the component’s implementation.

On the backend, however, there will be a learning curve, but we want to minimize it by following the ideas of well-established libraries like Compose, SwiftUI, and React. It’s true that, if the backend developer doesn’t know any of these (which are frontend platforms), it might still be hard for them to declare a Server Driven View. In the end, even though the work has been shifted to the server, it is still, basically, a frontend task, and maybe, the best people to develop it would be frontend developers.

Nimbus takes advantage of both Compose and SwiftUI to provide a clean implementation of Server Driven UI. It is an open-source tool and can be used by any project that needs it (given they respect the minimum version requirements of iOS and Android).

Nimbus is in the Beta stage, it is not yet stable, we count on the community for testing it and giving us feedback.

--

--