Write ViewModels for SwiftUI and Jetpack Compose only in Rust, powered by Mozilla UniFFI.
Caution
Do not use this software in production, it's just a Proof Of Concept (PoC)! Albeit a matuer POC.
Test coverage is near zero, because it is a PoC!
Documentation is non-existing (besides this README), because it is a PoC!
Examples are few, because it is a PoC!
<INSERT BAD>
, because it is a PoC!
lera
is a set of procmacros (own DSL), build scripts (build, bindgen, post-bindgen) allowing you to generate ready-to-use ViewModels for SwiftUI and Jetpack Compose in pure Rust only.
Note
The vision of lera is to be able to build iOS apps in SwiftUI and Android apps in Jetpack compose by writing only UI code natively (in Swift/Kotlin) with all logic written in Rust, in one place.
UniFFI is amazing (this software is built in top of it!), however, it does not (yet) support uniffi::viewmodel
(or similar), and even with support of @Observable
annotations for uniffi::Object
UniFFI does not export any Swift state as stored properties which SwiftUI could observe, nor any : ViewModel()
inheritance nor StateFlow
for Jetpack compose in Kotlin generated code.
lera
writes ViewModels which can be "observed" by SwiftUI and Jetpack Compose, ensuring the view updates according to state
, and state is always up-to-date with changes made through input from views, and also changes made internally from within Rust itself (e.g. a background task in Rust which automatically increases a counter).
macOS is assumed. You can surely make this work on Linux too, if you do, I welcome a PR with updated guides!
Disregarding if you are interested in Swift or Kotlin, these shared dependencies must be installed.
Install Xcode
Install Rust
This project makes heavy use of just
brew install just
For development install pre-commit
brew install pre-commit
Getting Swift/Apple to work is easiest.
rustup target add aarch64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim
Getting Kotlin/Android to work is harder than Swift/Apple.
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
You can use jenv
and install openjdk 23
Tip
Don't forget to export JAVA_HOME
and refresh your shell!
just example::android::install-jars
Still needed? Verify on another machine.
See demo / example in example
folder, scroll down for video recording of SwiftUI and Android demo apps.
Source code can be found in example/rust
This is all you have to do in Rust land, in terms of Rust code.
#[derive(Clone, Default)]
#[lera::state]
pub struct CounterState {
pub count: i64,
}
#[lera::model(state = CounterState)]
pub struct Counter {}
#[lera::api]
impl Counter {
pub fn increment_button_tapped(self: &Arc<Self>) {
self.mutate(|state| {
state.count += 1;
});
}
pub fn decrement_button_tapped(self: &Arc<Self>) {
self.mutate(|state| {
state.count -= 1;
});
}
pub fn reset_button_tapped(self: &Arc<Self>) {
self.mutate(|state| {
state.count = 0;
});
}
/// Can take args and return values
pub fn tell_full_name(&self, first_name: &str, last_name: &str) -> String {
format!("{} {}", first_name, last_name)
}
}
This is the simplest demo I've made, but I've also managed to create a background task in Rust which periodically increments the counter, and that state change propagates back to Swift thanks to the callback pattern.
Swift package can found in example/apple
.
lera_swiftui.mov
SwiftUI example in example/apple/app
uses Swift package in example/apple
as a local dependency.
This is how you use the by-lera
-generated CounterViewModel
:
import SwiftUI
import ModelsFromRust
public struct CounterView: View {
private let model: CounterViewModel
public init(model: CounterViewModel = .init()) {
self.model = model
}
public var body: some View {
VStack {
Text("Count: \(model.count)")
Button("Increment") {
model.incrementButtonTapped()
}
Button("Decrement") {
model.decrementButtonTapped()
}
Button("Reset") {
model.resetButtonTapped()
}
}
.padding()
}
}
Lera was used to generate a Swift package in example/apple
. See below what it generates for you.
The Swift code forwards actions from Swift to Rust, and any state changes occurring either directly as an effect of the action or state change happening internally inside of Rust (e.g. a background task) will all propagate back to Swift and to SwiftUI View (thanks to @Observable
).
// MARK: CounterViewModel
extension CounterState {
public init() {
self = newDefaultCounterState()
}
}
@Observable
@dynamicMemberLookup
public final class CounterViewModel: @unchecked Sendable {
/// This is state, set by listener
private var state: CounterState
@ObservationIgnored
private let model: Counter
@ObservationIgnored
private let listener: CounterStateChangeListener
private init(state: CounterState, listener: CounterStateChangeListener) {
self.state = state
self.listener = listener
self.model = Counter(state: state, listener: listener)
}
public convenience init(
state: CounterState = CounterState()
) {
let listener = Listener()
self.init(state: state, listener: listener)
listener.add(forwarder: Listener.Forwarder { [weak self] newState in
print("Swift forwarder got new state")
self?.state = newState
})
}
}
// MARK: Listener
extension CounterViewModel {
fileprivate final class Listener: CounterStateChangeListener, @unchecked Sendable {
fileprivate struct Forwarder {
typealias OnStateChange = @Sendable (CounterState) -> Void
private let onStateChange: OnStateChange
init(_ onStateChange: @escaping OnStateChange) {
self.onStateChange = onStateChange
}
fileprivate func forward(_ state: CounterState) {
self.onStateChange(state)
}
}
private var forwarder: Forwarder?
init() {}
fileprivate func add(forwarder: Forwarder) {
self.forwarder = forwarder
}
// MARK: CounterStateChangeListener
func onStateChange(state: CounterState) {
forwarder?.forward(state)
}
}
}
// MARK: @dynamicMemberLookup
extension CounterViewModel {
public subscript<Subject>(dynamicMember keyPath: KeyPath<CounterState, Subject>) -> Subject {
self.state[keyPath: keyPath]
}
}
// MARK: Forward Actions from view to model (Rust)
extension CounterViewModel {
public func incrementButtonTapped() {
model.incrementButtonTapped()
}
public func decrementButtonTapped() {
model.decrementButtonTapped()
}
public func resetButtonTapped() {
model.resetButtonTapped()
}
}
Using Counter
, CounterState
, CounterStateChangeListener
generated by UniFFI (via lera
).
just example::apple::app::build-and-run
Of course I've verified that there are no retain cycles between Swift and Rust, all classes deinit as expected, even when there is a background task running, so no memory leaks introduced.
Android/Kotlin works! Take a look at example/android
lera_android.mov
A simple Android app can be found in example/android/app
, which uses a kotlin package built by lera
as a local dependency.
@Composable
fun CounterScreen(
counterViewModel: CounterViewModel = viewModel()
) {
val counterUiState by counterViewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Count: ${counterUiState.count}",
style = MaterialTheme.typography.displayLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = { counterViewModel.decrementButtonTapped() }
) {
Text("−")
}
Button(
onClick = { counterViewModel.resetButtonTapped() }
) {
Text("Reset")
}
Button(
onClick = { counterViewModel.incrementButtonTapped() }
) {
Text("+")
}
}
Spacer(modifier = Modifier.height(16.dp))
if (counterUiState.isAutoIncrementing) {
Text(
text = "Counter is being incremented automatically every ${counterUiState.autoIncrementIntervalMs}ms",
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Button(onClick = { counterViewModel.stopAutoIncrementingButtonTapped() }) {
Text("Stop Auto")
}
} else {
Text(
text = "Automatic increment of the counter is stopped",
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Button(onClick = { counterViewModel.startAutoIncrementingButtonTapped() }) {
Text("Start Auto")
}
}
}
}
First build kotlin package with lera
using just
:
just example::android::build-package-and-app
Then open example/android/app
with Android Studio and run the app.
fun `newDefaultCounterState`(): CounterState {
return FfiConverterTypeCounterState.lift(
uniffiRustCall() { _status ->
UniffiLib.uniffi_counters_fn_func_new_default_counter_state(
_status)
}
)
}
class CounterViewModel(
state: CounterState = newDefaultCounterState()
) : androidx.lifecycle.ViewModel() {
private val listener = Listener()
private val model = Counter(state, listener)
private val _uiState = kotlinx.coroutines.flow.MutableStateFlow(state)
val uiState: kotlinx.coroutines.flow.StateFlow<CounterState> =
_uiState.asStateFlow()
init {
listener.addForwarder { newState ->
_uiState.value = newState
}
}
fun incrementButtonTapped() {
model.incrementButtonTapped()
}
fun decrementButtonTapped() {
model.decrementButtonTapped()
}
fun resetButtonTapped() {
model.resetButtonTapped()
}
fun startAutoIncrementingButtonTapped() {
model.startAutoIncrementingButtonTapped()
}
fun stopAutoIncrementingButtonTapped() {
model.stopAutoIncrementingButtonTapped()
}
override fun onCleared() {
super.onCleared()
listener.clear()
}
private inner class Listener : CounterStateChangeListener {
private var forwarder: ((CounterState) -> Unit)? = null
fun addForwarder(forwarder: (CounterState) -> Unit) {
this.forwarder = forwarder
}
fun clear() {
forwarder = null
}
override fun onStateChange(state: CounterState) {
forwarder?.invoke(state)
}
}
}
Note
Large parts of this software is written using prompts like ChatGPT, thus the code style might not at all reflect my own, so don't judge me!
If/when this software is upgraded from a POC, large parts of it written by AI ought to be rewritten.
lera
use askama (.rinja) templates to generate @Observable
Swift Viewmodels which work in SwiftUI and ViewModel()
classes in Kotlin with MutableStateFlow
properties
This is done using several layers of "metaprogramming", Rust code (procmacros) generating UniFFI compatible Rust code which generates Swift and Kotlin bindings, and Rust code using Swift/Kotlin-templates generating Swift/Kotlin code using the Swift/Kotlin bindings.
- You write your (view)model and your state and mark which methods you wanna export, using
#[lera::model(state = CounterState)]
,#[lera::state]
and#[lera::api]
. These procmacros will expand into#[uniffi:Object]
,#[uniffi:Record]
and#[uniffi:export]
, but with quite a bit of functionality. The#[lera::model(state = FooState)]
gives the Rust struct two fieldsstate: Arc<RwLock<FooState>
andstate_change_listener: Arc<dyn FooListenerTrait>
. It also generates this constructor and methods:
pub trait LeraModel {
type State: ModelState;
type Listener: StateChangeListener<State = Self::State>;
fn new(state: Self::State, listener: Self::Listener) -> Arc<Self>;
fn access<R: Clone>(&self, access: impl FnOnce(Self::State) -> R) -> R;
fn mutate<R>(&self, mutate: impl FnOnce(&mut Self::State) -> R) -> R;
fn notify_state_change(&self, new_state: Self::State);
}
It generates a UniFFI exported listener trait for you and sets up bridging from it to:
pub trait StateChangeListener: Send + Sync + 'static {
type State: ModelState;
fn on_state_change(&self, new_state: Self::State);
}
- Lera vendors a Rust build binary for Swift, which compiles Rust code (
cargo build
), runs bindgen (uniffi_bindgen::bindings::generate_swift_bindings
), and builds an xcframework. It vendors a similar build binary for Kotlin. - Lera then generates Swift and Kotlin ViewModels, which wraps the model object, and a listener class and implements the callback interface which updates state in FFI land whenever it changes in Rust land. Lera write code which forwards all methods declared in
#[lera::api]
from the ViewModel (generated by lera) to the model (generated by UniFFI (via Lera)). On the Swift side it also implements@dynamicMemberLookup
so that you need not writeviewModel.state.count
, you can just writeviewModel.count
(must like RustsDeref
trait!).
Lera optionally integrates with a tiny sampling framework to generate test/demo values for your state types. This is useful for previews, fixtures, and for generating platform-side lists of sample states for rapid UI iteration.
There are two pieces:
- Trait and core impls:
samples_core::Samples
defines how to enumerate a small set of representative values for a type (e.g., integers, strings, collections). Many Rust standard/container types already implement it. - Derive and field customization:
samples_derive::Samples
derivesSamples
for your struct by combining field samples. You can override per-field samples using a#[samples(...)]
attribute.
By default, lera does not force your state to implement Samples
. You opt in per state.
Annotate your state with #[lera::state(samples)]
to:
- Derive
samples_derive::Samples
for the type. - Export an FFI helper
new_<state>_samples(n: u8) -> Vec<State>
so Swift/Kotlin can request up ton
samples.
Example: when all fields are already sampleable
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
#[lera::state(samples)]
pub struct ManualOnlyCounterState {
pub count: i64,
}
Sometimes a field's type doesn't implement Samples
. You can still make the struct sampleable by providing explicit sample values for that field using #[samples(...)]
on the field. Lera's derive supports two forms per field:
- Direct literals/expressions
#[samples([value1, value2, value3])]
- Expressions validated and constructed via a function path Optionally you can pass the values through a validation function
#[samples([value1, value2] -> path::to_fn)]
The path::to_fn
must be a const fn
that returns Result<Type, E>
or Type
(if you panic!
in your validation). The derive will:
- Validate at compile time that
path::to_fn(input)
isOk(_)
; if it'sErr(_)
, compilation fails with a clear message. This protects you from shipping bad sample values. - Construct the field value at runtime using
match path::to_fn(input) { Ok(v) => v, Err(_) => unreachable!(...) }
(theErr
path is unreachable because of the compile-time check).
Complete example with a non-sampleable field type:
// Can optionally `#[derive(Samples)]`
pub struct Interval { ms: u64 }
impl Interval {
pub const fn const_try_from(value: u64) -> Result<Self, &'static str> {
if value == 0 { Err("Interval must be non-zero") } else { Ok(Interval { ms: value }) }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[lera::state(samples)]
pub struct CounterState {
pub count: i64,
pub is_auto_incrementing: bool,
#[samples([500, 1000] -> Interval::const_try_from)]
pub auto_increment_interval_ms: Interval,
}
Here, Interval
does not implement Samples
, but CounterState
still can, thanks to the field-level #[samples(...)]
attribute.
For a struct, the derive gathers a short list of candidate values per field and combines them:
- Single-field struct: iterates the field's samples directly.
- Up to 8 fields: uses a Cartesian product (iproduct) across the candidate lists.
- More than 8 fields: uses an index-based product to avoid combinatorial explosion in codegen size.
If any field yields no candidates, the final iterator is empty.
Here is how you can use these samples to power e.g. SwiftUI previews:
#Preview {
VStack {
ForEach(CounterViewModel.samples(n: 3)) {
CounterView(model: $0)
}
}
}
The static function CounterViewModel.samples(n:)
is being generated by Lera for
you if your state is marked #[lera::state(samples)]
. It creates at most n
many CounterState
s (using sample_vec
in Rust land) and initializes
ViewModels from them.
Note
SwiftUI previews just work in Xcode 26, due to improvements to previews. This was not the case for earlier versions of Xcode.
Demo of usage of samples
on ViewModels with SwiftUI
previews.mov
-
Rust side:
#[lera::state(samples)]
opt-in for state-level samples and export ofnew_<state>_samples(n)
.#[samples([...])]
or#[samples([... ] -> path::to_fn)]
per-field overrides.samples_core::Samples::sample_vec_n(n)
and::sample_vec()
return up ton
or 255 samples, respectively.
-
Swift/Kotlin side:
- The exported
new_<state>_samples(n)
makes a Vec of sample states available to the platform via FFI whensamples
is enabled on the state.
- The exported
- Keep samples small. The derive limits and patterns aim for fast, deterministic sets—not exhaustive testing.
- Use the
-> path::to_fn
form for smart constructors and validation. It must returnResult<FieldType, E>
;E
only needsDebug
. - You can mix direct values and validated values across fields.
- If you omit
#[lera::state(samples)]
, your state will still work in lera; it just won't implementSamples
or export the FFI helper.
Lera is a Swedish 🇸🇪 word meaning literally "clay", however, it is short for two heteronyms: 🇸🇪 model-lera (verb) and 🇸🇪 modellera (noun), meaning "to model" and "modelling clay" respectively. So with lera I mean both the verb and the noun! You model... ViewModels, using lera
as modelling clay!