Skip to main content
Version: Next

Declarative UI

Ribir uses a declarative DSL (Domain Specific Language) based on Rust macros to define the user interface. This allows you to describe what the UI should look like, rather than how to construct it step-by-step.

The core of this DSL is the fn_widget! macro.

The fn_widget! Macro

fn_widget! is the entry point for writing Ribir UI code. It transforms the DSL syntax into actual Rust code that builds the widget tree.

use ribir::prelude::*;

fn main() {
App::run(fn_widget! {
@Text { text: "Hello!" }
});
}

Creating Widgets with @

To instantiate a widget, use the @ symbol followed by the widget struct name. Properties are defined inside the curly braces {} using standard Rust struct initialization syntax key: value.

When @ is followed directly by a type, it invokes the corresponding builder to construct the object. This builder is typically generated by the #[derive(Declare)] macro, which enables the use of built-in attributes. We will explore this mechanism in detail in the Built-in Attributes & FatObj section.

Important: The @ operator is DSL-specific and only works within macros that support the Ribir DSL syntax, such as fn_widget! and rdl!. This operator is not valid Rust syntax outside of these macros and will cause compilation errors if used in regular Rust code or nested within third-party macros.

use ribir::prelude::*;

fn example() -> Widget<'static> {
fn_widget! {
@Text { text: "I am a Text widget" }
}.into_widget()
}

Parent-Child Composition

Ribir represents the UI as a tree. You can compose widgets by nesting them. Widgets that support children allow you to declare them directly inside their block.

use ribir::prelude::*;

fn composition_example() -> Widget<'static> {
fn_widget! {
@Column {
@Text { text: "Item 1" }
@Text { text: "Item 2" }
@Button {
@ { "Click Me" }
}
}
}.into_widget()
}

Here Column is a widget that supports multiple children (MultiChild, see Widget Composition), which allows you to declare children directly inside its block. Button is a widget that supports template children (TemplateChild, see Widget Composition). It automatically sets the corresponding properties through type matching, so you use @ { "Click Me" } to set the text, without needing text: "Click Me".

Reusing Widgets (Static Composition)

when you have a Widget expression , you can assign it to a variable or return it from a function, and then use it inside another fn_widget! block. This promotes reusability.

To embed a widget variable or expression into the DSL, use the @ { expression } syntax.

use ribir::prelude::*;

fn header() -> Widget<'static> {
fn_widget! {
@Text { text: "My App Header" }
}.into_widget()
}

fn app() -> Widget<'static> {
let footer = fn_widget! {
@Text { text: "Footer Content" }
};

fn_widget! {
@Column {
@ header() // Embedding a function that returns a Widget
@Text { text: "title" }
@fn_widget!{ @Text { text: "Main Content" } }
@ { footer } // Embedding a Widget variable
}
}.into_widget()
}

Note: fn_widget is like its name—a function that returns a widget. Although it can be treated as a Widget, it's a function fn -> FatObj<Stateful<Text>>, which will be called depending on the framework's build process. Thus, @fn_widget!{ @Text { text: "Main Content" } } differs from @Text { text: "title" }, which is a FatObj<Stateful<Text>>. With let text = @Text { text: "title" }, we can access the Text's struct attributes, such as $read(text).text, but this is not possible with @fn_widget!{ @Text { text: "Main Content" } }.

Dynamic Widgets

Ribir allows you to create widgets that automatically update when their data changes. The pipe! macro is the key tool for this. It creates a stream of values that can be transformed into widgets.

To create a dynamic widget, you can use the pipe! macro and embed it into your UI using the @ { ... } syntax.

use ribir::prelude::*;

fn dynamic_widget_example() -> Widget<'static> {
let count = Stateful::new(0);

fn_widget! {
@Column {
@{
pipe!(*$read(count)).map(move |c| {
if c % 2 == 0 {
@H1 { text: "Even" }.into_widget()
} else {
@H2 { text: "Odd" }.into_widget()
}
})
}
@Button {
on_tap: move |_| *$write(count) += 1,
@{ "Increment" }
}
}
}.into_widget()
}

Here are two points to note:

  1. The pipe! pipeline returns different types of widgets based on conditions, so .into_widget() is used to unify them into a single Widget type.
  2. pipe! monitors the state variables used in the expression (like $read(count)). When they change, the expression is re-evaluated, and the widget is updated.

The example uses .map() to transform the monitored result and build the corresponding Widget. However, Ribir also supports putting all operations inside pipe!, as shown below:

@ {
pipe! {
if *$read(count) % 2 == 0 {
@Text { text: "Even" }.into_widget()
} else {
@Button { @Text { text: "Odd" } }.into_widget()
}
}
}

Ribir automatically analyzes and listens for State changes in pipe!(expr) and re-evaluates expr when changes occur. However, pipe!($read(state)).map(expr) can explicitly specify the State to listen to, which may offer better performance in complex scenarios.