Day for SwiftUI developers

Day uses declarative composition, modifier chains, and a layout protocol similar to SwiftUI’s. Its main differences are Rust, signal-based updates, and support for non-Apple platforms. This guide compares the APIs and build tools. Existing SwiftUI views can also be embedded on Apple targets.

SwiftUI terms in Day

SwiftUIDay
struct MyView: View + bodya plain function returning a Piece
@State private var countlet count = Signal::new(…)
@Bindingpass the Signal itself; it’s Copy
@Observable class / ObservableObjecta struct holding Signals and Memos
body re-evaluated on changea binding re-runs and updates its widget
VStack / HStack / ZStack / Spacercolumn / row / zstack / spacer()
.font(.title) / .padding().font(Font::Title) / .padding(16.0)
List / ForEach(id:)list / each — the key function is required
NavigationSplitViewnav(…).style(NavStyle::Sidebar)
NavigationStack(path:)nav_stack(path, root), driven by a path signal
@Environment(\.horizontalSizeClass)size_class() — five width buckets, one table on every backend
.environment / @Environmentwith_environment / environment::<T>()
Task { } + @MainActorday::task — continuations land on the UI thread
String Catalogs (.xcstrings)Fluent .ftl + generated res::str functions
asset catalogsthe resource/ directory, staged per platform
Xcode project + SPMCargo.toml + Day.toml, editable in any editor
Archive → Organizer → notarize / uploadday pack -p <target>

The same counter in both

SwiftUIDay
struct Counter: View {
    @State private var count = 0

    var body: some View {
        VStack(spacing: 12) {
            Text("\(count) clicks")
            Button("+") { count += 1 }
        }
        .padding(16)
    }
}
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)
}

The structure is nearly identical, but the runtime behavior differs. SwiftUI re-evaluates body when count changes and diffs the result; Day re-runs only the label’s closure and sets one native string. You mark what’s dynamic yourself: a closure makes text live, and a bare value doesn’t. In exchange, an update’s cost is proportional to the number of readers of the changed value and independent of the size of the view. Reactivity explains the model.

Passing signals as bindings

SwiftUI splits state into @State (ownership) and $binding (projection). A Signal is both: it’s a Copy handle, so “passing a binding” is passing the signal.

SwiftUIDay
struct SettingsForm: View {
    @State private var name = ""
    @State private var volume = 40.0
    @State private var subscribed = false

    var body: some View {
        Form {
            Section("Profile") {
                TextField("Your name", text: $name)
                Slider(value: $volume, in: 0...100)
                Toggle("Subscribe", isOn: $subscribed)
            }
        }
    }
}
fn settings_form() -> impl Piece {
    let name = Signal::new(String::new());
    let volume = Signal::new(40.0);
    let subscribed = Signal::new(false);

    form((section((
        text_field(name).placeholder("Your name"),
        slider(volume).range(0.0..=100.0),
        labeled("Subscribe", toggle(subscribed)),
    ))
    .title("Profile"),))
}

Where you’d reach for @Observable, group signals in a plain struct and pass it around. Every field is independently observable, and the struct is plain Rust:

#[derive(Clone, Copy)]
struct Player {
    track: Signal<Option<TrackId>>,
    position: Signal<f64>,
    playing: Signal<bool>,
}

Lists

SwiftUIDay
List(albums, id: \.id) { album in
    Text(album.title)
}
list(
    move || albums.get(),
    |a| a.id,
    |slot: ItemSlot<Album, u64>| {
        label(move || slot.get().title).padding(8.0)
    },
)

list recycles native cells the way List does; each is the non-recycling ForEach equivalent for short collections. The id: key path becomes an explicit key function, and rows keep their own state across reorders by key.

Day’s two navigation Pieces line up with the two SwiftUI containers, down to binding a path collection for push/pop:

SwiftUIDay
@State private var path: [String] = []

NavigationStack(path: $path) {
    HomeView()
        .navigationDestination(for: String.self) { key in
            AlbumView(id: key)
        }
}

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

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

// push
path.update(|p| p.push("album-42".into()));
// or, from anywhere:
navigate("album-42");

NavigationSplitView maps to nav(…).style(NavStyle::Sidebar): an NSSplitView source list on macOS, and the platform’s idiom elsewhere (a pushing list on phones, libadwaita’s split view on GTK). On top of the signals sits a route layer: navigate("library/album-42"), current_route(), and DAY_DEEPLINK cover programmatic navigation, state restoration, and deep links with one set of strings, the job onOpenURL and scene restoration do separately. See Navigation.

Size classes

Day keeps Apple’s size-class idea, with a finer table and one number for every platform. @Environment(\.horizontalSizeClass) answers .compact or .regular; day::size_class() answers a WidthClass of Compact, Medium, Expanded, Large, or ExtraLarge, from one breakpoint table (Android’s) applied on every backend. So a 700pt window is Medium on a Mac, on a tablet, and in a browser.

SwiftUIDay
@Environment(\.horizontalSizeClass) private var sizeClass

var body: some View {
    if sizeClass == .regular {
        NavigationSplitView { Sidebar() } detail: { Detail() }
    } else {
        NavigationStack { Sidebar() }
    }
}
// A nav host already does this: it re-presents on every breakpoint crossing
// and re-homes the pages it built, so scroll offsets, focus, and the search
// query survive the morph.
nav(page)
    .style(NavStyle::Sidebar)
    .destination(|key| page_for(key))

// Where a view wants to make the same call itself:
let two_up = size_class().is_some_and(|c| c.width >= WidthClass::Expanded);

Like SwiftUI’s environment value, the read is tracked and per window: a piece that lays out from the class rebuilds when its own window crosses a breakpoint, and a second window in a different size gets its own answer.

Modifiers, fonts, and the layout protocol

Day uses SwiftUI’s layout algorithm: parents propose sizes, children choose, text is measured by the platform (height-for-width), and layout re-enters at boundaries rather than from the root. Spacer pushes, .frame fixes a size, and padding wraps. Modifier ordering works the same way: padding before background pads inside the fill.

SwiftUIDay
VStack(alignment: .leading, spacing: 8) {
    Text("Plan").font(.title)
    Text("Pro").font(.body)
}
.padding(16)
.background(Color(red: 0.12, green: 0.16, blue: 0.23))
.cornerRadius(12)
column((
    label("Plan").font(Font::Title),
    label("Pro").font(Font::Body),
))
.spacing(8.0)
.align(HAlign::Leading)
.padding(16.0)
.background(Color::rgb(0.12, 0.16, 0.23))
.corner_radius(12.0)

The semantic font roles (Title, Headline, Body, Caption, …) map to each platform’s typography scale (on Apple targets, the same text styles). SwiftUI’s semantic colors (Color.primary, automatic dark-mode adaptation for your own colors) have no Day equivalent yet. Native widget chrome follows the system appearance on its own, but hardcoded custom colors are yours to switch per appearance for now. Styling lists what you can and can’t restyle.

Bringing SwiftUI views with you

You don’t have to port every view. On macos-appkit and ios-uikit, Day embeds your own SwiftUI: keep the views in an ordinary local SwiftPM package of public View structs (testable with swift test, with their own SwiftPM dependencies), declare the package once in Cargo.toml, and day build compiles it into the app, wrapping each exported view in an NSHostingView / UIHostingController. build.rs generates a typed Rust constructor per view:

# Cargo.toml
[package.metadata.day.macos]
swift-packages = [{ path = "swiftui", products = ["MyViews"] }]
// generated from `public struct AlbumChart: View { public init(albumId: Int, zoom: Double) }`
crate::swiftui::AlbumChart(album_id, zoom)
    .state_key("album-chart")     // keep the hosting view (and its @State) across unmount
    .frame(320.0, 240.0)

Each argument takes a constant, a Signal, or a closure; a reactive argument re-invokes the view’s initializer live, and SwiftUI reconciles that like any parent-driven update, so the view’s @State survives. A renamed view or a changed parameter is a Rust compile error at the call site, the same contract the generated res::str accessors give strings. Views the scan can’t express (delegates, @ViewBuilder content) use the provider API: swiftui("name") resolves an @objc(DayView_name) provider class you write. The SwiftUI embedding reference covers the scanned subset, .state_key rules, and the per-platform build details.

For migration this means moving incrementally: port the shell and shared pages to Day, keep the SwiftUI views that matter as they are on Apple targets, and give each a Day-native counterpart only when the other platforms need it.

Concurrency

day::task plays the role of Task with @MainActor semantics: the future’s continuations run on the UI thread, so touching state after an await is ordinary code. The compiler enforces the boundary the way Swift 6 strict concurrency does: Signal is !Send, so a background thread can’t touch one; it gets a Setter, a Send write-only handle that marshals to the main thread and silently drops the write if the owning page is gone (the task-outlives-view case).

let status = Signal::new(String::new());

day::task(async move {
    match day_part_http::fetch_future(request).await {         // suspend
        Ok(resp) => status.set(resp.text().into_owned()),      // back on the UI thread
        Err(e) => status.set(format!("error: {e}")),
    }
});

Cancellation is explicit: day::task returns a TaskHandle, and handle.abort() drops the future, which cancels an in-flight platform request. Tasks are not tied to the scope that spawned them; Resource, the declarative fetch wrapper, adds scope-tied cancellation on top.

Package configuration

An Xcode project holds targets, build settings, signing, capabilities, and package dependencies. Day splits that into two small text files:

SwiftUI — Xcode + SPMDay
// Package.swift (if you use SPM for code),
// plus project.pbxproj for: targets, bundle id,
// version/build, entitlements, asset catalogs,
// signing team, Info.plist keys…
let package = Package(
    name: "FieldNotes",
    dependencies: [
        .package(
            url: "https://github.com/example/dep",
            from: "1.0.0"
        ),
    ]
)
# 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 = ["macos-appkit", "ios-uikit", "android-mdc"]

[window]
width = 480
height = 640

There is still an Xcode project (platform/ios/ holds a host that links the Rust static library), but it’s a shell you rarely touch: identity, version, icons, and permissions flow from Day.toml at build time. Opening it and pressing Run works (Xcode calls back into day for the Rust half), so Instruments and the debugger stay available.

Resources and localization

SwiftUIDay
// Assets.xcassets: wave.imageset (1x/2x/3x),
// AccentColor, AppIcon…
Image("wave")

// Localizable.xcstrings:
Text("unread-count \(unread)")

// bundled data
let url = Bundle.main.url(
    forResource: "stations", withExtension: "json")!
let data = try Data(contentsOf: url)
// resource/images/wave.png + wave@2x.png + wave@3x.png
image(res::images::wave)                 // generated constant; a typo is a compile error

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

// resource/assets/stations.json — zero-copy view
let data = day::resource(res::assets::stations_json)?;

On iOS, resource/images/ is staged into an asset catalog and compiled by actool (the same optimized Assets.car you’d get from Xcode), and fonts land in the bundle with the UIAppFonts plist entry written for you; on macOS images ship as ordinary bundle files rather than a compiled catalog. Localization uses Mozilla Fluent instead of String Catalogs: plural rules live in the message, res::str::* accessors are generated so a bad key is a compile error, and adding a language is adding a resource/locales/<lang>/ directory. The locale is a signal. Switching re-renders live, and RTL mirrors the layout. See Resources and Localization.

Project structure and per-platform configuration

field-notes/
├── Cargo.toml            # package: name, version, dependencies
├── Day.toml              # app: id, title, targets, window, signing
├── src/
│   ├── lib.rs            # the app: pieces, signals, routes
│   └── main.rs           # desktop entry point
├── resource/             # assets/ images/ fonts/ icons/ locales/
├── dayscript/            # UI test flows
├── platform/
│   ├── ios/              # Xcode host (thin; no app logic)
│   ├── android/          # Gradle host
│   └── ohos/             # HarmonyOS host
└── build/day/            # everything generated

Per-platform configuration is a Day.toml override, and it extends to every target, including the ones without an Xcode project:

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

[app.ios]
title = "Field Notes Mobile"        # only iOS sees this

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

Day’s macOS target is an AppKit app: NSButton, NSSplitView source lists, and menu bar items.

Building and shipping

TaskSwiftUIDay
run in devXcode Run (⌘R)day launch -p macos-appkit
iteratePreviews / rebuildday relaunch --all-running (no previews)
macOS releaseArchive → notarize → staple → DMG (your tooling)day pack -p macos-appkit → signed, notarized, stapled .dmg
iOS releaseArchive → Organizer / xcodebuild -exportArchiveday pack -p ios-uikit.ipa (App Store Connect export)
TestFlight / App Store uploadOrganizer or Transporterupload the .ipa with your usual tool; day pack stops at the artifact
Android / Windows / Linux / HarmonyOS / webday pack -p android-mdc / -p windows-xaml / -p linux-gtk / -p harmony-arkui; day build -p web-dom
UI testsXCUITestdayscript — one YAML script drives every platform

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

Signing works headless and in CI: identities and keys are ${ENV_VAR} references in Day.toml, day sign check reports readiness without printing secrets, and a missing variable degrades that platform to a dev-signed artifact with a warning instead of failing the build. Packaging has the details.

Where to go next

  • Getting started — install, scaffold, first launch.
  • API tour — examples of common UI components and patterns.
  • Layout — the negotiation protocol.
  • Why Day compares the approaches and says when SwiftUI alone is enough.