Skip to main content

Declarative UI

An essential part of Komplete UI is that it allows you to express the user interface in a declarative style. It's what allows you to write this:

component Button {
@property text: String
@property bg_color: Color

hovered: Bool = false
pressed: Bool = false

bg_color_state: Color {
if self.pressed { return self.bg_color.opacity(0.5) }
if self.hovered { return self.bg_color.opacity(1.0) }
return self.bg_color.opacity(0.75)
}

Text(self.text) with {
Background { Rectangle(color: self.bg_color_state) }
Hover(self.$hovered)
TapGesture(
down: fun (event) { self.pressed = true },
up: fun (event) { self.pressed = false },
)
}
}

instead of this (or at least some variation of it)

component Button {
text: StringState
bg_color: ColorState

text_child: Text
background_child: Rectangle

pressed: BoolState = BoolState(false)
hovered: BoolState = BoolState(false)

text_connection: Connection?
bg_color_connection: Connection?

constructor(text: StringState, bg_color: ColorState) {
self.text = text
self.bg_color = bg_color

self.text_child = Text()
self.background_child = Rectangle()

self.add_child(self.text_child)
self.add_child(self.background_child)

self.attach(TapGesture(
down: fun (event) { self.pressed.set(true) },
up: fun (event) { self.pressed.set(false) },
))
self.attach(Hover(self.hovered))

self.pressed.on_change(fun (new_pressed) {
self.update_bg_color()
})
self.hovered.on_change(fun (new_pressed) {
self.update_bg_color()
})
self.text_connection = text.on_change(fun (new_text) {
self.text_child.set_text(new_text)
})
self.bg_color_connection = bg_color.on_change(fun (new_bg_color) {
self.update_bg_color()
})
}

destructor() {
self.text_connection.disconnect()
self.bg_color_connection.disconnect()
}

update_bg_color() {
var color = self.bg_color.get().opacity(0.75)
if self.pressed.get() {
color = color.opacity(0.5)
}
else if self.hovered.get() {
color = color.opacity(1.0)
}
self.background_child.set_color(color)
}
}

It allows you to simply describe your UI and state the logic, without the need to write down how to get there. Reactivity plays an important part in achieving this. It's the system that automatically understands the dependencies of your logic and updates the UI when necessary.

Building Blocks

In Komplete UI components, modifiers and templates are the declarative building blocks to build your user interface. Inside a component or modifier everything is trackable, i.e. properties, bindings, states (computed or not), methods.

Properties, Bindings & States

Take this checkbox as an example:

component Checkbox {
@property text: String
@binding checked: Bool

hovered: Bool = false

color: Color {
return self.bg_color.opacity(self.hovered or self.checked ? 1.0 : 0.75)
}

HStack(spacing: 8) {
Text(self.text, color: self.color)
Rectangle(color: self.color) with {
Frame(width: 16, height: 16)
}
} with {
Hover(self.$hovered)
TapGesture(fun (event) {
self.checked = not self.checked
})
}
}

Let's break down the dependencies in this example:

  • The label text depends on the text property
  • The text's and rectangle's color depends on the computed color state, which in fact depends on the hovered state and the checked binding

When any of them get updated, the Checkbox will update its appearance on screen. This example also demonstrates how properties automatically change when their dependencies change. This is very different from classical imperative frameworks where you have to manually do something like text.set_text(...).

Any dependency used inside of a component's property is tracked. No matter whether the dependency is explicitly specified or implicitly used through methods and computed states.

component Example {
state: Int = 4
derived: Int {
return self.state + 10
}
offset(by delta: Int) -> (Int) {
return self.derived + delta
}

Text("Hello", size: self.offset(by: 5))
}
info

Using a value as part of a components property doesn't make the creation of the component dependent on said value, but only the property itself.

component Checkbox {
text: String = "Hello"
// Label isn't dependend on text here. Only the Text's text property is.
label: Component {
return Text(self.text)
}

self.label // label never changes, but the displayed text does
}

Component/Modifier Body & Templates

Dependency tracking also works inside a component's or modifier's body.

component Container {
@property vertical: Bool

if self.vertical {
VStack { ... }
}
else {
HStack { ... }
}
}

The if-statement automatically tracks the dependencies of its condition. Here the same rules apply as for component/modifier properties.

Templates behave exactly the same as a component's body.

component Checkbox {
@property indicator: Template(Bool)

checked: Bool = false

self.indicator(self.checked) with {
TapGesture(fun (event) {
self.checked = not self.checked
})
}
}

component App {
Checkbox(
indicator: template (checked) {
if checked {
Image("checked.png")
} else {
Rectangle(color: Color(0xFF242424))
}
}
)
}

Classes

Even though classes are not part of the declarative building blocks, their properties can be tracked directly or through computed properties and methods.

class Model {
checked: Bool = false
}
var model = Model()

export component Checkbox {
Rectangle(color: model.checked ? Color(0xFFFF0000) : Color(0xFF00FF00)) with {
TapGesture(fun (event) {
model.checked = not model.checked
})
}
}

Common Pitfalls

Dynamic Variables in Components

Variables cannot be tracked by Komplete UI at the moment. So the following example will not work.

var checked = false

export component Checkbox {
Rectangle(color: checked ? Color(0xFFFF0000) : Color(0xFF00FF00)) with {
TapGesture(fun (event) {
checked = not checked
})
}
}

Relying On Order Or Count Of Updates

The order in which Komplete UI updates the UI or how often it re-evaluates certain properties or computed states is not guaranteed. That means all your UI code should be side-effect free. Something is side-effect free if re-running the code several times always produces the same output and doesn't change global state. The following example is not side-effect free, as it implicitly updates the count variable whenever the color is computed.

var count = 0

component Checkbox {
checked: Bool = false
color: Color {
count = count + 1 // this is as side effect
return self.checked ? Color(0xFFFF0000) : Color(0xFF00FF00)
}

Rectangle(color: self.color) with {
TapGesture(fun (event) {
self.checked = not self.checked
print("Count: \{count}") // cannot rely on count value here
})
}
}
caution

Making use of side effects in your UI code could potentially break your instrument with the next Kontakt update. We don't guarantee the order or number of updates in the UI.

This also extends to computed properties of classes. How often they compute is not guaranteed.

Creating Components From Functions

Calling a function or method to create a component causes the component to be re-created whenever the arguments change. This is almost never what you want, because it costs performance, loses local state and interrupts animations.

component Checkbox {
@property indicator: (Bool) -> (Component)

checked: Bool = false

self.indicator(self.checked) with {
TapGesture(fun (event) {
self.checked = not self.checked
})
}
}

component App {
Checkbox(
indicator: fun (checked) {
return Rectangle(checked ? Color(0xFFFF0000) : Color(0xFF00FF00))
}
)
}

The indicator function will be invoked every time checked changes, thus a new indicator will be created every time. That the function is invoked every time is an important feature for many other use cases, but in particular for components it is not what is wanted. Use templates for this instead:

component Checkbox {
@property indicator: Template(Bool)

checked: Bool = false

self.indicator(self.checked) with {
TapGesture(fun (event) {
self.checked = not self.checked
})
}
}

component App {
Checkbox(
indicator: template (checked) {
Rectangle(checked ? Color(0xFFFF0000) : Color(0xFF00FF00))
}
)
}

Furthermore, templates have the advantage that they can contain multiple components and you use declarative if and for.

Limits

All values accessed as part of a reactive expression are tracked. This means that even values that were just read to print something can trigger a UI update. Take a look at the following example where a Rectangle is colored based on the hovered state. Even though the pressed state doesn't contribute to the color decision, it is used during the computation of color. So whenever pressed changes, the color of the Rectangle will be recomputed.

component Example {
pressed: Bool = false
hovered: Bool = false

color: Color {
print("Is Pressed: \{self.pressed}")
return self.hovered ? Color(0xFFFF0000) : Color(0xFF00FF00)
}

Rectangle(color: self.color) with {
Hover(self.$hovered)
TapGesture(
down: fun (event) { self.pressed = true }
up: fun (event) { self.pressed = false }
)
}
}