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 ComposeDay
@Composable funa plain function returning a Piece (no compiler plugin)
remember { mutableStateOf(x) }Signal::new(x); the function runs once, so there is no remember
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 + NavControllernav, nav_stack, and string routes
currentWindowAdaptiveInfo().windowSizeClasssize_class() — the same breakpoint table, everywhere
CompositionLocalwith_environment / environment::<T>()
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() -> 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.

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

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

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

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

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.

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

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

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

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(res::images::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 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

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

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

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.