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
| SwiftUI | Day |
|---|---|
struct MyView: View + body | a plain function returning a Piece |
@State private var count | let count = Signal::new(…) |
@Binding | pass the Signal itself; it’s Copy |
@Observable class / ObservableObject | a struct holding Signals and Memos |
body re-evaluated on change | a binding re-runs and updates its widget |
VStack / HStack / ZStack / Spacer | column / row / zstack / spacer() |
.font(.title) / .padding() | .font(Font::Title) / .padding(16.0) |
List / ForEach(id:) | list / each — the key function is required |
NavigationSplitView | nav(…).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 / @Environment | with_environment / environment::<T>() |
Task { } + @MainActor | day::task — continuations land on the UI thread |
String Catalogs (.xcstrings) | Fluent .ftl + generated res::str functions |
| asset catalogs | the resource/ directory, staged per platform |
| Xcode project + SPM | Cargo.toml + Day.toml, editable in any editor |
| Archive → Organizer → notarize / upload | day pack -p <target> |
The same counter in both
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.
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
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.
Navigation
Day’s two navigation Pieces line up with the two SwiftUI containers, down to binding a path collection for push/pop:
@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.
@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.
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:
// 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 = 640There 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
// 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
| Task | SwiftUI | Day |
|---|---|---|
| run in dev | Xcode Run (⌘R) | day launch -p macos-appkit |
| iterate | Previews / rebuild | day relaunch --all-running (no previews) |
| macOS release | Archive → notarize → staple → DMG (your tooling) | day pack -p macos-appkit → signed, notarized, stapled .dmg |
| iOS release | Archive → Organizer / xcodebuild -exportArchive | day pack -p ios-uikit → .ipa (App Store Connect export) |
| TestFlight / App Store upload | Organizer or Transporter | upload the .ipa with your usual tool; day pack stops at the artifact |
| Android / Windows / Linux / HarmonyOS / web | — | day pack -p android-mdc / -p windows-xaml / -p linux-gtk / -p harmony-arkui; day build -p web-dom |
| UI tests | XCUITest | dayscript — 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.