Embed SwiftUI views
Day renders native widgets from Rust, but sometimes the view you want already exists in SwiftUI,
or is easiest to build there: a custom chart, a control from an in-house Swift package, a screen
you’re migrating incrementally. On macos-appkit and ios-uikit, Day hosts your own SwiftUI
views inside the Day tree. You write ordinary SwiftUI in an ordinary SwiftPM package; Day
generates a typed Rust constructor per view, so the call site is:
crate::swiftui::TemperatureDial(room_name, 21.5)
Works on: macos-appkit, ios-uikit. Every other target compiles the same code and you
gate the UI with a support probe (below). The full contract is in
the swiftui reference.
1. Write the package
Create a normal SwiftPM package in your project, conventionally at swiftui/:
my-app/
└── swiftui/
├── Package.swift # name it after your module, e.g. MyAppViews
└── Sources/MyAppViews/
└── TemperatureDial.swift
The views you want to call from Rust are top-level public structs conforming to View, with a
public init whose parameters are String, Int, Double, or Bool:
import SwiftUI
public struct TemperatureDial: View {
let room: String
let celsius: Double
public init(room: String, celsius: Double) {
self.room = room
self.celsius = celsius
}
public var body: some View {
VStack {
Gauge(value: celsius, in: 0...40) { Text(room) }
Text("\(celsius, specifier: "%.1f") °C")
}
}
}
It’s a real package: it can depend on other SwiftPM packages, and swift test works in it.
Internal types stay internal — only public View structs are exported. Views whose init uses
other types (a model struct, a closure) are skipped with a build warning; the escape hatch below
covers them.
2. Declare it
Two additions to the app’s Cargo.toml:
[dependencies]
day-piece-swiftui = { git = "https://github.com/daybrite/day.git" }
[package.metadata.day.ios]
swift-packages = [{ path = "swiftui", products = ["MyAppViews"] }]
platform = "16.0" # optional: raise the floor for newer SwiftUI APIs
[package.metadata.day.macos]
swift-packages = [{ path = "swiftui", products = ["MyAppViews"] }]
platform = "13.0"
And one module in src/lib.rs, next to the res module the scaffold already has:
pub mod swiftui {
include!(concat!(env!("OUT_DIR"), "/day_swiftui.rs"));
}
That’s the whole setup. Your build.rs (the scaffold’s day_build::generate_resources()) scans
the package and writes one constructor per exported view; day build compiles the package into
the app and generates the hosting glue. On iOS the package joins the generated DayPieces
SwiftPM package the Xcode scaffold already links; on macOS day build runs a swift build
prepass and statically links the result, with no Xcode project involved.
3. Call it
The generated constructor mirrors the Swift init exactly: same name, same parameters, in order.
A renamed view or a changed parameter is a Rust compile error, the same contract as the
generated res:: resource constants:
use day::prelude::*;
fn climate_card(temp: Signal<f64>) -> AnyPiece {
if day_piece_swiftui::support() != Support::Native {
return label(crate::res::str::not_on_this_platform()).any();
}
crate::swiftui::TemperatureDial(String::from("Living room"), move || temp.get())
.frame(220.0, 220.0)
.id("climate-dial")
.any()
}
Two things to notice:
- Arguments are reactive. Each parameter takes a constant, a
Signal, or a closure. When a reactive argument changes, Day re-invokes the view’s initializer with the new values, and SwiftUI reconciles it like any parent-driven update, so@Stateinside the view survives. - Gate with
support(), notcfg.day_piece_swiftui::support()isNativeonly onmacos-appkitandios-uikit. A#[cfg(target_os = "macos")]is the wrong gate: it is also true onmacos-gtkandmacos-qt, where there is no AppKit view tree to host into.
The hosted view fills the space it’s offered, like image or canvas; constrain it with
.frame(w, h) when it shouldn’t.
Keeping state across navigation
Leaving the view’s branch (a tab switch, a when() going false, a page navigation) disposes
the hosting view and the @State it owns. When the view should hold its state instead, give it
a key:
crate::swiftui::TemperatureDial(String::from("Living room"), move || temp.get())
.state_key("climate-dial")
Day then retains the hosting view under that key and hands the same instance back on the next
mount: sliders, scroll positions, @State, and @StateObject all survive, and the mount’s
current arguments are re-applied. Two rules: at most one mounted view per key, and a key pins
its view for the app’s lifetime; use it for the handful of views that want persistence, not
per-row content.
When the scan isn’t enough
For views the generated path can’t express (an init taking a model type, a delegate,
dynamic content), use the provider escape hatch. Subclass the provider in Swift, name
it @objc(DayView_<name>), and call it by name from Rust:
@objc(DayView_history_chart)
final class HistoryChartProvider: DaySwiftUIProvider {
override func body(_ params: String?) -> AnyView {
AnyView(HistoryChart(model: decode(params)))
}
}
day_piece_swiftui::swiftui("history_chart")
.params(move || samples_as_json.get())
params is one JSON string, reactive like the typed arguments. The generated constructors are
this same mechanism with the ceremony generated for you.
Pitfalls
- Localized labels don’t localize themselves. Strings inside the Swift package don’t go
through Fluent. Pass them in as arguments (
res::str::…().format()closures on the Rust side), so the hosted view follows the app’s locale, including right-to-left layout, which the hosting view inherits. - Declare the floor you need. SwiftUI APIs like
Gridneed iOS 16 / macOS 13; declareplatformandday buildraises the deployment target for you. Xcode ⌘R builds don’t see the override; raiseplatform/ios/DayApp.xcodeprojby hand if you build from the IDE. - Provider not found renders
⟨name?⟩. A misspelled@objc(DayView_…)name or a package missing from the metadata shows a visible error view rather than crashing; the reference has the checklist.
Reference
swiftui — the scanned subset, hosting and ownership details, the macOS build leg, and every v1 limit. The extension model behind it is extending.