iOS/macOS
Let's start with the new part, and also typically the shorter part – implementing the capabilities.
Capability implementation
This is what Weather's core.swift looks like
@MainActor
class Core: ObservableObject {
@Published var view: ViewModel
private let logger = Logger(subsystem: "com.example.weather", category: "Core")
private let keyValueStore: KeyValueStore
private var isInitialized = false
private var core: CoreFfi
init() {
logger.info("Initializing Core")
self.core = CoreFfi()
// swiftlint:disable:next force_try
self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
do {
self.keyValueStore = try KeyValueStore()
logger.debug("KeyValueStore initialized successfully")
} catch {
logger.error("Failed to initialize KeyValueStore: \(error.localizedDescription)")
fatalError("KeyValueStore initialization failed: \(error)")
}
}
func update(_ event: Event) {
// swiftlint:disable:next force_try
let effects = [UInt8](core.update(Data(try! event.bincodeSerialize())))
// swiftlint:disable:next force_try
let requests: [Request] = try! .bincodeDeserialize(input: effects)
for request in requests {
processEffect(request)
}
}
func processEffect(_ request: Request) {
// ...
}
}
It's slightly more complicated, but broadly the same as the Counter's core.
We have an extra logger which is not really important for us, and we
also hold on to a KeyValueStore, which is the storage for the key-value
implementation.
We've truncated the processEffect method, because it's fairly long, but the basic
structure is this:
func processEffect(_ request: Request) {
switch request.effect {
case .render:
DispatchQueue.main.async {
self.view = try! .bincodeDeserialize(input: [UInt8](self.core.view()))
}
case .http(let req):
// ...
case .keyValue(let keyValue):
// ...
case .location(let locationOp):
// ...
}
}
We get a Request, and do an exhaustive match on what the requested effect is. In Swift we have tagged unions, so we can also destructure the operation requested.
We can have a look at what the HTTP branch does:
case .http(let req):
handleHttp(request, req)
This delegates to handleHttp, which does the actual work:
private func handleHttp(_ request: Request, _ req: HttpRequest) {
logger.info("Making HTTP request to: \(req.url)")
Task {
do {
let response = try await requestHttp(req).get()
logger.debug("Received HTTP response with status: \(response.status)")
// swiftlint:disable:next force_try
let data = Data(try! HttpResult.ok(response).bincodeSerialize())
resolveEffects(request.id, data)
} catch {
logger.error("HTTP request failed: \(error.localizedDescription)")
}
}
}
We start a new Task to run this job off the main thread, then use the
async requestHttp() call to run the request.
Then it takes the response, serializes it and passes it to core.resolve via resolveEffects, which
returns more effect requests. This is perhaps unexpected, but it's the direct
consequence of the Commands async nature. There can easily be a command which
does something along the lines of:
Command::new(|ctx| {
let http_req = Http::get(url).expect_json<Counter>().build().into_future(ctx);
let resp = http_req.await; // effect 1
let counter = resp.map(|result| match result {
Ok(mut response) => match response.take_body() {
Some(counter) => {
Ok(results)
}
None => Err(ApiError::ParseError),
},
Err(_) => Err(ApiError::NetworkError),
});
let _ = KeyValue::set(COUNTER, counter).into_future(ctx).await // effect 2
// ...
ctx.send_event(Event::Done);
})
Once we resolve the http request at the .await point marked "effect 1", this future can
proceed and make a KeyValue request at the "effect 2" .await point. So on the
shell end, we need to be able to respond appropriately.
What we do is loop through those effect requests (there could easily be multiple requests
at once), go through them and recurse - call processEffect again to handle it.
Just for completeness, this is what requestHttp looks like:
import App
import SwiftUI
enum HttpError: Error {
case generic(Error)
case message(String)
}
func requestHttp(_ request: HttpRequest) async -> Result<HttpResponse, HttpError> {
var req = URLRequest(url: URL(string: request.url)!)
req.httpMethod = request.method
for header in request.headers {
req.addValue(header.value, forHTTPHeaderField: header.name)
}
do {
let (data, response) = try await URLSession.shared.data(for: req)
if let httpResponse = response as? HTTPURLResponse {
let status = UInt16(httpResponse.statusCode)
let body = [UInt8](data)
return .success(HttpResponse(status: status, headers: [], body: body))
} else {
return .failure(.message("bad response"))
}
} catch {
return .failure(.generic(error))
}
}
Not that interesting, it's a wrapper around URLRequest and friends which takes and
returns the generated HttpRequest and HttpResponse, originally defined in Rust by
crux_http.
The pattern repeats similarly for key-value store and the location capability.
User interface and navigation
It's worth looking at how Weather handles the Workflow navigation in SwiftUI.
As in the Counter example, the Weather's core has a @Published var view: ViewModel
which we can use in the Views.
Here's the root content view:
struct ContentView: View {
@ObservedObject var core: Core
init(core: Core) {
self.core = core
}
var body: some View {
NavigationStack {
ZStack {
// Base background that's always present
Color(platformGroupedBackground)
.ignoresSafeArea()
// Content views
switch core.view.workflow {
case .home:
HomeView(core: core)
.transition(
.opacity.combined(with: .offset(x: 0, y: 10))
)
case .favorites:
FavoritesView(core: core)
.transition(
.opacity.combined(with: .offset(x: 0, y: 10))
)
case .addFavorite:
AddFavoriteView(core: core)
.transition(
.opacity.combined(with: .offset(x: 0, y: 10))
)
}
}
.animation(.easeOut(duration: 0.2), value: core.view.workflow)
}
}
}
Thanks to the declarative nature of SwiftUI, we can show the view we need to, depending on the workflow, and pass the core down.
We could do this differently - core could stay in the root view and we could pass
an update callback in an environment, and just the appropriate section of the
view model to each view, it's up to you how you want to go about it.
Let's look at the HomeView as well, just to complete the picture:
struct HomeView: View {
@ObservedObject var core: Core
@State private var hasLoadedInitialData = false
@State private var selectedPage = 0
var body: some View {
Group {
if case .home(let weatherData, let favorites) = core.view.workflow {
VStack {
TabView(selection: $selectedPage) {
// Main weather card
Group {
if weatherData.cod == 200 && weatherData.main.temp.isFinite {
WeatherCard(weatherData: weatherData)
.transition(.opacity)
} else {
LoadingCard()
}
}
.tag(0)
.tabItem { Label(weatherData.name.isEmpty ? "Current" : weatherData.name, systemImage: "location") }
// Favorite weather cards
ForEach(Array(favorites.enumerated()), id: \.element.name) { idx, favorite in
Group {
if let current = favorite.current {
WeatherCard(weatherData: current)
.transition(.opacity)
} else {
LoadingCard()
}
}
.tag(idx + 1)
.tabItem { Label(favorite.name, systemImage: "star") }
}
}
#if os(iOS)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
#endif
}
.padding(.vertical)
.toolbar {
ToolbarItem(placement: .automatic) {
Button {
withAnimation(.easeOut(duration: 0.2)) {
core.update(.navigate(.favorites(FavoritesState.idle)))
}
} label: {
Image(systemName: "star")
}
}
}
} else {
Color.clear // Placeholder for transition
}
}
.onAppear {
if !hasLoadedInitialData {
core.update(.home(.show))
hasLoadedInitialData = true
}
}
}
}
It simply caters for the possible situations in the view model, draws the
weather cards for each favorite and adds a toolbar with an item, which
when tapped calls core.update with the swift equivalent of the .navigate
event we saw earlier in the call.
This is quite a simple navigation setup in that it is a static set of screens
we're managing. Sometimes a more dynamic navigation is necessary, but
SwiftUI's NavigationStack in recent iOS supports quite complex scenarios in
a declarative fashion using NavigationPath,
so the general principle of naively projecting the view model into the user
interface broadly works even there.
There isn't much more to it, the rest of the app is rinse and repeat. It is relatively rare to implement a new capability, so most of the work is in finessing the user interface. Crux tends to work reasonably well with SwiftUI previews as well so you can typically avoid the Simulator or device for the inner development loop.
What's next
Congratulations! You know now all you will likely need to build Crux apps. The following parts of the book will cover advanced topics, other support platforms, and internals of Crux, should you be interested in how things work.
Happy building!