Skip to content

Sajjon/lera

Repository files navigation

lera

Write ViewModels for SwiftUI and Jetpack Compose only in Rust, powered by Mozilla UniFFI.

Status: PoC

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!

What?

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.

Table Of Contents

Why?

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).

Usage

macOS is assumed. You can surely make this work on Linux too, if you do, I welcome a PR with updated guides!

Prerequisites

Shared

Disregarding if you are interested in Swift or Kotlin, these shared dependencies must be installed.

Xcode

Install Xcode

Rust

Install Rust

brew

brew

just

This project makes heavy use of just

brew install just

pre-commit (for development)

For development install pre-commit

brew install pre-commit

Apple

Getting Swift/Apple to work is easiest.

Install Rust targets

rustup target add aarch64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim

Android

Getting Kotlin/Android to work is harder than Swift/Apple.

Install Rust targets

rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android

Install Java

You can use jenv and install openjdk 23

Tip

Don't forget to export JAVA_HOME and refresh your shell!

Install JNA

just example::android::install-jars

Install NDK

Still needed? Verify on another machine.

Demo

See demo / example in example folder, scroll down for video recording of SwiftUI and Android demo apps.

Rust side

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 side

Swift package can found in example/apple.

App demo

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()
    }
}

Swift Generated by lera

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).

Run it

just example::apple::app::build-and-run

No retain cycles

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.

Kotlin Side

Android/Kotlin works! Take a look at example/android

App demo

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")
            }
        }
    }
}

Run it

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.

Kotlin generated by lera

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)
        }
    }
}

How it works under the hood

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.

High level description

  1. 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 fields state: Arc<RwLock<FooState> and state_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);
}
  1. 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.
  2. 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 write viewModel.state.count, you can just write viewModel.count (must like Rusts Deref trait!).

Samples

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.

Note

Samples are deterministic (unlike Dummy / Fake crates)

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 derives Samples 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.

Opting in for a 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 to n samples.

Example: when all fields are already sampleable

#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
#[lera::state(samples)]
pub struct ManualOnlyCounterState {
    pub count: i64,
}

Custom samples

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:

  1. Direct literals/expressions
#[samples([value1, value2, value3])]
  1. 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) is Ok(_); if it's Err(_), 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!(...) } (the Err 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.

How derived Samples are combined

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.

Usage on Swift/Kotlin side

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 CounterStates (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

Demo of usage of samples on ViewModels with SwiftUI

previews.mov

API surface recap

  • Rust side:

    • #[lera::state(samples)] opt-in for state-level samples and export of new_<state>_samples(n).
    • #[samples([...])] or #[samples([... ] -> path::to_fn)] per-field overrides.
    • samples_core::Samples::sample_vec_n(n) and ::sample_vec() return up to n 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 when samples is enabled on the state.

Notes and tips

  • 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 return Result<FieldType, E>; E only needs Debug.
  • 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 implement Samples or export the FFI helper.

Etymology

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!

About

Write ViewModels for SwiftUI and Jetpack Compose only in Rust, powered by Mozilla UniFFI.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published