| name | waterui-native-bindings |
| description | Implement FFI bindings for WaterUI components across Rust, Apple Swift, and Android Kotlin/JNI layers. Use when adding new component types, metadata wrappers, or fixing native binding errors (UnsatisfiedLinkError, missing symbols, type mismatches). |
WaterUI Native Bindings
Architecture
Rust Core → Rust FFI (ffi/src/) → cbindgen → waterui.h
├── Apple (Swift + C)
└── Android (Kotlin + JNI + C++)
Layout Contract
Rust owns layout, native owns rendering. Native components must:
- Measure based on content - Report intrinsic size via
sizeThatFits(Apple) oronMeasure(Android) - Accept placement from Rust - Rust calls
place(x, y, width, height)to position the view - Use native UI components - Render with platform-native widgets (UIKit/AppKit/Android Views)
- Customize behavior freely - Handle gestures, animations, styling within the component
Rust Layout Engine Native Component
│ │
├── sizeThatFits? ─────────►│ (measure content)
│◄──────────────── size ────┤
│ │
├── place(x,y,w,h) ────────►│ (accept position)
│ │
└── (repeat per frame) ─────┘
Native components should NOT fight the layout system - measure honestly, accept placement.
Metadata never affects layout. Metadata wrappers (gestures, focus, background, etc.) are transparent to the layout system - they pass through the child's size unchanged. Only the wrapped content determines layout.
Building Native Components
Rust Side: Define with NativeView trait
pub struct MyComponent { /* fields */ }
impl NativeView for MyComponent {
// Declare stretch behavior (how component fills available space)
fn stretch_axis(&self) -> StretchAxis {
StretchAxis::None // Fixed size, no stretching
// StretchAxis::Horizontal // Fills width, fixed height
// StretchAxis::Vertical // Fixed width, fills height
// StretchAxis::Both // Fills both dimensions
}
}
Native Side: Render with Platform Views
Apple (Swift):
final class WuiMyComponent: WuiBaseView, WuiComponent {
static var rawId: WuiTypeId { waterui_my_component_id() }
required init(anyview: OpaquePointer, env: WuiEnvironment) {
super.init(frame: .zero)
let data = waterui_force_as_my_component(anyview)
// Use native UIKit/AppKit views
let label = UILabel() // or NSTextField for macOS
addSubview(label)
}
// Report intrinsic size for Rust layout
override func sizeThatFits(_ size: CGSize) -> CGSize {
return label.sizeThatFits(size)
}
}
Android (Kotlin):
private val myComponentRenderer = WuiRenderer { context, node, env, registry ->
val data = NativeBindings.waterui_force_as_my_component(node.rawPtr)
// Use native Android views
val textView = TextView(context).apply {
text = "Hello"
}
// Set stretch axis tag for layout system
textView.setTag(TAG_STRETCH_AXIS, StretchAxis.NONE)
textView.disposeWith { /* cleanup */ }
textView
}
StretchAxis Values
| Value | Behavior | Use Case |
|---|---|---|
None |
Fixed size from content | Text, icons, buttons |
Horizontal |
Fills width, height from content | Text fields, progress bars |
Vertical |
Fills height, width from content | Vertical dividers |
Both |
Fills all available space | Backgrounds, containers |
Quick Reference
| Layer | Key Files |
|---|---|
| FFI | ffi/src/lib.rs, ffi/src/macros.rs |
| Apple | backends/apple/Sources/WaterUI/Components/, Core/AnyView.swift |
| Android | backends/android/runtime/src/main/java/dev/waterui/android/ |
| Android JNI | backends/android/runtime/src/main/cpp/waterui_jni.cpp |
Adding a New Component
1. Rust FFI (ffi/src/lib.rs)
Metadata types: T in Metadata<T> must implement MetadataKey and be a concrete type (no generics) for FFI compatibility. For generic types, use type erasure (e.g., Box<dyn Any>).
// In core: T must implement MetadataKey
impl MetadataKey for Foo {}
// In ffi/src/lib.rs:
#[repr(C)]
pub struct WuiFooData { pub field: i32 }
pub type WuiMetadataFoo = WuiMetadata<WuiFooData>;
ffi_metadata!(path::to::Foo, WuiMetadataFoo, foo);
Generate header: cargo run --bin generate_header --features cbindgen
2. Apple Backend
Create backends/apple/Sources/WaterUI/Components/WuiFoo.swift:
@MainActor
final class WuiFoo: WuiBaseView, WuiComponent {
static var rawId: CWaterUI.WuiTypeId { waterui_foo_id() }
required init(anyview: OpaquePointer, env: WuiEnvironment) {
let data = waterui_force_as_foo(anyview)
super.init(frame: .zero)
// Implementation...
disposeBag.append { waterui_drop_foo(data.ptr) }
}
}
Naming convention: Swift classes intentionally share names with C types. When referencing C types, use CWaterUI. prefix (e.g., CWaterUI.WuiFoo, CWaterUI.WuiTypeId).
Register in AnyView.swift: registerComponent(WuiFoo.self)
3. Android Backend
See references/android-jni.md for complete step-by-step.
Quick checklist:
FfiStructs.kt- Add data classWatcherJni.kt- Addexternal fundeclarationsNativeBindings.kt- Add wrapper functionswaterui_jni.cpp- Add to symbol table + implement JNI functionscomponents/FooComponent.kt- Create rendererRenderRegistry.kt- Register component
Reactive Values
Swift:
let computed = WuiComputed<T>(ptr, env: env)
computed.watch { value in /* react */ }
let binding = WuiBinding<Bool>(ptr, env: env)
binding.observe { value in /* react */ }
binding.set(true)
Kotlin:
val computed = WuiComputed.resolvedColor(ptr, env)
computed.observe { value -> /* react */ }
computed.close()
val binding = WuiBinding.bool(ptr, env)
binding.observe { value -> /* react */ }
binding.set(true)
binding.current()
binding.close()
Build Commands
# Generate C header
cd ffi && cargo run --bin generate_header --features cbindgen
# Apple
cd backends/apple && swift build
# Android
cd backends/android && ./gradlew :runtime:assembleDebug
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
UnsatisfiedLinkError |
Missing JNI symbol | Add to WATCHER_SYMBOL_LIST in waterui_jni.cpp |
| Swift "Cannot find X" | Header outdated | Regenerate + copy waterui.h |
| Type mismatch | Wrong pointer type | Check header for exact type name |
Unit type () fails |
C can't represent () |
Use marker struct: struct Marker { _m: u8 } |
Adding New Primitive Types
When adding support for a new primitive type (e.g., f32, custom struct), update all layers:
Swift Side
- Watcher.swift - Add watcher creator following existing patterns (see
makeDoubleWatcher) - Binding.swift - Add
WuiBindingextension with read/watch/set/drop functions - Computed.swift - Add
WuiComputedextension if needed for computed values
Android Side
- WatcherJni.kt - Add
external fundeclarations for read/set/drop/watch/create - WuiBinding.kt - Add factory function in companion object +
WatcherStructFactoryfunction - WuiComputed.kt - Add factory function if needed for computed values
- waterui_jni.cpp - Add symbols to
WATCHER_SYMBOL_LIST+ implement JNI functions
Pattern: Follow Existing Types
Look at how Double/f64 is implemented across all layers and replicate the pattern for your new type. The key functions needed are:
read_binding_<type>/read_computed_<type>set_binding_<type>drop_binding_<type>/drop_computed_<type>watch_binding_<type>/watch_computed_<type>new_watcher_<type>
Custom Struct Types (Computed)
For custom struct types like Video, ResolvedColor, etc.:
- FfiStructs.kt - Add Kotlin data class matching the C struct
- WatcherJni.kt - Add
readComputed<Type>,watchComputed<Type>,dropComputed<Type>,create<Type>Watcher - WuiComputed.kt - Add factory function
- waterui_jni.cpp - Convert C struct fields to Java object in JNI function
Header Synchronization
Critical: The Apple backend has its own copy of waterui.h:
# After updating ffi/waterui.h, copy to Apple backend:
cp ffi/waterui.h backends/apple/Sources/CWaterUI/include/waterui.h
# Then clean and rebuild
cd backends/apple && swift package clean && swift build
FFI Type Ordering
When using custom FFI types (like WuiId) as struct fields:
- The type must be defined BEFORE structs that use it in the C header
- If header ordering causes "incomplete type" errors, use raw C types (
uint64_t,int32_t) in FFI structs instead - Convert in
IntoFFIimpl:id: i32::from(rust_id) as u64
Greedy Layout Components
Components that fill available space (containers, navigation):
// Rust: Use raw_view! with StretchAxis::Both
raw_view!(MyContainer, StretchAxis::Both);
// Swift: Set stretchAxis and use proposal size
private(set) var stretchAxis: WuiStretchAxis = .both
func sizeThatFits(_ proposal: WuiProposalSize) -> CGSize {
let width = proposal.width.map { CGFloat($0) } ?? 320
let height = proposal.height.map { CGFloat($0) } ?? 480
return CGSize(width: width, height: height)
}
Platform-Specific Layout Triggers
#if canImport(UIKit)
setNeedsLayout()
layoutIfNeeded()
#elseif canImport(AppKit)
needsLayout = true
#endif
Reactive Color Watchers
For components with reactive Computed<Color> properties:
private func setupColorWatcher(colorPtr: OpaquePointer?) {
guard let colorPtr = colorPtr else { return }
// Read Color, then resolve to Computed<ResolvedColor>
let color = WuiColor(waterui_read_computed_color(colorPtr)!)
let resolved = color.resolve(in: env)
applyColor(resolved.value)
colorWatcher = resolved.watch { [weak self] color, metadata in
withPlatformAnimation(metadata) {
self?.applyColor(color)
}
}
}
Array Extraction Pattern
private func extractItems(from array: WuiArray_WuiItem) {
let slice = array.vtable.slice(array.data)
guard let head = slice.head else { return }
for i in 0..<slice.len {
let item = head.advanced(by: Int(i)).pointee
let view = WuiAnyView(anyview: item.content, env: env)
items.append((id: item.id, view: view, ptr: item.data))
}
}