Compose widgets
Widgets in rsx!
The same crate also takes Compose's widget names in rsx!: Column,
Text, Button and about thirty more. This path is
implemented and draws through the renderer today. Use it when
you want Compose's own widgets and a design system rather than CSS.
The shape of an app
An application is a function returning Element and a call to
launch. The crate's root prelude brings in the widgets, the hooks and the macros.
use dioxus_compose::prelude::*;
fn app() -> Element {
let mut count = use_signal(|| 0i32);
rsx! {
Column {
fill_max_width: true,
Text { text: format!("count: {}", count()) }
Button {
text: "increment",
on_click: move |_| count += 1,
}
}
}
}
fn main() {
dioxus_compose::launch(app);
}
launch is shorthand for LaunchBuilder::new().launch(app). The
builder also takes a theme and a window description. On desktop and iOS, Rust's
main drives the renderer. On Android and in the browser the platform owns the
loop, and android_main! and web_main! declare the entry point
instead.
LaunchBuilder::new()
.with_theme(Theme::unified(DesignSystem::Material3))
.launch(app);
The widget set
The set is closed on purpose. The renderer interprets exactly these, and a kind it does not
recognise produces a ProtocolError event rather than a crash. Every composable,
with its props and defaults, is in the composable reference.
| Group | Composables |
|---|---|
| Layout | Column, Row, Box, ScrollColumn, ScrollRow, Spacer |
| Text and input | Text, TextField, SelectionContainer |
| Controls | Button, Checkbox, RadioButton, Switch, Slider, Dropdown, DatePicker, TimePicker, ProgressIndicator, Chip, FloatingAction |
| Containers | Card, Surface, Scaffold, TopAppBar, Divider, Separator, Tooltip, Badge |
| Lists | LazyColumn, LazyRow, LazyGrid |
| Navigation | Navigation, NavigationItem, Tabs |
| Overlays | Dialog, Menu, Sheet, and show_message, which is not part of the tree |
| Pictures | Image, Icon, Canvas |
dioxus_compose::Box, not Box
The rsx! expansion in dioxus-core 0.7 refers to an unqualified
Box<T>. A Compose Box in the glob prelude would shadow
std::boxed::Box and break that expansion, so the prelude leaves it out. Use the
qualified path.
Modifiers
In Compose a Modifier applies to any composable, and the same holds here. Every
widget takes the same thirteen attributes: weight, fill_max_width,
fill_max_height, width, height, padding,
padding_role, background, shape_role,
corner_radius, border_width, border_color and
elevation.
rsx! {
Card {
fill_max_width: true,
padding_role: SpaceRole::Md,
shape_role: ShapeRole::Large,
background: Paint::Role(ColorRole::SurfaceContainer),
Text { text: "in a panel" }
}
}
padding_role and shape_role take the design system's answer;
padding and corner_radius take yours. Prefer the role, because
density and corner radius differ per design system and a number pins your layout to one of
them. An attribute you did not write sends nothing at all.
Events and key consumption
Handlers are ordinary closures, called synchronously on the renderer's UI thread. The diff they cause travels back in the same call, so nothing is queued.
| Handler | Payload | On |
|---|---|---|
on_click | () | Button, NavigationItem and other clickable widgets |
on_value_change | String | TextField |
on_submit | String | TextField |
on_focus_lost | () | TextField |
on_key_down | KeyEvent | TextField |
on_change | the control's value | Checkbox, RadioButton, Switch, Slider, Dropdown, DatePicker, TimePicker |
on_dismiss | () | Dialog, Menu, Sheet |
A chat input needs Enter to send and Shift+Enter to break the
line. Dioxus 0.7 handlers return nothing, so a handler marks the event consumed instead, the
same idea as preventDefault() on the web.
TextField {
placeholder: "Write a message",
multiline: true,
on_value_change: move |value| draft.set(value),
on_key_down: move |event: KeyEvent| {
if event.key() == Key::Enter && !event.shift_key() {
let message = draft().trim().to_owned();
if !message.is_empty() {
messages.write().push(message);
draft.set(String::new());
}
event.consume(); // the renderer's onKeyEvent returns true
}
}
}
While an input method is composing, the renderer sends no key events to Rust. An Enter pressed mid-composition commits the syllable; it does not submit. That is why the simple condition above is correct and not merely convenient.
The uncontrolled TextField
TextField has no value property. The edit buffer, the selection and
the composition belong to the renderer, and Rust hears about them through
on_value_change, on_submit and on_focus_lost. Hangul is
assembled from jamo as you type, so a value echoed back from Rust mid-syllable would destroy
it. Keeping the buffer in the renderer makes that failure impossible rather than rare. The
usual way to clear a field from a component is to give it a new identity, as in any Dioxus
app.
Lists and streaming
A Column with a for loop is fine for the hundreds of rows an ordinary
screen holds. Updating one row sends one change and leaves its siblings alone. For long lists,
LazyColumn turns the flow around: the renderer says which range is visible and
the Host builds exactly that range plus a buffer. With 10,000 items and 20 visible rows the
Host builds 28.
rsx! {
LazyColumn {
item_count: messages.len(),
buffer: 4,
key_of: move |index| messages()[index].id.clone(),
item: move |index| rsx! {
Text { text: messages()[index].body.clone() }
},
}
}
Work that blocks (a network stream, a file, a PTY) belongs on a worker thread. A worker writes
to a signal, and the Host asks the renderer for a frame itself; requests coalesce into one
frame. Text that grows a token at a time is sent as appended tails, coalesced per frame, so a
long reply does not resend itself on every token. There is no rsx!-level API for
that yet: Host::append_text takes a protocol node id, so component code streams
by writing to a signal.
Design systems
Widgets name roles, not colours: a colour role, a type role, a shape role, a spacing role. The
renderer resolves them through the design system, so switching to dark mode is one theme
change rather than a change to every node. Seven systems are built in:
Material3, Cupertino, Fluent, Gnome,
Breeze, Deepin and LiquidGlass.
// The same design system everywhere
LaunchBuilder::new().with_theme(Theme::unified(DesignSystem::Material3)).launch(app);
// Follow the host platform; the fallback covers platforms with no look of their own
LaunchBuilder::new().with_theme(Theme::adaptive(DesignSystem::Material3)).launch(app);
Saying nothing follows the host platform, with Material 3 as the fallback, because a desktop
app should look like its desktop without configuration. use_design_system()
reports which system the renderer chose. The design systems themselves are Compose code and
live in compose-rust.
Where state lives
| State | Owner | Why |
|---|---|---|
| Application and domain state | Rust (signals) | It is your program; Dioxus reconciles it into changes. |
| Text being edited, IME composition | Renderer | Composition must never round-trip. |
| Scroll position, focus | Renderer | UI-local, and a frame behind if Rust owned it. |
| Animation progress | Renderer | Runs on the Compose frame clock. |
| Whether a dialog, menu or sheet is open | Renderer | The boolean you pass seeds it; the renderer owns the enter and exit. |
Examples
The native-widget samples are in compose-rust/samples/: a calculator, a
notepad, a chat, a to-do list, a store and others. The notepad, built for release on macOS, is
one 28.76 MB file with no runtime beside it.