iOS — Swift and SwiftUI
In this section, we'll set up Xcode to build and run the simple counter app we built so far.
We think that using XcodeGen may be the simplest way to create an Xcode project to build and run a simple iOS app that calls into a shared core.
If you'd rather set up Xcode manually, you can do that, but most of this section will still apply. You just need to add the Swift package dependencies into your project by hand.
When we use Crux to build iOS apps, the Core API bindings are generated in Swift (with C headers) using Mozilla's UniFFI.
The shared core, which we built in previous chapters, is compiled to a static library and linked into the iOS binary.
The shared types are generated by Crux as a Swift package, which we can add to our iOS project as a dependency. The Swift code to serialize and deserialize these types across the boundary is also generated by Crux as Swift packages.
Compile our Rust shared library
When we build our iOS app, we also want to build the Rust core as a static library so that it can be linked into the binary that we're going to ship.
Other than Xcode and the Apple developer tools, we will use
cargo-swift to generate a
Swift package for our shared library, which we can add in Xcode.
To match our current version of UniFFI, we need to install version 0.9 of cargo-swift. You can install it with
cargo install cargo-swift --version '=0.9'
To run the various steps, we'll also use the Just task runner.
cargo install just
Let's write the Justfile and we can look at what happens:
# /iOS/Justfile
# Generate types for Swift, build the shared library as a Swift package and rebuild the Xcode project
build: typegen package generate-project
# clean and build
rebuild: clean build
# build and run Xcode
dev: build
xed .
# remove all the generated artefacts
clean:
cargo clean
rm -rf *.xcodeproj generated
# rebuild the Xcode project from the `project.yml` file
generate-project:
xcodegen
# generate types for Swift
typegen:
RUST_LOG=info cargo run \
--package shared \
--bin codegen \
--features codegen,facet_typegen \
-- \
--language swift \
--output-dir generated
# use `cargo swift` to build the shared library as a Swift package
[working-directory('../shared')]
package:
cargo swift --version | grep -q '0.9.0'
cargo swift package \
--name Shared \
--platforms ios \
--lib-type static \
--features uniffi
rm -rf generated
mkdir -p ../iOS/generated/Shared
cp -r Shared/* ../iOS/generated/Shared/
rm -rf Shared
We have quite a few tasks. The main one is dev which we'll use shortly. It
runs the build task and opens Xcode in the current directory.
build in turn runs typegen, package and generate-project. typegen
will use the codegen CLI we prepared earlier, and
package will use cargo swift to create a Shared package with our app binary and the
bindgen code. That package will be our Swift interface to the core.
Finally generate-project will run xcodegen to give us an Xcode
project file. They are famously fragile files and difficult to version control,
so generating it from a less arcane source of truth seems like a good idea
(yes, even if that source of truth is YAML).
Here's the project file:
# /iOS/project.yml
name: SimpleCounter
packages:
Shared:
path: ./generated/Shared
App:
path: ./generated/App
options:
bundleIdPrefix: com.crux.examples.simplecounter
attributes:
BuildIndependentTargetsInParallel: true
targets:
SimpleCounter:
type: application
platform: iOS
deploymentTarget: 18.0
sources: [SimpleCounter]
dependencies:
- package: Shared
- package: App
info:
path: SimpleCounter/Info.plist
properties:
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UILaunchScreen: {}
Nothing too special, other than linking a couple packages and using them as dependencies.
With that, you can run
just dev
Simple - just dev! So what exactly happened?
The core built, including the FFI and the extra CLI binary, which was then called
to generate Swift code, and that was then packaged as a Swift package. You can
look at the generated directory, and you'll see two Swift packages - Shared and App,
just like we asked in project.yml. The Shared package has our app as a static lib and all the
generated FFI code for our FFI bindings, and the App package has the key types we will need.
No need to spend much time in here, but this is all the low-level glue code sorted out. Now we need to actually build some UI and we can run our app.
Building the UI
To add some UI, we need to do three things: wrap the core with a simple Swift interface, build a basic View to give us something to put on screen, and use that view as our main app view.
Wrap the core
The generated code still works with byte buffers, so lets give ourselves a nicer interface for it:
// iOS/SimpleCounter/core.swift
import App
import UIKit
import Shared
@MainActor
class Core: ObservableObject {
@Published var view: ViewModel
private var core: CoreFfi
init() {
self.core = CoreFfi()
self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
}
func update(_ event: Event) {
let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
let requests: [Request] = try! .bincodeDeserialize(input: effects)
for request in requests {
processEffect(request)
}
}
func processEffect(_ request: Request) {
switch request.effect {
case .render:
DispatchQueue.main.async {
self.view = try! .bincodeDeserialize(input: [UInt8](self.core.view()))
}
}
}
}
This is mostly just serialization code. But the processEffect method is interesting.
That is where effect execution goes. At the moment the switch statement has a single
lonely case updating the view model whenever the .render variant is requested,
but you can add more in here later, as you expand your Effect type.
Build a basic view
Xcode should've generated a ContentView file for you in iOS/SimpleCounter/ContentView.swift.
Change it to look like this:
import SwiftUI
struct ContentView: View {
@ObservedObject var core: Core
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text(core.view.count)
HStack {
ActionButton(label: "Reset", color: .red) {
core.update(.reset)
}
ActionButton(label: "Inc", color: .green) {
core.update(.increment)
}
ActionButton(label: "Dec", color: .yellow) {
core.update(.decrement)
}
}
}
}
}
struct ActionButton: View {
var label: String
var color: Color
var action: () -> Void
init(label: String, color: Color, action: @escaping () -> Void) {
self.label = label
self.color = color
self.action = action
}
var body: some View {
Button(action: action) {
Text(label)
.fontWeight(.bold)
.font(.body)
.padding(EdgeInsets(top: 10, leading: 15, bottom: 10, trailing: 15))
.background(color)
.cornerRadius(10)
.foregroundColor(.white)
.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(core: Core())
}
}
And finally, make sure iOS/SimpleCounter/SimpleCounterApp.swift looks like this to use
the ContentView:
import SwiftUI
@main
struct SimpleCounterApp: App {
var body: some Scene {
WindowGroup {
ContentView(core: Core())
}
}
}
The one interesting part of this is the @ObservedObject var core: Core. Since the Core is
an ObservableObject, we can subscribe to it to refresh our view. And we've marked the view
property as @Published, so whenever we set it, the View will draw.
The view then simply shows the core.view.count in a Text and whenever we press a button, we directly
call core.update() with the appropriate action.
