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 thehovered
state and thechecked
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))
}
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
})
}
}
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 }
)
}
}