Day for Jetpack Compose developers

Compose and Day both build on state that tracks its readers: mutableStateOf records which composables read it and recomposes them; a Day Signal records which closures read it and re-runs them. This guide maps the vocabulary and the toolchain (composables to Pieces, Gradle to Cargo) and covers the two structural differences.

The first is update granularity. Compose re-executes recomposition scopes and skips what it can; Day has no recomposition at all. The tree is built once, and a state change re-runs only the closures that read it, each ending in one native setter call. Stability annotations and remember bookkeeping have no equivalent because there is nothing to skip.

The second is what’s on screen. Compose draws its own Material widgets on a canvas (and Compose Multiplatform carries that renderer to other platforms). Day creates each platform’s real widgets: Material Components views on Android, AppKit on macOS, WinUI on Windows, actual DOM elements on the web. Your app looks native everywhere rather than identical everywhere. Why Day lists what each approach costs and when Compose is the better fit.

Compose terms in Day

Jetpack ComposeDay
@Composable funa plain function returning a Piece — no compiler plugin
remember { mutableStateOf(x) }Signal::new(x) — no remember; the function runs once
derivedStateOfMemo::new
LaunchedEffect / DisposableEffectEffect::new / watch; scopes clean up automatically
recomposition + skippingno recomposition; one binding patches one widget
Column / Row / Box / Spacercolumn / row / zstack / spacer()
Modifier.padding(16.dp).padding(16.0) — modifiers are builder methods
LazyColumnlist — recycling rows, key required
NavHost + NavControllerselector, stack, and string routes
CompositionLocalwith_environment / use_context
ViewModel + StateFlowstructs of signals; Setter for background threads
Gradle + version catalogsCargo.toml (package) + Day.toml (app manifest)
res/drawable, res/fontresource/images/, resource/fonts/ — staged into res/ at build
strings.xml + pluralsFluent .ftl + generated res::str functions
@Previewnone — day launch + dayscript screenshots
bundleRelease + Play Consoleday pack -p android-mdc → signed .apk + .aab

The same counter in both

ComposeDay
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Text("$count clicks")
        Button(onClick = { count++ }) {
            Text("+")
        }
    }
}
use day::prelude::*;

fn counter() -> AnyPiece {
    let count = Signal::new(0i64);
    column((
        label(move || format!("{} clicks", count.get())),
        button("+").action(move || count.update(|c| *c += 1)),
    ))
    .spacing(12.0)
    .padding(16.0)
    .any()
}

There’s no remember because the function isn’t re-executed; state doesn’t need rescuing from re-execution. The label’s closure is the only code that runs on a tap, and it ends in one native setText. The habits Compose teaches around recomposition (hoisting state, stability annotations, skippability) have no Day equivalent, because there is no recomposition to manage. Reactivity covers the model, including its cost: you mark what’s dynamic (a closure makes text live; a bare value doesn’t).

Two-way controls

Compose controls are stateless value-plus-callback pairs by design. Day’s take signals directly; the write-back is built in.

ComposeDay
var name by remember { mutableStateOf("") }
var volume by remember { mutableStateOf(40f) }
var subscribed by remember { mutableStateOf(false) }

Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
    OutlinedTextField(
        value = name,
        onValueChange = { name = it },
        placeholder = { Text("Your name") },
    )
    Slider(
        value = volume,
        onValueChange = { volume = it },
        valueRange = 0f..100f,
    )
    Switch(
        checked = subscribed,
        onCheckedChange = { subscribed = it },
    )
}
let name = Signal::new(String::new());
let volume = Signal::new(40.0);
let subscribed = Signal::new(false);

column((
    text_field(name).placeholder("Your name"),
    slider(volume).range(0.0..=100.0),
    toggle(subscribed),
))
.spacing(12.0)

On Android these are real Material Components views (TextInputLayout, MaterialSwitch), so they track the OS’s Material version rather than your Compose BOM.

Lists

ComposeDay
LazyColumn {
    items(albums, key = { it.id }) { album ->
        Text(
            album.title,
            modifier = Modifier.padding(8.dp),
        )
    }
}
list(
    move || albums.get(),
    |a| a.id,
    |slot: ItemSlot<Album, u64>| {
        label(move || slot.get().title).padding(8.0)
    },
)

There are two differences. The key is a required argument, not an optional lambda. And list recycles real native cells (a RecyclerView on Android), so the row builder runs per recycled slot rather than per composition.

NavController owns a back stack you push routes onto. Day turns that inside out: the back stack is your Signal<Vec<String>>, and the platform container is reconciled to it. On Android that container is the androidx Fragment back stack, with predictive back (opt-in on 13/14, default on 15).

ComposeDay
val nav = rememberNavController()

NavHost(nav, startDestination = "home") {
    composable("home") { HomeScreen(nav) }
    composable("album/{id}") { entry ->
        AlbumScreen(entry.arguments?.getString("id")!!)
    }
}

// push
nav.navigate("album/42")
let path = Signal::new(Vec::<String>::new());

stack(path, home_page())
    .title("Home")
    .destination(|key| album_page(key))

// push — it's just a vector edit:
path.update(|p| p.push("album-42".into()));
// or, from anywhere:
navigate("album-42");

Because navigation is state, the system back gesture writes a pop into your signal, deep links are DAY_DEEPLINK=library/album-42 at launch, and a UI test asserts current_route() as a string. Sections (the role of a bottom bar or nav rail) are selector, which renders each platform’s idiom: tabs on phones, a sidebar split view on desktop. See Navigation.

Modifiers and layout

The chain reads the same, but the algorithm differs. Compose measures with constraints flowing down; Day uses the SwiftUI-style proposal protocol (parent proposes, child chooses), with text measured by the platform’s own engine. Day for Modifier.weight(1f) is .grow(), and containers don’t stretch children by default.

ComposeDay
Column(
    modifier = Modifier
        .padding(16.dp)
        .background(
            Color(0xFF1E293B),
            RoundedCornerShape(12.dp),
        ),
    horizontalAlignment = Alignment.Start,
    verticalArrangement = Arrangement.spacedBy(8.dp),
) {
    Text("Plan", style = MaterialTheme.typography.titleLarge)
    Text("Pro")
}
column((
    label("Plan").font(Font::Title),
    label("Pro"),
))
.spacing(8.0)
.align(HAlign::Leading)
.padding(16.0)
.background(Color::hex(0x1E293B))
.corner_radius(12.0)

Font::Title resolves to each platform’s typography scale, not a theme object you define. Day inherits the platform’s look rather than shipping a design system. The flip side is a real limit: there’s no MaterialTheme equivalent to recolor control chrome, and no portable way to restyle a slider track or checkbox. Styling lists what you can restyle and what stays native, and tweaks reach per-platform knobs when one platform has the setting you need.

State management: ViewModels, flows, and threads

The ViewModel + StateFlow + collectAsStateWithLifecycle pipeline exists to move state out of recomposition and survive configuration changes. Day needs neither: signals already live outside the tree (module statics, structs, wherever), and there’s no activity recreation to survive. The window is Day’s, on every platform.

#[derive(Clone, Copy)]
struct Library {
    albums: Signal<Vec<Album>>,
    query: Signal<String>,
}

impl Library {
    fn filtered(self) -> Memo<Vec<Album>> {
        Memo::new(move || {
            let q = self.query.get().to_lowercase();
            self.albums.with(|v| {
                v.iter().filter(|a| a.title.to_lowercase().contains(&q)).cloned().collect()
            })
        })
    }
}

Threading is where Rust’s compiler does what conventions do in Kotlin. Signal is !Send: a background thread can’t touch one, at compile time. The door back is a Setter (a Send, write-only handle that marshals to the main thread, like withContext(Dispatchers.Main) with the dispatcher rule enforced by types), or day::task, whose continuations land on the UI thread:

let progress = Signal::new(0.0);
let tick = progress.setter();          // Setter<f64>: Send + Copy

std::thread::spawn(move || {
    for step in 0..100 {
        // heavy work…
        tick.set(step as f64 / 100.0); // hops to the main thread
    }
});

A Setter write after the owning page is gone is a silent no-op: the case viewModelScope cancellation handles, done by scope teardown instead.

Package configuration

Compose — GradleDay
// build.gradle.kts (module)
android {
    namespace = "dev.example.fieldnotes"
    compileSdk = 36
    defaultConfig {
        applicationId = "dev.example.fieldnotes"
        minSdk = 26
        versionName = "1.2.0"
        versionCode = 34
    }
    buildFeatures { compose = true }
}

dependencies {
    implementation(platform(libs.compose.bom))
    implementation(libs.compose.material3)
    implementation(libs.navigation.compose)
}
# Cargo.toml — the package
[package]
name = "field-notes"
version = "1.2.0"
edition = "2024"

[dependencies]
day = { git = "https://github.com/daybrite/day.git" }

# Day.toml — the app manifest
[app]
id = "dev.example.fieldnotes"
title = "Field Notes"
build = 34
targets = ["android-mdc", "ios-uikit", "macos-appkit"]

[permissions]
camera = "Scan shelf barcodes to add albums."

A Gradle project still exists (platform/android/ is a thin host that loads the Rust cdylib), but it isn’t where configuration lives. Day.toml declares identity, version, window, and permissions once; day build writes the manifest entries (and the iOS plist keys, and the HarmonyOS profile) from it. A dependency with native platform code declares its Java sources, Gradle dependencies, and Maven repositories in its own crate metadata, and the host project reads them generically. Adding such a package to Cargo.toml is the whole integration.

Resources and localization

ComposeDay
// res/drawable-xhdpi/wave.png …
Image(painterResource(R.drawable.wave), null)

// res/values/strings.xml + plurals:
// <plurals name="unread">
//   <item quantity="one">1 unread</item>
//   <item quantity="other">%d unread</item>
// </plurals>
Text(pluralStringResource(R.plurals.unread, n, n))
// resource/images/wave.png + wave@2x.png …
image("wave")

// resource/locales/en/app.ftl:
//   unread = { $count ->
//       [one] 1 unread
//      *[other] { $count } unread
//   }
label(res::str::unread(count))   // generated, compile-checked

res::str plays the role of R: res::str::unread is generated at build time, so a missing key or wrong argument count is a compile error, and a plural selector is typed numeric. On Android the staging target is the res/ system: resource/images/ becomes density-bucketed res/drawable-* entries crunched by aapt2, fonts land in res/font/. The same sources also become asset catalogs on iOS and GResource bundles on GTK, from one directory. Unlike strings.xml, the locale is a runtime signal. Switching re-renders live without an activity recreation, and RTL mirrors the layout. See Resources and Localization.

Project structure and per-platform configuration

ComposeDay
FieldNotes/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle/libs.versions.toml
├── app/
│   ├── build.gradle.kts
│   └── src/main/
│       ├── AndroidManifest.xml
│       ├── java/…/MainActivity.kt
│       └── res/
└── (per extra platform: another
     module or a KMP source set)
field-notes/
├── Cargo.toml
├── Day.toml
├── src/
│   ├── lib.rs        # the app: pieces, signals, routes
│   └── main.rs       # desktop entry point
├── resource/         # assets/ images/ fonts/ icons/ locales/
├── dayscript/        # UI test flows
├── platform/
│   ├── android/      # Gradle host (thin; no app logic)
│   ├── ios/          # Xcode host
│   └── ohos/         # HarmonyOS host
└── build/day/        # everything generated

Per-platform values are Day.toml overrides (per platform, per toolkit, or per target, most specific wins) rather than flavors or source-set forks:

[app]
id = "dev.example.fieldnotes"
title = "Field Notes"

[app.android]
title = "Field Notes Mobile"        # only Android sees this

[app.macos-appkit]
id = "dev.example.fieldnotes.mac"   # only the macOS target sees this

Android Studio still works when you want it: opening platform/android/ gives you the debugger and profilers, and Gradle calls back into day to rebuild the Rust half.

Building and shipping

TaskComposeDay
run in devRun in Android Studioday launch -p android-mdc
iterate@Preview, Live Editday relaunch --all-running (no previews)
Play Store buildbundleRelease + signing configday pack -p android-mdc → signed .apk + .aab, apksigner-verified, 16 KB-alignment checked
other platformsCompose Multiplatform (its own renderer everywhere)day pack -p ios-uikit / -p macos-appkit / -p windows-xaml / -p linux-gtk / -p harmony-arkui; day build -p web-dom — native widgets on each
UI testscreateComposeRule, Espressodayscript — one YAML script drives every platform
environment checkAndroid Studio / doctor scriptsday doctor

Release signing reads the keystore from ${ENV_VAR} references in Day.toml (the same signingConfig idea), but a missing variable degrades to a dev-signed artifact with a warning instead of failing, so CI forks and fresh laptops still build. Packaging has the per-target details.

Where to go next

  • Getting started — install, scaffold, first launch.
  • API tour — the whole authoring surface in one pass.
  • Reactivity — signals vs. snapshot state, in depth.
  • Why Day — the frank comparison, including when Compose is the better fit.