WaterUI Tutorial Book
Welcome to the complete guide for building cross-platform applications with WaterUI! This book will take you from a complete beginner to an advanced WaterUI developer, capable of building sophisticated applications that run on desktop, web, mobile, and embedded platforms.
What is WaterUI?
WaterUI is a modern, declarative UI framework for Rust that enables you to build applications using a single codebase for multiple platforms. It combines the safety and performance of Rust with an intuitive, reactive programming model inspired by SwiftUI and React.
Key Features
- π Cross-Platform: Write once, deploy everywhere - desktop, web, mobile, embedded
- π¦ Type-Safe: Leverage Rust's powerful type system for compile-time correctness
- β‘ Reactive: Automatic UI updates when data changes
- π Declarative: Describe what your UI should look like, not how to build it
Prerequisites
Before starting this book, you should have:
- Basic Rust Knowledge: Understanding of ownership, borrowing, traits, and generics
- Programming Experience: Familiarity with basic programming concepts
- Command Line Comfort: Ability to use terminal/command prompt
If you're new to Rust, we recommend reading The Rust Programming Language first.
Contributing
This book is open source! Found a typo, unclear explanation, or want to add content?
- Source Code: Available on GitHub
- Issues: Report problems or suggestions
- Pull Requests: Submit improvements
Installation and Setup
Before we dive into building applications with WaterUI, let's set up a proper development environment. This chapter will guide you through installing Rust, setting up your editor, and creating your first WaterUI project.
Installing Rust
WaterUI requires Rust 1.85 or later with the 2024 edition. The easiest way to install Rust is through rustup.
On macOS, Linux, or WSL
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
On Windows
- Download the installer from rustup.rs
- Run the downloaded
.exe
file - Follow the installation prompts
- Restart your command prompt or PowerShell
Verify Installation
After installation, verify that everything works:
rustc --version
cargo --version
You should see output like:
rustc 1.85.0 (a28077b28 2024-02-28)
cargo 1.85.0 (1e91b550c 2024-02-27)
Note: WaterUI requires Rust 1.85 or later. If you have an older version, update with
rustup update
.
Editor Setup
While you can use any text editor, we recommend VS Code for the best WaterUI development experience.
Visual Studio Code (Recommended)
-
Install VS Code: Download from code.visualstudio.com
-
Install Essential Extensions:
- rust-analyzer: Provides IntelliSense, error checking, and code completion
- CodeLLDB: Debugger for Rust applications
- Better TOML: Syntax highlighting for Cargo.toml files
-
Optional Extensions:
- Error Lens: Inline error messages
- Bracket Pair Colorizer: Colorizes matching brackets
- GitLens: Enhanced Git integration
Other Popular Editors
IntelliJ IDEA / CLion:
- Install the "Rust" plugin
- Excellent for complex projects and debugging
Vim / Neovim:
- Use
rust.vim
for syntax highlighting - Use
coc-rust-analyzer
for LSP support
Emacs:
- Use
rust-mode
for syntax highlighting - Use
lsp-mode
with rust-analyzer
Creating Your First Project
Let's create a new WaterUI project from scratch:
cargo new hello-waterui
cd hello-waterui
This creates a new Rust project with the following structure:
hello-waterui/
βββ Cargo.toml
βββ src/
β βββ main.rs
βββ .gitignore
Adding WaterUI Dependencies
Edit your Cargo.toml
file to include WaterUI:
Filename: Cargo.toml
[package]
name = "hello-waterui"
version = "0.1.0"
edition = "2021"
[dependencies]
waterui = "0.1.0"
# Choose your backend(s)
waterui_gtk4 = "0.1.0" # For desktop applications
# waterui_web = "0.1.0" # For web applications
Tip: You can include multiple backends in the same project to support different platforms.
Platform-Specific Setup
Depending on your target platform, you may need additional system dependencies.
Desktop Development (GTK4)
Ubuntu/Debian:
sudo apt update
sudo apt install libgtk-4-dev build-essential
Fedora/RHEL:
sudo dnf install gtk4-devel gcc
Arch Linux:
sudo pacman -S gtk4 base-devel
macOS:
# Install Homebrew if you haven't already
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install GTK4
brew install gtk4
Windows:
- Install MSYS2
- Open MSYS2 terminal and run:
pacman -S mingw-w64-x86_64-gtk4 mingw-w64-x86_64-toolchain
- Add MSYS2 to your PATH:
C:\msys64\mingw64\bin
Web Development (WebAssembly)
For web development, install additional tools:
# Install wasm-pack for building WebAssembly packages
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# Add WebAssembly target
rustup target add wasm32-unknown-unknown
Hello,world!
Let's create a simple "Hello, World!" application to verify everything works.
Filename: src/main.rs
use waterui::View;
use waterui_gtk4::{Gtk4App, init};
fn home() -> impl View { "Hello, WaterUI! π" }
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize the GTK4 backend
init()?;
// Create and run the application
let app = Gtk4App::new("com.example.hello-waterui");
Ok(app.run(home).into())
}
Building and Running
Build and run your application:
cargo run
If everything is set up correctly, you should see a window with "Hello, WaterUI! π" displayed.
Troubleshooting Common Issues
GTK4 Not Found
Error: Package 'gtk4' not found
Solution: Install GTK4 development libraries for your platform (see Platform-Specific Setup above).
Rust Version Too Old
Error: error: package requires Rust version 1.85
Solution: Update Rust:
rustup update
Windows Build Issues
Error: Various Windows compilation errors
Solutions:
- Ensure you have the Microsoft C++ Build Tools installed
- Use the
x86_64-pc-windows-msvc
toolchain - Consider using WSL2 for a Linux-like environment
Your First WaterUI App
Now that your development environment is set up, let's build your first interactive WaterUI application! We'll create a counter app that demonstrates the core concepts of views, state management, and user interaction.
What We'll Build
Our counter app will feature:
- A display showing the current count
- Buttons to increment and decrement the counter
- A reset button
- Dynamic styling based on the counter value
By the end of this chapter, you'll understand:
- How to create interactive views
- How to manage reactive state
- How to handle user events
- How to compose views together
Setting Up the Project
Create a new project for our counter app:
cargo new counter-app
cd counter-app
Update your Cargo.toml
:
Filename: Cargo.toml
[package]
name = "counter-app"
version = "0.1.0"
edition = "2021"
[dependencies]
waterui = { path = ".." }
waterui_gtk4 = { path = "../backends/gtk4" }
Building the Counter Step by Step
Let's build our counter app incrementally, learning WaterUI concepts along the way.
Step 1: Basic Structure
Start with a simple view structure. Since our initial view doesn't need state, we can use a function:
Filename: src/main.rs
use waterui::View;
use waterui_gtk4::{Gtk4App, init};
pub fn counter() -> impl View {
"Counter App"
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
init()?;
let app = Gtk4App::new("com.example.counter-app");
Ok(app.run(counter).into())
}
Run this to make sure everything works:
cargo run
You should see a window with "Counter App" displayed.
Step 2: Adding Layout
Now let's add some layout structure using stacks:
use waterui::{component::layout::stack::vstack, View};
pub fn counter() -> impl View {
vstack((
"Counter App",
"Count: 0",
))
}
Note:
vstack
creates a vertical stack of views. We'll learn abouthstack
(horizontal) andzstack
(overlay) later.
Step 3: Adding Reactive State
Now comes the exciting part - let's add reactive state! We'll use the s!
macro from nami for reactive computations and the text!
macro for reactive text:
use waterui::{
component::{
layout::stack::{vstack, hstack},
button::button,
},
View,
};
use waterui_text::text;
use waterui::reactive::binding;
pub fn counter() -> impl View {
let count = binding(0);
vstack((
"Counter App",
// Use text! macro for reactive text
text!("Count: {}", count),
hstack((
button("- Decrement").action_with(&count, |count| count.update(|n| n - 1)),
button("+ Increment").action_with(&count, |count| count.update(|n| n + 1)),
)),
))
}
Run this and try clicking the buttons! The counter should update in real-time.
Understanding the Code
Let's break down the key concepts introduced:
Reactive State with binding
let count = Binding::int(0);
This creates a reactive binding with an initial value of 0. When this value changes, any UI elements that depend on it will automatically update.
Reactive Text Display
// β
Use the text! macro for reactive display
text!("Count: {}", count)
- The
text!
macro automatically handles reactivity - The text will update whenever
count
changes
Event Handling
button("- Decrement").action_with(&count, |count| count.update(|n| n - 1))
.action_with()
attaches an event handler with captured stateBinding<T>::update(|v| ...)
updates the value and notifies watchers
Layout with Stacks
vstack((...)) // Vertical stack
hstack((...)) // Horizontal stack
Stacks are the primary layout tools in WaterUI, allowing you to arrange views vertically or horizontally.
Understanding Views
The View system is the heart of WaterUI. Everything you see on screen is a View, and understanding how Views work is crucial for building efficient and maintainable applications. In this chapter, we'll explore the View trait in depth and learn how to create custom components.
What is a View?
A View in WaterUI represents a piece of user interface. It could be as simple as a text label or as complex as an entire application screen. The beauty of the View system is that simple and complex views work exactly the same way.
The View Trait
Every View implements a single trait:
pub trait View: 'static {
fn body(self, env: &Environment) -> impl View;
}
This simple signature enables powerful composition patterns. Let's understand each part:
'static
lifetime: Views can't contain non-static references, ensuring they can be stored and moved safelyself
parameter: Views consume themselves when building their body, enabling zero-cost movesenv: &Environment
: Provides access to shared configuration and dependencies-> impl View
: Returns any type that implements View, enabling flexible composition
Built-in Views
WaterUI provides many built-in Views for common UI elements:
Text Views
// Static text
"Hello, World!"
// Equal to text(name.map(|n| format!("Hello, {n}!")))
text!("Hello, {}!", name)
// Styled text
waterui_text::Text::new("Important!")
.size(24.0)
Control Views
// Button
button("Click me")
.action(|| println!("Clicked!"))
// Text field
let input = waterui::reactive::binding(String::new());
text_field(&input)
.placeholder("Enter text...")
// Toggle switch
let enabled = waterui::reactive::binding(false);
toggle(&enabled)
Layout Views
// Vertical stack
vstack((
"First",
"Second",
"Third",
))
// Horizontal stack
hstack((
button("Cancel"),
button("OK"),
))
// Overlay stack
zstack((
background_view(),
content_view(),
overlay_view(),
))
Creating Custom Views
The real power of WaterUI comes from creating your own custom Views. Let's explore different patterns:
Function Views (Recommended)
// Simpler and cleaner - no View trait needed!
fn welcome_message(name: &str) -> impl View {
vstack((
waterui_text::Text::new("Welcome!").size(24.0),
waterui_text::Text::new(format!("Hello, {}!", name)),
))
.frame(waterui::component::layout::Frame::new().margin(waterui::component::layout::Edge::round(20.0)))
}
// Usage - functions are automatically views!
welcome_message("Alice")
// Can also use closures for lazy initialization
let lazy_view = || welcome_message("Bob");
Struct Views (For Components with State)
Only use the View trait when your component needs to store state, or you prefer direct access to the environment in body
.
// Only needed when the struct holds state
struct CounterWidget {
initial_value: i32,
step: i32,
}
impl View for CounterWidget {
fn body(self, _env: &Environment) -> impl View {
let count = binding(self.initial_value);
vstack((
text!("Count: {}", count),
button("+")
.action_with(&count, move |count| count.update(|n| n + self.step)),
))
}
}
// Usage
CounterWidget {
initial_value: 0,
step: 5,
}
Nami - Reactive System of WaterUI
Reactive state management is the heart of interactive WaterUI applications. When your data changes, the UI automatically updates to reflect those changes. This chapter teaches you how to master WaterUI's reactive system powered by the nami crate.
Understanding the Foundation: Signal Trait
Everything in nami's reactive system implements the Signal
trait. This trait represents any value that can be observed and computed:
pub trait Signal: Clone + 'static {
type Output;
type Guard:WatcherGuard;
// Get the current value
fn get(&self) -> Self::Output;
// Watch for changes (used internally by the UI system)
fn watch(&self, watcher: impl Fn(Context<Self::Output>) + 'static) -> Self::Guard;
}
Key insight: A Signal
represents a reactive value that knows how to:
- Compute its current value (
get()
) - Notify observers when it changes (
watch()
)
Types of Signals
There are several types that implement Signal
, each serving different purposes:
1. Constants - Never Change
use nami::constant;
let fixed_name = constant("WaterUI"); // Never changes
let fixed_number = constant(42); // Never changes
// Even literals implement Signal automatically! (but not all!)
let literal_string = "Hello World"; // Already a Signal!
let literal_number = 100; // Already a Signal!
2. Binding - Mutable Reactive State
Binding<T>
is for mutable reactive state that can be changed and will notify the UI:
use waterui::{binding, Binding};
// Create mutable reactive state
let counter: Binding<i32> = binding(0);
let name: Binding<String> = binding("Alice");
// Set new values (triggers UI updates)
counter.set(42);
name.set("Bob".to_string());
3. Computed Signals - Derived from Other Signals
These are created by transforming other signals using SignalExt methods:
use nami::SignalExt;
let first_name = binding("Alice".to_string());
let last_name = binding("Smith".to_string());
// Create computed signals that update automatically
let full_name = first_name.zip(last_name).map(|(first, last)| {
format!("{} {}", first, last)
});
let name_length = first_name.map(|name| name.len());
β οΈ WARNING: The Dangers of .get()
.get()
is the #1 reactivity killer! Here's why it's dangerous:
let name = binding("Alice".to_string());
let age = binding(25);
// β WRONG: Using .get() breaks reactivity
let broken_message = format!("Hello {}, you are {}", name.get(), age.get());
text(broken_message); // This will NEVER update when name or age change!
// β
CORRECT: Keep reactive chain intact
let reactive_message = s!("Hello {name}, you are {age}");
text(reactive_message); // This updates automatically when name or age change
When you call .get()
:
- You extract a snapshot of the current value
- The reactive connection is permanently broken
- UI will never update even when the original signal changes
- You lose all the benefits of the reactive system
Only use .get()
when you absolutely need the raw value outside reactive contexts (like debugging, logging, or interfacing with non-reactive APIs).
Working with Bindings - Mutable Signals
Now that you understand signals, let's dive into Binding<T>
- the mutable reactive state container:
Basic Operations
let counter = Binding::int(0);
// Set new values (triggers UI updates)
counter.set(42);
// Bindings automatically provide their current value in reactive contexts
// No need to extract values with .get() - just use the binding directly!
Type-Specific Convenience Methods
Nami provides specialized methods for different types to make common operations more ergonomic:
Integer Bindings
let counter = Binding::int(0);
// Convenient arithmetic operations
counter.increment(1); // counter += 1
counter.decrement(2); // counter -= 2
counter.set(10);
Boolean Bindings
let is_enabled = Binding::bool(false);
// Toggle between true/false
is_enabled.toggle();
// Logical NOT operation
let is_disabled = !is_enabled; // Creates a new reactive binding
String Bindings
let text = Binding::container(String::from("Hello"));
// Append text
text.append(" World");
text.clear(); // Empty the string
Vector Bindings
let items = binding(vec![1, 2, 3]);
// Collection operations
items.push(4); // Add to end
items.insert(1, 99); // Insert at index
let last = items.pop(); // Remove and return last
items.clear(); // Remove all elements
// For sortable vectors
let sortable = binding(vec![3, 1, 4, 1, 5]);
sortable.sort(); // Sort in-place
Creating Computed Signals with SignalExt
All signals get powerful transformation methods through the SignalExt
trait:
Basic Transformations
use nami::SignalExt;
let numbers = Binding::container(vec![1, 2, 3, 4, 5]);
// Transform the data
let doubled = numbers.map(|nums| {
nums.iter().map(|&n| n * 2).collect::<Vec<_>>()
});
// Single value transformations
let count = numbers.map(|nums| nums.len());
let sum = numbers.map(|nums| nums.iter().sum::<i32>());
Combining Multiple Signals
let a = binding(10);
let b = binding(20);
// Combine two signals
let sum = a.zip(b).map(|(x, y)| x + y);
let product = a.zip(b).map(|(x, y)| x * y);
let complex = a.zip(b).map(|(x, y)| x * 2 + y / 2);
Performance Optimizations
let expensive_data = binding(vec![1, 2, 3, 4, 5]);
// Cache expensive computations (only recomputes when data changes)
let sum = expensive_data.cached().map(|nums| {
// Expensive operation here
nums.iter().sum::<i32>()
});
The s!
Macro - Reactive String Formatting
The s!
macro from nami is a specialized macro for string formatting with automatic variable capture from reactive signals:
use nami::s;
use waterui::{binding, text};
let name = binding("Alice".to_string());
let age = binding(25);
let score = binding(95.5);
// β
s! macro for reactive string formatting with automatic capture
let greeting = s!("Hello {name}!"); // Captures 'name' automatically
let info = s!("Name: {name}, Age: {age}"); // Multiple variables
let detailed = s!("{name} is {age} years old"); // Clean, readable syntax
// Use with text to display
text(greeting); // Automatically updates when 'name' changes
text(info); // Updates when either 'name' or 'age' changes
// You can also use positional arguments
let positioned = s!("Hello {}, you are {} years old", name, age);
// The s! macro is specifically for string formatting -
// for other reactive computations, use SignalExt methods
Advanced Features
Mutable Access Guard
let data = binding(vec![1, 2, 3]);
// Get mutable access that automatically updates on drop
let mut guard = data.get_mut();
guard.push(4);
guard.sort();
// Updates are sent when guard is dropped
Filtered/Constrained Bindings
let temperature = Binding::int(25);
// Create a binding constrained to a range
let safe_temp = temperature.range(0..=100);
// Custom filters
let even_numbers = binding(0);
let only_even = even_numbers.filter(|&n| n % 2 == 0);
Debounced Bindings
use std::time::Duration;
let search_query = Binding::container(String::new());
// Only update after user stops typing for 300ms
let debounced_search = search_query.debounced(Duration::from_millis(300));
Working with Optional Values
let maybe_name: Binding<Option<String>> = binding(None);
// Provide default when None
let display_name = maybe_name.unwrap_or_else(|| "Anonymous".to_string());
// Transform the inner value if present
let maybe_upper = maybe_name.map(|opt| opt.map(|s| s.to_uppercase()));
The Environment System
The Environment system is WaterUI's approach to dependency injection and configuration management. It provides a type-safe way to pass data, themes, services, and other dependencies through your view hierarchy without explicit parameter passing. Think of it as a context that flows down through your UI tree.
Basic Environment Usage
Storing Values
The most common way to add values to an environment:
use waterui::{Environment, View, ViewExt};
use waterui::component::layout::{Edge, Frame};
#[derive(Debug, Clone)]
struct AppConfig {
api_url: String,
timeout_seconds: u64,
}
#[derive(Debug, Clone)]
struct Theme {
primary_color: waterui::core::Color,
background_color: waterui::core::Color,
}
pub fn entry() -> impl View {
home
.with(AppConfig {
api_url: "https://api.example.com".to_string(),
timeout_seconds: 30,
})
.with(Theme {
primary_color: (0.0, 0.4, 1.0).into(),
background_color: (1.0, 1.0, 1.0).into(),
})
}
pub fn home() -> impl View{
// Your home page
}
Accessing Values in Views
For Struct Views
Views can access environment values in their body
method:
struct ApiStatusView;
impl View for ApiStatusView {
fn body(self, env: &Environment) -> impl View {
// Get configuration from environment
let config = env.get::<AppConfig>()
.expect("AppConfig should be provided");
let theme = env.get::<Theme>()
.expect("Theme should be provided");
vstack((
waterui_text::Text::new(config.api_url.clone()).foreground(theme.primary_color.clone()),
waterui_text::Text::new(format!("Timeout: {}s", config.timeout_seconds)).size(14.0),
))
.background(waterui::background::Background::color(theme.background_color.clone()))
.frame(Frame::new().margin(Edge::round(12.0)))
}
}
For Function Views
You can pass values through the environment at the call site and read them inside struct views as shown above. Function views typically compose other views and donβt receive env
directly.
In action
use waterui::{View};
use waterui::reactive::binding;
use waterui_text::text;
use waterui::component::{layout::stack::vstack, button::button};
#[derive(Debug, Clone)]
pub struct Message(&'static str);
pub fn click_me() -> impl View {
let value = binding(String::new());
vstack((
button("Show environment value").action_with(&value, |value, msg: waterui::core::extract::Use<Message>| {
value.set(msg.0 .0.to_string());
}),
text!("{}", value),
))
.with(Message("I'm Lexo"))
}
Conditional Rendering
Conditional rendering is a fundamental technique for creating dynamic user interfaces that respond to changing application state. WaterUI provides powerful and ergonomic components for conditional rendering through the when
function and related components.
The when
Function
The when
function is the primary tool for conditional rendering in WaterUI. It takes a reactive boolean condition and a closure that returns a view to display when the condition is true.
use waterui::widget::condition::when;
use waterui_text::text;
use waterui::reactive::{binding, s};
let is_logged_in = binding(false);
when(&is_logged_in, || {
text!("Welcome back!")
})
Basic Conditional Rendering
Here's a simple example showing how to conditionally display content:
use waterui::widget::condition::when;
use waterui_text::text;
use waterui::component::{button::button, layout::stack::vstack};
use waterui::reactive::binding;
pub fn login_view() -> impl View {
#[derive(Debug,Clone,Default)]
pub struct Authenticated(Binding<Bool>)
vstack((
use_env(|Authenticated(auth):Authenticated|{
when(&auth, || {
text!("You are logged in!")
}),
when(&!is_authenticated, |Authenticated(auth):Authenticated| {
button("Login").action(move || auth.set(true)})
})
})
)).with(Authenticated::default())
}
Reactive Negation with !
WaterUI's Binding
type implements the Not
trait, which means you can use !
directly on bindings without wrapping them in s!()
. This maintains full reactivity:
use waterui::{when, text};
use nami::binding;
let is_visible = binding(true);
when(!is_visible, || text!("Hidden"));
Complete Conditional Rendering with or
For situations where you need to display one of two views, use the .or()
method:
use waterui::widget::condition::when;
use waterui_text::text;
use waterui::component::button::button;
use waterui::reactive::binding;
let has_data = binding(false);
when(&has_data, || {
text!("Data loaded successfully!")
}).or(|| {
text!("Loading...")
})
Layout Components
WaterUI provides powerful layout components for arranging UI elements. This chapter covers the essential layout tools.
Stack Layouts
VStack - Vertical Arrangement
use waterui::{View, ViewExt};
use waterui::component::layout::stack::vstack;
use waterui::component::layout::{Edge, Frame};
fn vertical_layout() -> impl View {
vstack((
"First item",
"Second item",
"Third item",
))
.frame(Frame::new().margin(Edge::round(20.0)))
}
HStack - Horizontal Arrangement
use waterui::{View, ViewExt};
use waterui::component::layout::stack::hstack;
use waterui::component::layout::spacer::spacer;
use waterui::component::button::button;
use waterui::component::layout::{Edge, Frame};
fn navigation_bar() -> impl View {
hstack((
button("β Back"),
spacer(), // Pushes items apart
"Title",
spacer(),
button("Menu"),
))
.frame(Frame::new().margin(Edge::round(15.0)))
}
ZStack - Overlay Arrangement
// Overlay examples depend on your backend renderer; use zstack to layer views.
Grid Layout
use waterui::View;
use waterui_layout::grid::Grid;
use waterui_layout::{row, Alignment};
fn photo_grid() -> impl View {
Grid::new(
Alignment::Center,
[
row((photo("1.jpg"), photo("2.jpg"), photo("3.jpg"))),
row((photo("4.jpg"), photo("5.jpg"), photo("6.jpg"))),
row((photo("7.jpg"), photo("8.jpg"), photo("9.jpg"))),
],
)
}
Scrolling
// Scrolling helpers exist; see waterui_layout::scroll for details.
Sizing and Constraints
// Use Frame to control size constraints:
// view.frame(Frame::new().width(250.0).max_width(300.0))
Next: Text and Typography
Text and Typography
Text is fundamental to any UI framework. WaterUI provides two distinct approaches for displaying text: labels for static text without styling, and the Text component for reactive text with rich styling capabilities.
Understanding Labels vs Text Components
Labels: Simple Static Text
In WaterUI, several types implement the View
trait directly and are called "labels":
&'static str
- String literalsString
- Owned stringsStr
- WaterUI's optimized string type
Labels are rendered as simple, unstyled text and are perfect for static content:
#![allow(unused)] fn main() { use waterui::View; use waterui::component::layout::stack::vstack; fn label_examples() -> impl View { vstack(( // String literal as label "Simple static text", // String variable as label String::from("Dynamic string as label"), // Multi-line text r#"Multi-line text with line breaks"#, )) } }
Text Component: Reactive and Styleable
The Text
component provides reactive updates and rich styling options:
#![allow(unused)] fn main() { use waterui::View; use waterui::reactive::binding; use waterui::component::layout::stack::vstack; use waterui::component::button::button; use waterui_text::{text, Text}; fn text_component_examples() -> impl View { let count = binding(0); let name = binding("Alice".to_string()); vstack(( // Basic Text component "Styleable text content", // Reactive text with text! macro text!("Count: {}", count), text!("Hello, {}!", name), // Text component with styling Text::new("Styled text").size(20.0), button("Increment") .action({ let count = count.clone(); move |_| count.update(|c| c + 1) }), )) } }
Text Styling with the Text Component
Only the Text
component (created with text()
function or Text::new()
) supports styling. Labels (&str
, String
, Str
) are rendered without styling.
Font Properties
#![allow(unused)] fn main() { use waterui::View; use waterui::component::layout::stack::vstack; use waterui_text::text; fn font_styling_demo() -> impl View { vstack(( // Labels - no styling available "Default label text", // Text components - styling available text("Large text") .size(24.0), text("Small text") .size(12.0), // Bold/weight not yet available text("Styleable text").size(18.0), )) } }
When to Use Labels vs Text Components
Choose the right approach based on your needs:
#![allow(unused)] fn main() { use waterui::View; use waterui::component::layout::stack::vstack; use waterui_text::text; fn choosing_text_type() -> impl View { vstack(( // Use labels for simple, static text "Static heading", "Simple description text", // Use Text component for styled text text("Styled heading").size(20.0), // Use text! macro for reactive content { let count = binding(42); text!("Dynamic count: {}", count) }, )) } }
Reactive Text with the text! Macro
The text!
macro creates reactive Text components that automatically update when underlying data changes:
#![allow(unused)] fn main() { use waterui::View; use waterui::reactive::{binding, s}; use waterui::component::layout::stack::{vstack, hstack}; use waterui::component::button::button; use waterui_text::text; fn reactive_text_demo() -> impl View { let count = binding(0); let name = binding(String::from("Alice")); let temperature = binding(22.5); vstack(( // Reactive formatted text text!("Count: {}", count), text!("Hello, {}!", name), text!("Temperature: {:.1}Β°C", temperature), // Reactive with computed expressions using s! macro text!("Status: {}", s!(if count > 5 { "High" } else { "Low" })), hstack(( button("Increment").action({ let count = count.clone(); move |_| count.update(|c| c + 1) }), button("Reset").action({ let count = count.clone(); move |_| count.set(0) }), )), )) } }
Formatting Best Practices
Always use text!
macro for reactive text, never format!
macro which loses reactivity:
#![allow(unused)] fn main() { use waterui::View; use waterui::component::layout::stack::vstack; use waterui::reactive::binding; use waterui_text::text; fn formatting_best_practices() -> impl View { let user_count = binding(42); let status = binding(String::from("Active")); vstack(( // β CORRECT: Use text! for reactive content text!("Users: {} ({})", user_count, status), // β WRONG: .get() breaks reactivity! // text(format!("Users: {} ({})", user_count.get(), status.get())) // This creates static text that won't update when signals change! // β CORRECT: Use labels for static text "Status Dashboard", // β CORRECT: Use text() for static styleable content text("Styleable heading").size(18.0), )) } }
Advanced Text Component Features
The Text component provides additional capabilities beyond basic labels:
Text Display Options
#![allow(unused)] fn main() { use waterui::View; use waterui::component::layout::stack::vstack; use waterui::reactive::binding; use waterui_text::{text, Text}; fn text_display_demo() -> impl View { vstack(( // Display formatting with different value types Text::display(binding(42)), Text::display(binding(3.14159)), Text::display(binding(true)), // Custom formatting with formatters { let price = binding(29.99); Text::format(price, |value| format!("${:.2}", value)) }, )) } }
// Rich text (spans, bold/italic) is planned but not yet available.
Performance Considerations
Efficient Reactive Updates
#![allow(unused)] fn main() { use waterui::View; use waterui::component::layout::stack::vstack; use waterui::component::button::button; use waterui::reactive::{binding, s}; use waterui_text::text; fn efficient_text_updates() -> impl View { let counter = binding(0); vstack(( // β GOOD: Reactive text with text! macro text!("Counter: {}", counter), // β GOOD: Computed reactive text text!("Status: {}", s!(match counter { 0..=10 => "Low", 11..=50 => "Medium", _ => "High" })), button("Increment").action({ let counter = counter.clone(); move |_| counter.update(|c| c + 1) }), )) } }
Summary
WaterUI's text system provides:
- Labels (
&str
,String
,Str
): Simple, unstyled text for static content - Text Component: Reactive, styleable text with font customization
- text! macro: Reactive formatted text that updates automatically
- Font API: Comprehensive typography control for Text components
Key Guidelines
- Use labels for simple, static text without styling
- Use Text component when you need styling or reactive updates
- Use text! macro for formatted reactive content
- Never use
format!
with reactive values - it breaks reactivity - Choose the simplest approach that meets your needs
Quick Reference
#![allow(unused)] fn main() { use waterui::View; use waterui::component::layout::stack::vstack; use waterui::reactive::binding; use waterui_text::{text, Text}; fn text_reference() -> impl View { let count = binding(42); vstack(( // Label: static, no styling "Simple text", // Text: static, with styling text("Styled text").size(18.0), // Text: reactive, formatted text!("Count: {}", count), // Text: reactive, custom formatting Text::display(count), Text::format(count, |n| format!("#{:03}", n)), )) } }
Next: Forms
Form Controls
WaterUI provides a comprehensive form system that makes creating interactive forms both simple and powerful. The centerpiece of this system is the FormBuilder
derive macro, which automatically generates form UIs from your data structures.
Quick Start with FormBuilder
The easiest way to create forms in WaterUI is using the #[derive(FormBuilder)]
macro:
#![allow(unused)] fn main() { use waterui_form::{FormBuilder, form}; use waterui::reactive::Binding; #[derive(Default, Clone, Debug, FormBuilder)] pub struct LoginForm { /// The user's username pub username: String, /// The user's password pub password: String, /// Whether to remember the user pub remember_me: bool, /// The user's age pub age: i32, } fn login_view() -> impl View { let form_binding = LoginForm::binding(); form(&form_binding) } }
That's it! WaterUI automatically creates appropriate form controls for each field type:
String
β Text fieldbool
β Toggle switchi32
β Number stepperf32
β Slider- And many more...
Type-to-Component Mapping
The FormBuilder
macro automatically maps Rust types to appropriate form components:
Rust Type | Form Component | Description |
---|---|---|
String , &str | TextField | Single-line text input |
bool | Toggle | On/off switch |
i32 , i64 , etc. | Stepper | Numeric input with +/- buttons |
f32 , f64 | Slider | Slider with 0.0-1.0 range |
Color | ColorPicker | Color selection widget |
Complete Example: User Registration Form
Let's build a more comprehensive form:
#![allow(unused)] fn main() { use waterui_form::{FormBuilder, form}; use waterui::reactive::Binding; use waterui::core::Color; use waterui::component::layout::stack::vstack; use waterui_text::{text}; #[derive(Default, Clone, Debug, FormBuilder)] struct RegistrationForm { /// Full name (2-50 characters) full_name: String, /// Email address email: String, /// Age (must be 18+) age: i32, /// Subscribe to newsletter newsletter: bool, /// Account type is_premium: bool, /// Profile completion (0.0 to 1.0) profile_completion: f32, /// Theme color preference theme_color: Color, } fn registration_view() -> impl View { let form_binding = RegistrationForm::binding(); vstack(( "User Registration", form(&form_binding), // Real-time validation feedback validation_feedback(&form_binding), )) } fn validation_feedback(form: &Binding<RegistrationForm>) -> impl View { text!( validate_registration(&form.get()) ) } fn validate_registration(data: &RegistrationForm) -> &'static str { if data.full_name.len() < 2 { "Name too short" } else if data.age < 18 { "Must be 18 or older" } else if !data.email.contains('@') { "Invalid email" } else { "Form is valid β" } } }
Individual Form Controls
You can also use form controls individually:
Text Fields
#![allow(unused)] fn main() { use waterui_form::{TextField, field}; use waterui::reactive::binding; fn text_field_example() -> impl View { let name = binding("".to_string()); field("Name:", &name) } }
Toggle Switches
#![allow(unused)] fn main() { use waterui_form::{Toggle, toggle}; fn toggle_example() -> impl View { let enabled = binding(false); toggle("Enable notifications", &enabled) } }
Number Steppers
#![allow(unused)] fn main() { use waterui_form::{Stepper, stepper}; fn stepper_example() -> impl View { let count = binding(0); stepper(&count) } }
Sliders
#![allow(unused)] fn main() { use waterui_form::Slider; fn slider_example() -> impl View { let volume = binding(0.5); Slider::new(0.0..=1.0, &volume) } }
Advanced Form Patterns
Multi-Step Forms
#![allow(unused)] fn main() { #[derive(Default, Clone, FormBuilder)] struct PersonalInfo { first_name: String, last_name: String, birth_year: i32, } #[derive(Default, Clone, FormBuilder)] struct ContactInfo { email: String, phone: String, preferred_contact: bool, // true = email, false = phone } #[derive(Default, Clone)] struct RegistrationWizard { personal: PersonalInfo, contact: ContactInfo, current_step: usize, } fn registration_wizard() -> impl View { let wizard = binding(RegistrationWizard::default()); vstack(( text!(format!("Step {} of 2", wizard.current_step.get() + 1)), match wizard.current_step.get() { 0 => vstack(( "Personal Information", form(&wizard.personal), )), 1 => vstack(( "Contact Information", form(&wizard.contact), )), _ => "Registration Complete!", }, navigation_buttons(&wizard), )) } }
Custom Form Layouts
For complete control over form layout, implement FormBuilder
manually:
#![allow(unused)] fn main() { use waterui_form::{FormBuilder, TextField, Toggle}; use waterui::{ core::Binding, component::layout::stack::{vstack, hstack}, }; struct CustomForm { title: String, active: bool, } impl FormBuilder for CustomForm { type View = VStack; fn view(binding: &Binding<Self>) -> Self::View { vstack(( hstack(( "Title:", TextField::new(&binding.title), )), hstack(( "Active:", Toggle::new(&binding.active), )), )) } } }
Secure Fields
For sensitive data like passwords:
#![allow(unused)] fn main() { use waterui_form::{SecureField, secure}; fn password_form() -> impl View { let password = binding(String::new()); let confirm_password = binding(String::new()); vstack(( secure("Password:", &password), secure("Confirm Password:", &confirm_password), password_validation(&password, &confirm_password), )) } fn password_validation(pwd: &Binding<String>, confirm: &Binding<String>) -> impl View { text!( if pwd.get() == confirm.get() && !pwd.get().is_empty() { "Passwords match β" } else { "Passwords don't match" } ) } }
Form Validation Best Practices
Real-time Validation
#![allow(unused)] fn main() { #[derive(Default, Clone, FormBuilder)] struct ValidatedForm { email: String, password: String, age: i32, } fn validated_form_view() -> impl View { let form = ValidatedForm::binding(); vstack(( form(&form), // Email validation text!( if form.email.get().contains('@') && form.email.get().contains('.') { "β Valid email" } else { "β Please enter a valid email" } ), // Password validation text!( if form.password.get().len() >= 8 { "β Password is strong enough" } else { "β Password must be at least 8 characters" } ), // Age validation text!( if form.age.get() >= 18 { "β Age requirement met" } else { "β Must be 18 or older" } ), // Submit button - only enabled when form is valid button("Submit") .disabled(s!( !form.email.get().contains('@') || form.password.get().len() < 8 || form.age.get() < 18 )) .action(|| { // Handle form submission println!("Form submitted!"); }), )) } }
Integration with State Management
Forms integrate seamlessly with WaterUI's reactive state system:
#![allow(unused)] fn main() { use nami::s; #[derive(Default, Clone, FormBuilder)] struct UserSettings { name: String, theme: String, notifications: bool, } fn settings_panel() -> impl View { let settings = UserSettings::binding(); // Computed values based on form state let has_changes = s!( settings.name.get() != "Default Name" || settings.theme.get() != "Light" || settings.notifications.get() ); let settings_summary = s!( format!("User: {} | Theme: {} | Notifications: {}", settings.name.get(), settings.theme.get(), if settings.notifications.get() { "On" } else { "Off" } ) ); vstack(( "Settings", form(&settings), // Live preview "Preview:", text!(settings_summary), // Save button button("Save Changes") .disabled(s!(!has_changes)) .action({ let settings = settings.clone(); move |_| { save_settings(&settings.get()); } }), )) } fn save_settings(settings: &UserSettings) { println!("Saving settings: {settings:?}"); // Save to database, file, etc. } }
[WIP]
Media Components
WaterUI provides a comprehensive set of media components for displaying images, videos, and Live Photos in your applications. These components are designed to be reactive, configurable, and type-safe.
Overview
The media components in WaterUI include:
- Photo: Display static images with customizable placeholders
- Video: Video sources and players with reactive controls
- VideoPlayer: Advanced video playback with volume control
- LivePhoto: Apple Live Photo display with image and video components
- MediaPicker: Platform-native media selection (when available)
- Media: Unified enum for different media types
Photo Component
The Photo
component displays images from URLs with support for placeholder views while loading.
Basic Usage
#![allow(unused)] fn main() { use waterui::View; use waterui_media::Photo; use waterui_text::text; pub fn photo_example() -> impl View { Photo::new("https://example.com/image.jpg") .placeholder(text!("Loading image...")) } }
Local Images
#![allow(unused)] fn main() { pub fn local_photo() -> impl View { Photo::new("assets/photo.jpg") .placeholder( waterui_layout::stack::vstack((text!("π·"), text!("Loading..."))) ) } }
Responsive Placeholders
You can create sophisticated placeholder views that match your app's design:
#![allow(unused)] fn main() { use waterui::{View, ViewExt, background::Background}; use waterui_layout::stack::{vstack, zstack}; use waterui_text::text; use waterui::component::layout::{Edge, Frame}; pub fn styled_photo() -> impl View { Photo::new("https://example.com/large-image.jpg") .placeholder( zstack(( // Background color layer ().background(Background::color((0.1, 0.1, 0.1))), // Loading indicator vstack(( text!("πΈ").size(48.0), text!("Loading image...").foreground((0.6, 0.6, 0.6)), )), )) .frame(Frame::new().width(300.0).height(200.0)) ) } }
Video Components
WaterUI provides both Video
sources and VideoPlayer
components for video playback.
Basic Video
#![allow(unused)] fn main() { use waterui::View; use waterui_media::Video; pub fn basic_video() -> impl View { Video::new("https://example.com/video.mp4") } }
When a Video
is used as a view, it automatically creates a VideoPlayer
.
Video Player with Controls
For more control over video playback, use VideoPlayer
directly:
#![allow(unused)] fn main() { use waterui::{View, ViewExt}; use waterui_media::{Video, VideoPlayer}; use waterui_text::text; use waterui::reactive::binding; use waterui_layout::stack::{vstack, hstack}; pub fn video_with_controls() -> impl View { let video = Video::new("assets/demo.mp4"); let muted = binding(false); vstack(( VideoPlayer::new(video).muted(&muted), // Mute toggle button waterui::component::button::button("Toggle Mute") .action_with(&muted, |muted| muted.update(|m| !m)) )) } }
Volume Control System
The video player uses a unique volume system where:
- Positive values (> 0): Audible volume level
- Negative values (< 0): Muted state that preserves the original volume level
- When unmuting, the absolute value is restored
#![allow(unused)] fn main() { use waterui::{View}; use waterui_media::{Video, VideoPlayer}; use waterui::reactive::binding; use waterui_layout::stack::{vstack, hstack}; use waterui_text::text; pub fn volume_control_example() -> impl View { let video = Video::new("video.mp4"); let muted = binding(false); let volume = binding(0.7); // 70% volume vstack(( VideoPlayer::new(video).muted(&muted), // Volume controls hstack(( waterui::component::button::button("π") .action_with(&muted, |muted| muted.set(true)), waterui::component::button::button("π") .action_with(&muted, |muted| muted.set(false)), // Volume internally stored as -0.7 when muted, +0.7 when unmuted )) )) } }
Live Photos
Live Photos combine a still image with a short video, similar to Apple's Live Photos feature.
Basic Live Photo
#![allow(unused)] fn main() { use waterui::View; use waterui_media::{LivePhoto, LivePhotoSource}; pub fn live_photo_example() -> impl View { let source = LivePhotoSource::new( "photo.jpg".into(), "photo_video.mov".into() ); LivePhoto::new(source) } }
Reactive Live Photo
#![allow(unused)] fn main() { use waterui::{View}; use waterui_media::{LivePhoto, LivePhotoSource}; use waterui_text::text; use waterui_layout::stack::{vstack, hstack}; use waterui::reactive::binding; pub fn reactive_live_photo() -> impl View { let photo_index = binding(0); let live_source = s!({ LivePhotoSource::new( format!("photo_{}.jpg", photo_index).into(), format!("photo_{}.mov", photo_index).into() ) }); vstack(( LivePhoto::new(live_source), hstack(( waterui::component::button::button("Previous") .action_with(&photo_index, |photo_index| { photo_index.update(|idx| if idx > 0 { idx - 1 } else { idx }) }), waterui::component::button::button("Next") .action_with(&photo_index, |photo_index| photo_index.update(|idx| idx + 1)), )) )) } }
Unified Media Type
The Media
enum provides a unified way to handle different types of media content:
#![allow(unused)] fn main() { use waterui::View; use waterui_media::{Media, LivePhotoSource}; use waterui_layout::stack::vstack; pub fn media_gallery() -> impl View { let media_items = vec![ Media::Image("photo1.jpg".into()), Media::Video("video1.mp4".into()), Media::LivePhoto(LivePhotoSource::new( "live1.jpg".into(), "live1.mov".into() )), ]; // Each Media automatically renders as the appropriate component vstack( media_items .into_iter() .map(|media| media.frame(waterui::component::layout::Frame::new().width(300.0).height(200.0))) .collect::<Vec<_>>() ) } }
Media Picker
The MediaPicker
component provides platform-native media selection when available:
#![allow(unused)] fn main() { use waterui::View; use waterui_media::{MediaPicker, MediaFilter}; use waterui::widget::condition::when; use waterui_layout::stack::vstack; use waterui_text::text; use waterui::reactive::binding; pub fn photo_picker_example() -> impl View { let selected_media = binding(None); vstack(( waterui::component::button::button("Select Photo") .action(|| { /* open picker */ }), // Display selected media when(waterui::reactive::s!(selected_media.is_some()), || { // Simplest approach: show placeholder text when none is selected text!("Media selected") }) .or(|| text!("No media selected")) )) } }
Media Filters
You can filter the types of media shown in the picker:
#![allow(unused)] fn main() { use waterui::media::MediaFilter; // Only show images let image_filter = MediaFilter::Image; // Only show videos let video_filter = MediaFilter::Video; // Show images and live photos let mixed_filter = MediaFilter::Any(vec![ MediaFilter::Image, MediaFilter::LivePhoto ]); // Show everything except videos let no_videos = MediaFilter::Not(vec![MediaFilter::Video]); }
Practical Example: Media Gallery
Here's a complete example of a media gallery with different types of content:
#![allow(unused)] fn main() { use waterui::{View, ViewExt}; use waterui_media::*; use waterui_layout::stack::{vstack, hstack}; use waterui_text::text; use waterui::reactive::binding; use waterui::component::layout::{Edge, Frame}; pub fn media_gallery_app() -> impl View { let selected_index = binding(0); let media_items = vec![ Media::Image("gallery/sunset.jpg".into()), Media::Video("gallery/timelapse.mp4".into()), Media::LivePhoto(LivePhotoSource::new( "gallery/action.jpg".into(), "gallery/action.mov".into() )), Media::Image("gallery/landscape.jpg".into()), ]; let current_media = s!(media_items.get(selected_index).cloned()); vstack(( // Header text!("Media Gallery") .size(24.0) .frame(Frame::new().margin(Edge::round(16.0))), // Main media display waterui::component::Dynamic::watch(current_media, |media| { if let Some(media) = media { media .frame(Frame::new().width(600.0).height(400.0)) .frame(Frame::new().margin(Edge::round(16.0))) .anyview() } else { text!("No media available").anyview() } }), // Navigation controls hstack(( waterui::component::button::button("β Previous") .action_with(&selected_index, |selected_index| { selected_index.update(|idx| if idx > 0 { idx - 1 } else { idx }) }), text!("{} / {}", s!(selected_index + 1), media_items.len() ) .frame(Frame::new().margin(Edge::horizontal(16.0))), waterui::component::button::button("Next βΆ") .action_with(&selected_index, |selected_index| { selected_index.update(|idx| if idx + 1 < media_items.len() { idx + 1 } else { idx }) }) )) .frame(Frame::new().margin(Edge::round(16.0))), // Thumbnail strip hstack( media_items .iter() .enumerate() .map(|(index, media)| { waterui::component::button::button( media.clone().frame(Frame::new().width(80.0).height(60.0)) ) .action_with(&selected_index, move |selected_index| selected_index.set(index)) }) .collect::<Vec<_>>() ) .frame(Frame::new().margin(Edge::round(16.0))) )) } }
Best Practices
Loading States
Always provide meaningful placeholder views for images:
#![allow(unused)] fn main() { // Good: Informative placeholder Photo::new("large-image.jpg") .placeholder( vstack(( text!("πΈ"), text!("Loading high-resolution image...") )) ) // Avoid: No placeholder (jarring loading experience) Photo::new("large-image.jpg") }
Performance
For large media galleries, consider lazy loading:
#![allow(unused)] fn main() { use waterui::View; use waterui_text::text; use waterui_layout::stack::vstack; pub fn efficient_gallery() -> impl View { let visible_items = waterui::reactive::Computed::new({ // Only load visible items based on scroll position // Placeholder for example Vec::<Media>::new() }); // Lazy stacks are experimental; use a regular vstack for now vstack( visible_items.get().into_iter() .map(|media| media.frame(Frame::new().width(300.0).height(200.0))) .collect::<Vec<_>>() ) } }
Responsive Design
Make media components responsive to different screen sizes:
#![allow(unused)] fn main() { // Example placeholder: responsive sizing requires reading screen metrics from the backend }
Animation and Transitions
WaterUI provides a revolutionary reactive animation system that leverages nami's metadata capabilities to create smooth, performant animations with zero boilerplate. Unlike traditional animation systems that require explicit animation setup, WaterUI's animations work through reactive value metadata that automatically flows to the renderer.
The Reactive Animation System
At its core, WaterUI's animation system is built on metadata propagation through the reactive system. When you use .animated()
on a reactive value, you're attaching animation metadata that travels with every value change. The upstream renderer receives this metadata and handles all the complex interpolation, timing, and rendering automatically.
βββββββββββββββββββ .animated() ββββββββββββββββββββββββββββ
β Reactive Value β ββββββββββββββββββ> β Value + Animation β
β β β Metadata Wrapper β
β s!(0.0) β β β
βββββββββββββββββββ βββββββββββββββ¬βββββββββββββ
β
.set(100.0) β
β β
βΌ β
βββββββββββββββββββ βββββββββββββββββββ β
β nami Context β ββ> β Renderer β <ββββββββββ
β β β β
β value: 100.0 β β - Sees new β
β metadata: β β target value β
β Animation:: β β - Gets animationβ
β ease_in_out β β config β
β (250ms) β β - Creates β
βββββββββββββββββββ β interpolator β
β - Animates β
β 0.0 β 100.0 β
βββββββββββββββββββ
The Secret: Metadata Flow
The fundamental innovation is that animation metadata flows through nami's reactive system alongside value changes:
use waterui::*;
use nami::s;
fn reactive_animation_demo() -> impl View {
let position = s!(0.0);
// The .animated() method attaches Animation metadata to the binding
let animated_position = position.animated();
vstack((
rectangle()
.width(50.0)
.height(50.0)
.offset_x(animated_position), // Renderer receives value + animation metadata
.fill(color::BLUE),
button("Move")
.on_press(move || {
// When we change the value, the metadata flows to the renderer
position.set(200.0); // Renderer sees: new_value=200.0 + Animation::default()
})
))
}
Using .animated()
- The Core Method
The .animated()
method is your primary interface to WaterUI's animation system. It creates a binding wrapper that attaches default animation metadata:
use waterui::*;
use nami::s;
fn animated_examples() -> impl View {
let opacity = s!(1.0);
let scale = s!(1.0);
let color = s!(color::RED);
// Each .animated() call attaches metadata that the renderer will use
let animated_opacity = opacity.animated(); // Default ease-in-out animation
let animated_scale = scale.animated(); // Default ease-in-out animation
let animated_color = color.animated(); // Default ease-in-out animation
rectangle()
.width(100.0)
.height(100.0)
.opacity(animated_opacity)
.scale(animated_scale)
.fill(animated_color)
.on_tap(move || {
// All three changes will be animated by the renderer
opacity.set(0.5);
scale.set(1.2);
color.set(color::BLUE);
})
}
Practical Examples (Current API)
While some sections below use conceptual shape APIs (marked as rust,ignore), you can use .animated()
today with existing view modifiers like .frame(...)
, .foreground(...)
, and normal components. Here are two minimal, working examples:
#![allow(unused)] fn main() { use waterui::{View, ViewExt, Color}; use waterui::reactive::{binding}; use nami::SignalExt; // map/computed helpers use waterui::component::layout::stack::{vstack, hstack}; use waterui::component::layout::{Edge, Frame}; use waterui::component::button::button; use waterui_text::text; // Animate padding by mapping an animated number to Frame margins fn animated_padding_demo() -> impl View { let pad = binding(8.0); let animated_pad = pad.animated(); let frame = animated_pad.map(|p| Frame::new().margin(Edge::round(p))).computed(); vstack(( text!("Animated padding"), hstack(( button("Less").action_with(&pad, |pad| pad.update(|p| (p - 4.0).max(0.0))), button("More").action_with(&pad, |pad| pad.update(|p| p + 4.0)), )), )) .frame(frame) } // Animate text alpha by mapping an animated number to a Color fn animated_color_demo() -> impl View { let alpha = binding(1.0); let animated_alpha = alpha.animated(); let color = animated_alpha .map(|a| Color::from_rgba(0.0, 0.5, 1.0, a)) .computed(); vstack(( text!("Fade me").foreground(color.clone()), hstack(( button("Hide").action_with(&alpha, |a| a.set(0.0)), button("Show").action_with(&alpha, |a| a.set(1.0)), )), )) } }
Custom Animation Configurations
While .animated()
provides sensible defaults, you can customize animations using the .with_animation()
method:
Animation Types Available
WaterUI supports several animation curves, each creating different visual effects:
use waterui::*;
use nami::s;
use std::time::Duration;
fn animation_types_demo() -> impl View {
let position = s!(0.0);
vstack((
// Default ease-in-out (250ms) - most common
rectangle()
.width(40.0).height(40.0)
.offset_x(position.animated()) // Uses default animation
.fill(color::BLUE),
// Linear - constant speed
rectangle()
.width(40.0).height(40.0)
.offset_x(position.with_animation(Animation::linear(Duration::from_millis(500))))
.fill(color::GREEN),
// Ease-in - starts slow, accelerates
rectangle()
.width(40.0).height(40.0)
.offset_x(position.with_animation(Animation::ease_in(Duration::from_millis(400))))
.fill(color::RED),
// Ease-out - starts fast, decelerates
rectangle()
.width(40.0).height(40.0)
.offset_x(position.with_animation(Animation::ease_out(Duration::from_millis(400))))
.fill(color::ORANGE),
// Spring - physics-based bouncing
rectangle()
.width(40.0).height(40.0)
.offset_x(position.with_animation(Animation::spring(300.0, 20.0)))
.fill(color::PURPLE),
button("Move All")
.on_press(move || {
position.set(if position.get() > 100.0 { 0.0 } else { 200.0 });
})
))
.spacing(20.0)
}
The Metadata Attachment Process
Here's what happens under the hood when you use .animated()
:
use waterui::*;
use nami::s;
fn metadata_flow_explanation() -> impl View {
let opacity = s!(0.5);
// Step 1: Raw reactive value
let raw_value = opacity.clone(); // Just the binding
// Step 2: Attach animation metadata
let animated_value = opacity.animated(); // Now wrapped with Animation metadata
// Step 3: When used in a view property, both value + metadata flow to renderer
text!("Fade me")
.opacity(animated_value) // Renderer receives: value=0.5 + metadata=Animation::default()
.on_tap(move || {
raw_value.set(1.0); // Change triggers: value=1.0 + metadata flows through
})
}
Understanding Metadata Propagation
Animation metadata propagates through reactive computations automatically:
use waterui::*;
use nami::s;
fn metadata_propagation() -> impl View {
let count = s!(0);
let animated_count = count.animated();
// Metadata flows through map operations
let opacity = animated_count.map(|n| (n as f32 / 10.0).clamp(0.0, 1.0));
let scale = animated_count.map(|n| 1.0 + (n as f32 * 0.1));
// Both opacity and scale inherit the animation metadata from count
rectangle()
.width(100.0).height(100.0)
.opacity(opacity) // Animated because count is animated
.scale(scale) // Also animated because count is animated
.fill(color::BLUE)
.on_tap(move || {
count.set((count.get() + 1) % 11); // 0-10 range
})
}
How Renderers Consume Animation Metadata
When a renderer (like GTK4 or Web backend) receives a reactive value change with animation metadata, it automatically handles the interpolation process:
The Rendering Pipeline
User Code nami System Renderer UI Output
βββββββββ βββββββββββ ββββββββ βββββββββ
position.set(100.0) ββ> Context { ββββββ> βββββββββββββββββββ βββββββββββββββ
value: 100.0 β Current: 0.0 β β β β
metadata: β Target: 100.0 β β β β
Animation:: β β β β β
spring(300,20) β Creates Spring β β β β
} β Interpolator β β β β
βββββββββββββββββββ βββββββββββββββ
β frame 1 (t=0ms)
β
Timer triggers 60fps
β
βββββββββββββββββββ βββββββββββββββ
β Spring calc: β β β β
β t=0.1 β 15.0 β β β β
β Apply to UI β β β β
βββββββββββββββββββ βββββββββββββββ
β frame 6 (t=100ms)
β
Continue until
target reached
β
βββββββββββββββββββ βββββββββββββββ
β Final position β β ββ
β reached: 100.0 β β ββ
β Remove timer β β ββ
βββββββββββββββββββ βββββββββββββββ
final position
Renderer Implementation Details
Here's a simplified view of how renderers handle animation metadata:
// Pseudocode showing renderer animation handling
impl Renderer {
fn handle_property_change<T>(&mut self, context: Context<T>)
where
T: Interpolatable + Clone
{
let Context { value: new_value, metadata } = context;
// Check if animation metadata is present
if let Some(animation) = metadata.get::<Animation>() {
// Create interpolator based on animation type
let interpolator = match animation {
Animation::Linear(duration) => LinearInterpolator::new(duration),
Animation::EaseInOut(duration) => EaseInterpolator::new(duration),
Animation::Spring { stiffness, damping } => SpringInterpolator::new(stiffness, damping),
Animation::Default => EaseInterpolator::new(Duration::from_millis(250)),
};
// Start animation from current_value to new_value
self.start_animation(interpolator, self.current_value, new_value);
} else {
// No animation metadata - update immediately
self.current_value = new_value;
self.render_immediately();
}
}
}
Animation Interpolation Types
Different animation types use different mathematical functions for interpolation:
// How different animation curves work internally
fn interpolate_value(animation: &Animation, progress: f32) -> f32 {
match animation {
Animation::Linear(_) => progress, // t
Animation::EaseIn(_) => progress * progress, // tΒ²
Animation::EaseOut(_) => 1.0 - (1.0 - progress).powi(2), // 1-(1-t)Β²
Animation::EaseInOut(_) => {
if progress < 0.5 {
2.0 * progress * progress // 2tΒ² for first half
} else {
1.0 - (-2.0 * progress + 2.0).powi(2) / 2.0 // Smooth transition for second half
}
},
Animation::Spring { stiffness, damping } => {
// Complex physics simulation using spring equations
spring_interpolation(progress, stiffness, damping)
},
}
}
Zero-Cost Abstractions
The beauty of WaterUI's animation system is that it provides zero-cost abstractions:
- No Animation Metadata: If a value has no animation metadata, it updates immediately with no overhead
- With Animation Metadata: The renderer automatically creates the appropriate interpolator
- Type Safety: Animation metadata is type-erased but type-safe through nami's metadata system
- Composability: Multiple animated properties work independently without interference
use waterui::*;
use nami::s;
fn zero_cost_demo() -> impl View {
let animated_opacity = s!(1.0).animated(); // Gets interpolator
let instant_opacity = s!(1.0); // Updates immediately
let animated_position = s!(0.0).with_animation( // Gets custom interpolator
Animation::spring(200.0, 15.0)
);
rectangle()
.opacity(animated_opacity) // Renderer creates opacity interpolator
.width(instant_opacity) // Renderer updates width immediately
.offset_x(animated_position) // Renderer creates position interpolator
.height(100.0) // Static value - no computation
.fill(color::BLUE)
}
Complete Animation Flow Example
Here's a practical example showing the complete metadata flow in a button component:
use waterui::*;
use nami::s;
fn animated_button_demo() -> impl View {
let is_pressed = s!(false);
let is_hovered = s!(false);
// Create animated reactive values
let scale = is_pressed.map(|pressed| if *pressed { 0.95 } else { 1.0 }).animated();
let bg_color = is_hovered.map(|hovered| {
if *hovered { color::BLUE } else { color::GRAY }
}).with_animation(Animation::ease_out(Duration::from_millis(150)));
/*
Metadata Flow Visualization:
User hovers ββ> is_hovered.set(true) ββ> map() ββ> bg_color gets new value + Animation metadata
β
βΌ
Renderer receives:
Context {
value: BLUE,
metadata: Animation::ease_out(150ms)
}
β
βΌ
Creates color interpolator GRAY β BLUE over 150ms
*/
rectangle()
.width(120.0)
.height(40.0)
.scale(scale) // Gets scale animation metadata
.fill(bg_color) // Gets color animation metadata
.corner_radius(8.0)
.overlay(
text!("Animated Button")
.color(color::WHITE)
)
.on_press(move || {
is_pressed.set(true);
// Animation automatically triggered by metadata flow
})
.on_release(move || {
is_pressed.set(false);
})
.on_hover(move || {
is_hovered.set(true);
})
.on_hover_end(move || {
is_hovered.set(false);
})
}
Metadata Flow Through Complex Computations
Animation metadata propagates through reactive computations, creating sophisticated animations with simple code:
βββββββββββββββββββ
β Base Signal β
β count.animated()β
β β
βββββββ¬ββββββββββββ
β Animation metadata attached
β
βββββββββββββββββββββββΌββββββββββββββββββββββ
β β β
βΌ βΌ βΌ
βββββββββββ βββββββββββ βββββββββββ
β .map() β β .map() β β .map() β
β opacity β β scale β β rotationβ
βββββββββββ βββββββββββ βββββββββββ
β β β
β metadata flows β metadata flows β metadata flows
β β β
βΌ βΌ βΌ
βββββββββββ βββββββββββ βββββββββββ
βRenderer β βRenderer β βRenderer β
βcreates β βcreates β βcreates β
βopacity β βscale β βrotation β
βanimator β βanimator β βanimator β
βββββββββββ βββββββββββ βββββββββββ
fn complex_metadata_flow() -> impl View {
let count = s!(0).animated(); // Single source of animation metadata
// All these computed values inherit the animation metadata from count
let opacity = count.map(|n| (*n as f32 / 10.0).clamp(0.0, 1.0));
let scale = count.map(|n| 1.0 + (*n as f32 * 0.05));
let rotation = count.map(|n| *n as f32 * 36.0); // 36Β° per increment
let bg_color = count.map(|n| {
let hue = (*n as f32 * 30.0) % 360.0;
Color::from_hsv(hue, 1.0, 1.0)
});
rectangle()
.width(100.0).height(100.0)
.opacity(opacity) // Animated - renderer gets metadata from count
.scale(scale) // Animated - renderer gets metadata from count
.rotation(rotation) // Animated - renderer gets metadata from count
.fill(bg_color) // Animated - renderer gets metadata from count
.on_tap(move || {
count.set((count.get() + 1) % 11); // Single change animates everything
})
}
Animatable Properties
WaterUI can animate most visual properties smoothly:
Transform Properties
Transform properties are ideal for animations as they're GPU-accelerated and don't trigger layout recalculations:
use waterui::*;
use nami::s;
fn transform_animations() -> impl View {
let offset_x = s!(0.0);
let offset_y = s!(0.0);
let scale = s!(1.0);
let rotation = s!(0.0);
// Apply animations to transform properties using reactive values
rectangle()
.width(100.0).height(100.0)
.offset_x(offset_x.animated()) // Spring animation for smooth movement
.offset_y(offset_y.with_animation(Animation::ease_out(Duration::from_millis(400))))
.scale(scale.with_animation(Animation::spring(200.0, 15.0))) // Bouncy scaling
.rotation(rotation.animated()) // Default ease for rotation
.fill(color::RED)
.overlay(
vstack((
hstack((
button("β")
.on_press({
let x = offset_x.clone();
move || x.set(if x.get() > 50.0 { 0.0 } else { 100.0 })
}),
button("β")
.on_press({
let y = offset_y.clone();
move || y.set(if y.get() > 30.0 { 0.0 } else { 60.0 })
}),
button("β‘")
.on_press({
let s = scale.clone();
move || s.set(if s.get() > 1.1 { 1.0 } else { 1.5 })
}),
button("β»")
.on_press(move || {
rotation.update(|r| r + 90.0);
}),
))
.spacing(5.0),
button("Reset All")
.on_press({
let x = offset_x.clone();
let y = offset_y.clone();
let s = scale.clone();
let r = rotation.clone();
move || {
x.set(0.0);
y.set(0.0);
s.set(1.0);
r.set(0.0);
}
})
))
.spacing(10.0)
.frame(waterui::component::layout::Frame::new().margin(waterui::component::layout::Edge::round(10.0)))
)
}
Color Animations
Color transitions create smooth theme changes and visual feedback:
use waterui::*;
use nami::s;
fn color_animations() -> impl View {
let theme_index = s!(0);
// Define color themes
let themes = [
(color::BLUE, color::WHITE, "Ocean"),
(color::RED, color::YELLOW, "Sunset"),
(color::GREEN, color::BLACK, "Forest"),
(color::PURPLE, color::WHITE, "Royal"),
];
// Map theme index to colors with animation
let bg_color = theme_index.map(|&idx| themes[idx % 4].0).animated();
let text_color = theme_index.map(|&idx| themes[idx % 4].1).with_animation(
Animation::ease_in_out(Duration::from_millis(300))
);
let theme_name = theme_index.map(|&idx| themes[idx % 4].2);
vstack((
// Animated color display
rectangle()
.width(200.0).height(100.0)
.fill(bg_color)
.corner_radius(12.0)
.overlay(
text!(theme_name)
.size(24.0)
.color(text_color)
),
// Theme selection buttons
hstack((
button("β Prev")
.on_press({
let idx = theme_index.clone();
move || idx.update(|i| (*i + 3) % 4) // Wrap around backwards
}),
button("Next β")
.on_press(move || {
theme_index.update(|i| (*i + 1) % 4) // Cycle forward
}),
))
.spacing(20.0),
// Random theme button
button("π² Random Theme")
.on_press({
let idx = theme_index.clone();
move || {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
idx.set((hasher.finish() as usize) % 4);
}
})
))
.spacing(30.0)
}
Size and Layout Animations
Size animations can create expand/collapse effects and responsive layouts:
use waterui::*;
use nami::s;
fn size_animations() -> impl View {
let is_expanded = s!(false);
// Map boolean state to size values with different animations
let width = is_expanded.map(|&expanded| if expanded { 250.0 } else { 100.0 })
.with_animation(Animation::spring(180.0, 12.0)); // Bouncy width
let height = is_expanded.map(|&expanded| if expanded { 180.0 } else { 100.0 })
.with_animation(Animation::ease_out(Duration::from_millis(400))); // Smooth height
let corner_radius = is_expanded.map(|&expanded| if expanded { 20.0 } else { 8.0 }).animated();
let padding = is_expanded.map(|&expanded| if expanded { 20.0 } else { 10.0 }).animated();
vstack((
// Animated container
rectangle()
.width(width)
.height(height)
.corner_radius(corner_radius)
.fill(color::PURPLE)
.overlay(
vstack((
text!("π¦")
.size(32.0),
text!(is_expanded.map(|&exp| if exp { "Expanded!" } else { "Compact" }))
.color(color::WHITE)
.size(16.0),
))
.spacing(8.0)
.padding(padding)
),
// Control buttons
hstack((
button(is_expanded.map(|&exp| if exp { "π¦ Collapse" } else { "π Expand" }))
.on_press({
let expanded = is_expanded.clone();
move || expanded.update(|e| !e)
}),
button("π Quick Toggle")
.on_press(move || {
let exp = is_expanded.clone();
// Rapid toggle demonstration
tokio::spawn(async move {
for _ in 0..3 {
exp.update(|e| !e);
tokio::time::sleep(Duration::from_millis(300)).await;
}
});
})
))
.spacing(15.0),
// Status indicator
text!(is_expanded.map(|&exp| {
format!("State: {} | Size: {}x{}",
if exp { "EXPANDED" } else { "COMPACT" },
if exp { "250" } else { "100" },
if exp { "180" } else { "100" }
)
}))
.size(12.0)
.color(color::GRAY)
))
.spacing(25.0)
}
Complex Animation Sequences
For more complex animations, you can chain and combine multiple animations:
Sequential Animations
use std::time::Duration;
use tokio::time::sleep;
fn sequential_animation() -> impl View {
let position = s!(Offset::zero());
let scale = s!(1.0);
let opacity = s!(1.0);
let animated_circle = circle()
.size(50.0)
.offset(position.clone())
.scale(scale.clone())
.opacity(opacity.clone())
.animation(Animation::ease_in_out(Duration::from_millis(500)))
.fill(Color::orange());
vstack((
animated_circle,
button("Animate Sequence")
.on_press({
let position = position.clone();
let scale = scale.clone();
let opacity = opacity.clone();
move || {
let pos = position.clone();
let sc = scale.clone();
let op = opacity.clone();
tokio::spawn(async move {
// Step 1: Move right
pos.set(Offset::new(100.0, 0.0));
sleep(Duration::from_millis(500)).await;
// Step 2: Scale up
sc.set(1.5);
sleep(Duration::from_millis(500)).await;
// Step 3: Fade out
op.set(0.3);
sleep(Duration::from_millis(500)).await;
// Step 4: Return to original state
pos.set(Offset::zero());
sc.set(1.0);
op.set(1.0);
});
}
}),
))
.spacing(30.0)
}
Parallel Animations
fn parallel_animations() -> impl View {
let transform = s!(Transform::identity());
let color = s!(Color::blue());
rectangle()
.size(100.0)
.transform(transform.clone())
.fill(color.clone())
.animation(Animation::spring())
.on_tap(move || {
// Both animations happen simultaneously
transform.set(Transform::identity()
.scaled(1.5)
.rotated(45.0)
.translated(50.0, 25.0));
color.set(Color::red());
})
}
Gesture-Driven Animations
WaterUI animations work seamlessly with gesture recognizers:
Drag Animations
fn draggable_animation() -> impl View {
let position = s!(Offset::zero());
let is_dragging = s!(false);
circle()
.size(80.0)
.offset(position.clone())
.scale(s!(if is_dragging { 1.1 } else { 1.0 }))
.animation(Animation::spring())
.fill(Color::green())
.gesture(
DragGesture::new()
.on_started(move |_| is_dragging.set(true))
.on_changed({
let position = position.clone();
move |delta| position.update(|pos| pos + delta.translation)
})
.on_ended(move |_| is_dragging.set(false))
)
}
Swipe Animations
fn swipe_cards() -> impl View {
let cards = s!(vec!["Card 1", "Card 2", "Card 3", "Card 4"));
let current_offset = s!(0.0);
zstack(
cards.iter().enumerate().map(|(index, card)| {
let offset = s!(current_offset + (index as f32 * 300.0));
rectangle()
.width(250.0)
.height(150.0)
.offset_x(offset)
.corner_radius(10.0)
.fill(Color::blue())
.animation(Animation::spring())
.overlay(
text(card)
.color(Color::white())
.size(18.0)
)
.gesture(
DragGesture::new()
.on_ended({
let current_offset = current_offset.clone();
move |details| {
if details.velocity.x.abs() > 500.0 {
// Snap to next card
current_offset.update(|offset| {
let new_offset = if details.velocity.x < 0.0 {
offset - 300.0
} else {
offset + 300.0
};
new_offset.max(-600.0).min(0.0)
});
}
}
})
)
}).collect::<Vec<_>>()
)
}
Performance Optimization
Animation Performance Tips
- Use Transform Properties: Transform properties (translate, scale, rotate) are GPU-accelerated:
// β
Efficient - uses transforms
fn efficient_animation() -> impl View {
let scale = s!(1.0);
circle()
.width(100.0).height(100.0)
.scale(scale.animated()) // GPU-accelerated, metadata-driven animation
.fill(color::BLUE)
.on_tap(move || scale.update(|s| if *s > 1.1 { 1.0 } else { 1.3 }))
}
// β Less efficient - changes layout
fn less_efficient_animation() -> impl View {
let size = s!(100.0);
circle()
.width(size.animated()) // May trigger layout recalculation
.height(size.clone()) // Better to use transform scale instead
.fill(color::BLUE)
.on_tap(move || size.update(|s| if *s > 110.0 { 100.0 } else { 150.0 }))
}
- Leverage Reactive Computations: Use reactive patterns to batch logical updates:
use waterui::*;
use nami::s;
fn efficient_batched_updates() -> impl View {
let interaction_state = s!(0); // Single source of truth
// All animations derive from one state change - efficient!
let scale = interaction_state.map(|&state| match state {
0 => 1.0, // Normal
1 => 1.1, // Hover
2 => 0.95, // Pressed
_ => 1.0,
}).animated();
let color = interaction_state.map(|&state| match state {
0 => color::BLUE,
1 => color::CYAN,
2 => color::NAVY,
_ => color::BLUE,
}).with_animation(Animation::ease_in_out(Duration::from_millis(150)));
let rotation = interaction_state.map(|&state| (state as f32) * 15.0).animated();
rectangle()
.width(100.0).height(100.0)
.scale(scale) // All three properties animate from single state change
.fill(color) // Efficient: one reactive update β three animations
.rotation(rotation)
.on_hover(move || interaction_state.set(1))
.on_press(move || interaction_state.set(2))
.on_release(move || interaction_state.set(0))
}
- Use Appropriate Animation Curves: Choose the right animation type for your use case:
fn optimized_animations() -> impl View {
vstack((
// For UI feedback - use spring for natural feel
button("Spring Animation")
.animation(Animation::spring()),
// For loading indicators - use linear for consistency
progress_bar()
.animation(Animation::linear(Duration::from_millis(1000))),
// For page transitions - use ease curves
page_transition()
.animation(Animation::ease_in_out(Duration::from_millis(300))),
))
}
Custom Animation Curves
You can create custom animation curves for unique effects:
fn custom_curve_animation() -> impl View {
let progress = s!(0.0);
// Custom bounce curve
let bounce_curve = |t: f32| -> f32 {
if t < 1.0 / 2.75 {
7.5625 * t * t
} else if t < 2.0 / 2.75 {
let t = t - 1.5 / 2.75;
7.5625 * t * t + 0.75
} else if t < 2.5 / 2.75 {
let t = t - 2.25 / 2.75;
7.5625 * t * t + 0.9375
} else {
let t = t - 2.625 / 2.75;
7.5625 * t * t + 0.984375
}
};
circle()
.size(60.0)
.offset_y(s!(progress * 200.0))
.animation(Animation::custom(
Duration::from_millis(1000),
bounce_curve
))
.fill(Color::red())
.on_tap(move || {
progress.update(|p| if p > 0.5 { 0.0 } else { 1.0 });
})
}
Animation State Management
Animation Controllers
For complex animation sequences, use animation controllers:
struct AnimationController {
is_playing: Binding<bool>,
progress: Binding<f32>,
direction: Binding<i32>, // 1 for forward, -1 for reverse
}
impl AnimationController {
fn new() -> Self {
Self {
is_playing: s!(false),
progress: s!(0.0),
direction: s!(1),
}
}
fn play(&self) {
self.is_playing.set(true);
self.animate_to_end();
}
fn reverse(&self) {
self.direction.set(-1);
self.is_playing.set(true);
self.animate_to_start();
}
fn animate_to_end(&self) {
let progress = self.progress.clone();
let is_playing = self.is_playing.clone();
tokio::spawn(async move {
while progress.with(|p| *p < 1.0) {
progress.update(|p| (p + 0.02).min(1.0));
sleep(Duration::from_millis(16)).await; // ~60 FPS
}
is_playing.set(false);
});
}
fn animate_to_start(&self) {
let progress = self.progress.clone();
let is_playing = self.is_playing.clone();
tokio::spawn(async move {
while progress.with(|p| *p > 0.0) {
progress.update(|p| (p - 0.02).max(0.0));
sleep(Duration::from_millis(16)).await;
}
is_playing.set(false);
});
}
}
fn controlled_animation() -> impl View {
let controller = AnimationController::new();
let scale = controller.progress.map(|p| 1.0 + p * 0.5);
let rotation = controller.progress.map(|p| p * 360.0);
vstack((
rectangle()
.size(100.0)
.scale(scale.clone())
.rotation(rotation.clone())
.fill(Color::blue()),
hstack((
button("Play")
.on_press({
let controller = controller.clone();
move || controller.play()
}),
button("Reverse")
.on_press(move || controller.reverse()),
))
.spacing(10.0),
))
.spacing(20.0)
}
Real-World Animation Examples
Loading Animations
fn loading_animations() -> impl View {
let rotation = s!(0.0);
let pulse_scale = s!(1.0);
// Start continuous animations
let rotation_clone = rotation.clone();
tokio::spawn(async move {
loop {
rotation_clone.update(|r| r + 2.0);
sleep(Duration::from_millis(16)).await;
}
});
let pulse_clone = pulse_scale.clone();
tokio::spawn(async move {
let growing = binding(true);
loop {
let current_val = pulse_clone.with(|p| *p);
let is_growing = growing.with(|g| *g);
if is_growing {
let new_val = current_val + 0.01;
pulse_clone.set(new_val);
if new_val >= 1.2 { growing.set(false); }
} else {
let new_val = current_val - 0.01;
pulse_clone.set(new_val);
if new_val <= 0.8 { growing.set(true); }
}
sleep(Duration::from_millis(16)).await;
}
});
vstack((
// Spinning loader
circle()
.size(40.0)
.stroke(Color::blue(), 4.0)
.stroke_dash([10.0, 5.0))
.rotation(rotation.clone())
.animation(Animation::linear(Duration::from_millis(16))),
// Pulsing dot
circle()
.size(20.0)
.scale(pulse_scale.clone())
.animation(Animation::ease_in_out(Duration::from_millis(16)))
.fill(Color::green()),
text("Loading...")
.color(Color::gray()),
))
.spacing(20.0)
}
Page Transitions
fn page_transition_example() -> impl View {
let current_page = s!(0);
let transition_offset = s!(0.0);
let pages = vec!["Home", "Profile", "Settings"];
vstack((
// Page content with slide transition
zstack(
pages.iter().enumerate().map(|(index, page)| {
let offset = s!((index as f32 - current_page as f32) * 300.0 + transition_offset);
rectangle()
.width(300.0)
.height(200.0)
.offset_x(offset)
.fill(Color::white())
.border(Color::gray(), 1.0)
.animation(Animation::ease_out(Duration::from_millis(300)))
.overlay(
text(page)
.size(24.0)
.color(Color::black())
)
}).collect::<Vec<_>>()
),
// Navigation
hstack(
pages.iter().enumerate().map(|(index, page)| {
button(page)
.on_press({
let current_page = current_page.clone();
move || current_page.set(index)
})
.background(s!(if current_page == index {
Color::blue()
} else {
Color::gray()
}))
}).collect::<Vec<_>>()
)
.spacing(10.0),
))
.spacing(20.0)
}
Animation Testing and Debugging
Animation Inspector
fn animation_debug_view(animated_view: impl View) -> impl View {
let show_debug = s!(false);
vstack((
animated_view,
when(show_debug.clone(), |debug| if debug {
vstack((
text("Animation Debug Info"),
text("Frame Rate: 60 FPS"),
text("GPU Accelerated: Yes"),
text("Active Animations: 2"),
))
.background(waterui::background::Background::color((0.0, 0.0, 0.0, 0.8)))
.color(Color::white())
.frame(waterui::component::layout::Frame::new().margin(waterui::component::layout::Edge::round(10.0)))
.into_view()
} else {
empty().into_view()
}),
button("Toggle Debug")
.on_press(move || show_debug.update(|d| !d)),
))
.spacing(10.0)
}
Best Practices
-
Keep Animations Purposeful: Every animation should serve a purpose - providing feedback, guiding attention, or enhancing understanding.
-
Follow Platform Conventions: Respect platform-specific animation durations and curves.
-
Test Performance: Profile your animations on target devices to ensure smooth performance.
-
Provide Accessibility Options: Allow users to disable animations if needed for accessibility.
-
Use Appropriate Durations:
- Micro-interactions: 100-200ms
- Page transitions: 300-500ms
- Loading animations: Continuous
- Attention-seeking: 500-800ms
-
Optimize for Battery Life: Avoid unnecessary continuous animations on mobile devices.
By following these patterns and best practices, you can create smooth, performant animations that enhance your WaterUI applications' user experience while maintaining good performance across all target platforms.
Suspense and Asynchronous Loading
Modern applications often need to load data asynchronously from APIs, databases, or other sources. The Suspense
component in WaterUI provides an elegant way to handle async content loading while maintaining a responsive user interface. This chapter covers everything you need to know about implementing suspense in your applications.
Basic Suspense Usage
The simplest way to use Suspense is with async functions that return views:
#![allow(unused)] fn main() { use waterui::{View}; use waterui_text::text; use waterui::component::layout::stack::{vstack}; use waterui::widget::suspense::Suspense; // Async function that loads data async fn load_user_profile(user_id: u32) -> impl View { // Simulate API call tokio::time::sleep(Duration::from_secs(2)).await; let user_data = fetch_user_data(user_id).await; vstack(( text!("Name: {}", user_data.name), text!("Email: {}", user_data.email), text!("Joined: {}", user_data.joined_date), )) } fn user_profile_view(user_id: u32) -> impl View { // Basic suspense with custom loading view Suspense::new(load_user_profile(user_id)) .loading(text!("Loading user profile...")) } }
Using Default Loading Views
You can set up default loading views in your application's environment:
#![allow(unused)] fn main() { use waterui::{Environment}; use waterui::view::AnyViewBuilder; use waterui::widget::suspense::{Suspense, DefaultLoadingView}; use waterui_text::text; use waterui::component::layout::stack::vstack; use waterui::component::layout::{Edge, Frame}; // Set up default loading view in your app fn setup_app_environment() -> Environment { let loading_view = AnyViewBuilder::new(|_| { vstack(( text!("Loading..."), )) .frame(Frame::new().margin(Edge::round(20.0))) }); Environment::new().with(DefaultLoadingView(loading_view)) } // Components can now use the default loading view fn simple_async_view() -> impl View { Suspense::new(load_data) // Uses default loading view from environment } }
The SuspendedView Trait
Any type can be used with Suspense by implementing the SuspendedView
trait. The trait is automatically implemented for any Future
that resolves to a View
.
Automatic Implementation for Futures
#![allow(unused)] fn main() { use waterui::widget::suspense::{Suspense, SuspendedView}; use waterui_text::text; // These all work with Suspense automatically: // 1. Async functions async fn fetch_weather() -> impl View { let weather = get_weather_data().await; text!("Temperature: {}Β°F", weather.temperature) } // 2. Async closures let load_news = async move || { let articles = fetch_news_articles().await; news_list_view(articles) }; // 3. Future types use std::future::Future; use std::pin::Pin; type BoxedFuture = Pin<Box<dyn Future<Output = impl View>>>; fn get_async_content() -> BoxedFuture { Box::pin(async { text!("Async content loaded!") }) } // All work with Suspense: let weather_view = Suspense::new(fetch_weather); let news_view = Suspense::new(load_news); let content_view = Suspense::new(get_async_content()); }
Custom SuspendedView Implementation
For more complex scenarios, you can implement SuspendedView
manually:
#![allow(unused)] fn main() { use waterui::{Environment, View}; use waterui::widget::suspense::SuspendedView; use waterui_text::text; use waterui::component::layout::stack::vstack; struct DataLoader { user_id: u32, include_posts: bool, } impl SuspendedView for DataLoader { async fn body(self, _env: Environment) -> impl View { // Custom loading logic with environment access let user = fetch_user(self.user_id).await; if self.include_posts { let posts = fetch_user_posts(self.user_id).await; vstack(( user_profile_view(user), posts_list_view(posts), )) } else { user_profile_view(user) } } } // Usage fn user_dashboard(user_id: u32, show_posts: bool) -> impl View { Suspense::new(DataLoader { user_id, include_posts: show_posts, }) .loading(text!("Loading dashboard...")) } }
[WIP]