August 19, 2024 by Olivier Goffart

Property Changed Callback in Slint


Slint is a modern UI toolkit with its own domain-specific language (DSL) designed for creating responsive and intuitive user interfaces. In this blog post, we'll dive into a recently introduced experimental feature in Slint: the property changed callback. We'll explore how it interacts with Slint's property system and we'd love to get your feedback on this new addition.

The Property System in Slint

Slint's property system is built on the foundation of property bindings. With property bindings, you define relationships between properties in a declarative manner. Here's a typical example:

width: parent.width - parent.paddings * 2;

This binding is evaluated lazily. What does that mean? The computation will only be performed when the width is needed, such as during rendering. If the element isn't visible, the binding won't be evaluated at all, saving on unnecessary computation.

Moreover, Slint's properties are reactive. Once a dependency, like parent.width or parent.paddings, changes, the binding is marked as dirty. Dirty bindings will be re-evaluated next time the property is accessed. Otherwise the property value is cached. It's worth noting that even if the resulting value doesn't actually change, the binding might still be marked as dirty. This behavior ensures that your UI always reflects the latest data while avoiding unnecessary updates. And don't worry about creating circular dependencies: Slint detects these at compile time.

Slint also supports property animations:

animate width { duration: 200ms; }

This makes a smooth transitions as soon as the property binding changes. The animation starts whenever the property is marked as dirty.

Introducing the changed Callback

Users have been asking for a way to execute Slint code when a property changes, leading us to introduce the changed callback:

changed width => {
    // Your code here
}

This feature is currently behind an experimental flag and can be enabled by setting the SLINT_ENABLE_EXPERIMENTAL_FEATURES=1 environment variable at application build time, as of Slint 1.7.

This enables use cases that are difficult to do without it, such as ensuring that a spinbox's value property remains in bounds:

component SpinBox {
  property<int> value;
  changed value => {
    // ensure value is in bound
    if (value < 0 || value > 100) {
       value = value.clamp(0, 100);
    }
    // keep in sync with the text
    txt.text = value;
  }
  HorizontalLayout {
    Button { text: "-"; clicked => { value -= 1; } }
    txt := LineEdit { accepted => { value = self.text.to-float(); }  }
    Button { text: "+"; clicked => { value += 1; } }
  }
}

Challenges

We've been delaying the implementation of this feature for several reasons.

One of the core philosophies of Slint is to maintain a declarative approach, which makes it easier to manage and edit UIs. The changed callback introduces imperative logic, complicating the tooling experience.

Moreover, the callback interacts with Slint's lazy evaluation model. Triggering a changed callback forces the property binding to be evaluated even if the property is otherwise unused.

Another challenge we faced is the question when to execute the callback. Our current implementation delays its execution until the next event loop iteration. This delay is necessary because side effects can't be triggered while marking a property as dirty, and also to avoid the invocation of the callback several times if the property is being changed several times through different properties.

One solution we're still not happy with, is the solution the loop problem. Consider this scenario:

changed foo => { bar += 1 }
changed bar => { foo += 1 }

This example creates a loop where each property change triggers the other. Detecting such loops at compile time proved too complex. Sometimes, loops are even desirable when the value converge (such as in the SpinBox example above.
We've therefore implemented a runtime safeguard. We limit the number of chained changes to 10 per event loop. While this prevents the application from hanging, it still results in constant re-evaluations of the bindings.

Conclusion

The changed callback is an experimental addition to Slint's property system. It opens up new possibilities but also introduces complexities that need careful consideration. Before we stabilize this feature, we're eager to hear your thoughts. Please share your feedback in the comments or in the related issue tracker.

To try it out, set the SLINT_ENABLE_EXPERIMENTAL_FEATURES=1 environment variable while compiling or previewing your Slint application.
For more details, check out the current documentation.


Comments

Slint is a Rust-based toolkit for creating reactive and fluent user interfaces across a range of targets, from embedded devices with limited resources to powerful mobile devices and desktop machines. Supporting Android, Windows, Mac, Linux, and bare-metal systems, Slint features an easy-to-learn domain-specific language (DSL) that compiles into native code, optimizing for the target device's capabilities. It facilitates collaboration between designers and developers on shared projects and supports business logic development in Rust, C++, JavaScript, or Python.