Day for Jetpack Compose developers
Day and Compose both track which code reads state, but update the UI differently. Compose recomposes functions; Day builds the piece tree once and re-runs bindings to update native widgets. Day uses Material Components on Android and the corresponding toolkit on other platforms. This guide compares the APIs, state model, packages, and build tools.
Compose terms in Day
| Jetpack Compose | Day |
|---|---|
@Composable fun | a plain function returning a Piece (no compiler plugin) |
remember { mutableStateOf(x) } | Signal::new(x); the function runs once, so there is no remember |
derivedStateOf | Memo::new |
LaunchedEffect / DisposableEffect | Effect::new / watch; scopes clean up automatically |
| recomposition + skipping | no recomposition; one binding patches one widget |
Column / Row / Box / Spacer | column / row / zstack / spacer() |
Modifier.padding(16.dp) | .padding(16.0) — modifiers are builder methods |
LazyColumn | list — recycling rows, key required |
NavHost + NavController | nav, nav_stack, and string routes |
currentWindowAdaptiveInfo().windowSizeClass | size_class() — the same breakpoint table, everywhere |
CompositionLocal | with_environment / environment::<T>() |
ViewModel + StateFlow | structs of signals; Setter for background threads |
| Gradle + version catalogs | Cargo.toml (package) + Day.toml (app manifest) |
res/drawable, res/font | resource/images/, resource/fonts/ — staged into res/ at build |
strings.xml + plurals | Fluent .ftl + generated res::str functions |
@Preview | none — day launch + dayscript screenshots |
bundleRelease + Play Console | day pack -p android-mdc → signed .apk + .aab |
The same counter in both
@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() -> impl Piece {
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)
}There’s no remember because the function runs once, so state never needs to survive a
re-execution. The label’s closure is the only code that runs on a tap, and it ends in one native
setText. Reactivity covers the model and its cost: you mark what’s dynamic
yourself (a closure makes text live; a bare value doesn’t).
Two-way controls
Compose controls are stateless value-plus-callback pairs. Day’s take signals directly, with the write-back built in.
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),
labeled("Subscribe", toggle(subscribed)),
))
.spacing(12.0)On Android these are Material Components views (TextInputLayout, MaterialSwitch), so they track
the OS’s Material version rather than your Compose BOM.
Lists
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, where Compose’s key lambda is optional.
And list recycles native cells (a RecyclerView on Android), so the row builder runs per recycled
slot rather than per composition.
Navigation
NavController owns a back stack you push routes onto. In Day 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).
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());
nav_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 nav, which renders each
platform’s idiom: tabs on phones, a sidebar split view on desktop. See
Navigation.
Adaptive layout
SizeClass uses Android’s window size classes unchanged on every backend, so a 700pt window is
Medium on a Mac, in a browser, and on a tablet. In Compose you pick the scaffold per class; in Day
the nav re-presents itself.
val width = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass
NavigationSuiteScaffold(
layoutType = if (width == WindowWidthSizeClass.COMPACT)
NavigationSuiteType.NavigationBar
else
NavigationSuiteType.NavigationRail,
navigationSuiteItems = { items.forEach { item(it) } },
) { Page(page) }// One nav host, both presentations. Day re-resolves on every breakpoint
// crossing and re-homes the pages it already built, so scroll offsets and
// focus survive the morph rather than being rebuilt.
nav(page)
.style(NavStyle::Sidebar)
.destination(|key| page_for(key))
// Where the app wants to make the same call itself:
let two_up = size_class().is_some_and(|c| c.width >= WidthClass::Expanded);On Android the backend hands the class to SlidingPaneLayout, which morphs with its own
animation and gesture and then reports what it chose; Day follows the platform’s choice where
the platform already makes one (details). The class is per
window, so split-screen and freeform windows each get their own answer.
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 engine. Day’s equivalent of Modifier.weight(1f) is .grow(),
and containers don’t stretch children by default.
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. Day inherits the platform’s look and
ships no design system of its own, so there is no MaterialTheme equivalent to recolor control
chrome or to restyle a slider track or checkbox portably. 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 Day owns the window on every
platform, so there is no activity recreation to survive.
#[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()
})
})
}
}
For threading, the Rust compiler enforces the rule that Kotlin leaves to convention. Signal
is !Send: a background thread can’t touch one, at compile time. The way 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; scope teardown covers the
case viewModelScope cancellation handles.
Package configuration
// 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 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
the package to Cargo.toml is the only app-side step. On the Apple targets, Day can also embed your
own SwiftUI views (SwiftUI embedding).
Resources and localization
// 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(res::images::wave)
// resource/locales/en/app.ftl:
// unread = { $count ->
// [one] 1 unread
// *[other] { $count } unread
// }
label(res::str::unread(count)) // generated, compile-checkedres::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 itself: 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
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 generatedPer-platform values are Day.toml overrides (per platform, per toolkit, or per target, most
specific wins), which take the role of flavors and 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
| Task | Compose | Day |
|---|---|---|
| run in dev | Run in Android Studio | day launch -p android-mdc |
| iterate | @Preview, Live Edit | day relaunch --all-running (no previews) |
| Play Store build | bundleRelease + signing config | day pack -p android-mdc → signed .apk + .aab, apksigner-verified, 16 KB-alignment checked |
| other platforms | Compose 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 tests | createComposeRule, Espresso | dayscript — one YAML script drives every platform |
| environment check | Android Studio / doctor scripts | day doctor |
Maturity differs by target, and Platform support lists what each target has
today: every (OS, toolkit) pair carries a support tier, from
Tier 1 (thoroughly tested, with shipping apps on it) down to
Tier 4 (development combinations nobody ships).
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 — examples of common UI components and patterns.
- Reactivity — signals vs. snapshot state, in depth.
- Why Day compares the approaches and says when Compose fits better.