iOS — Swift and SwiftUI — using XcodeGen
These are the steps to set up Xcode to build and run a simple iOS app that calls into a shared core.
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 jump to iOS — Swift and SwiftUI — manual setup, otherwise read on.
This walk-through assumes you have already added the shared
and shared_types
libraries to your repo — as described in Shared core and types.
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.
We will use cargo-xcode
to generate an
Xcode project for our shared library, which we can add as a sub-project in
Xcode.
Recent changes to cargo-xcode
mean that we need to use version <=1.7.0 for
now.
If you don't have this already, you can install it in one of two ways:
-
Globally, with
cargo install --force cargo-xcode --version 1.7.0
-
Locally, using
cargo-run-bin
, after ensuring that yourCargo.toml
has the following lines (see The workspace and library manifests):[workspace.metadata.bin] cargo-xcode = { version = "=1.7.0" }
Ensure you have
cargo-run-bin
(and optionallycargo-binstall
) installed:cargo install cargo-run-bin cargo-binstall
Then, in the root of your app:
cargo bin --install # will be faster if `cargo-binstall` is installed cargo bin --sync-aliases # to use `cargo xcode` instead of `cargo bin xcode`
Let's generate the sub-project:
cargo xcode
This generates an Xcode project for each crate in the workspace, but we're only
interested in the one it creates in the shared
directory. Don't open this
generated project yet, it'll be included when we generate the Xcode project for
our iOS app.
Generate the Xcode project for our iOS app
We will use XcodeGen
to generate an Xcode project for our iOS app.
If you don't have this already, you can install it with brew install xcodegen
.
Before we generate the Xcode project, we need to create some directories and a
project.yml
file:
mkdir -p iOS/SimpleCounter
cd iOS
touch project.yml
The project.yml
file describes the Xcode project we want to generate. Here's
one for the SimpleCounter example — you may want to adapt this for your own
project:
name: SimpleCounter
projectReferences:
Shared:
path: ../shared/shared.xcodeproj
packages:
SharedTypes:
path: ../shared_types/generated/swift/SharedTypes
options:
bundleIdPrefix: com.example.simple_counter
attributes:
BuildIndependentTargetsInParallel: true
targets:
SimpleCounter:
type: application
platform: iOS
deploymentTarget: "15.0"
sources:
- SimpleCounter
- path: ../shared/src/shared.udl
buildPhase: sources
dependencies:
- target: Shared/uniffi-bindgen-bin
- target: Shared/shared-staticlib
- package: SharedTypes
info:
path: SimpleCounter/Info.plist
properties:
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UILaunchScreen: {}
settings:
OTHER_LDFLAGS: [-w]
SWIFT_OBJC_BRIDGING_HEADER: generated/sharedFFI.h
ENABLE_USER_SCRIPT_SANDBOXING: NO
buildRules:
- name: Generate FFI
filePattern: "*.udl"
script: |
#!/bin/bash
set -e
# Skip during indexing phase in XCode 13+
if [ "$ACTION" == "indexbuild" ]; then
echo "Not building *.udl files during indexing."
exit 0
fi
# Skip for preview builds
if [ "$ENABLE_PREVIEWS" = "YES" ]; then
echo "Not building *.udl files during preview builds."
exit 0
fi
cd "${INPUT_FILE_DIR}/.."
"${BUILD_DIR}/debug/uniffi-bindgen" generate "src/${INPUT_FILE_NAME}" --language swift --out-dir "${PROJECT_DIR}/generated"
outputFiles:
- $(PROJECT_DIR)/generated/$(INPUT_FILE_BASE).swift
- $(PROJECT_DIR)/generated/$(INPUT_FILE_BASE)FFI.h
runOncePerArchitecture: false
Then we can generate the Xcode project:
xcodegen
This should create an iOS/SimpleCounter/SimpleCounter.xcodeproj
project file,
which we can open in Xcode. It should build OK, but we will need to add some
code!
Create some UI and run in the Simulator, or on an iPhone
There is slightly more advanced example of an iOS app in the Crux repository.
However, we will use the
simple counter example,
which has shared
and shared_types
libraries that will work with the
following example code.
Simple counter example
A simple app that increments, decrements and resets a counter.
Wrap the core to support capabilities
First, let's add some boilerplate code to wrap our core and handle the
capabilities that we are using. For this example, we only need to support the
Render
capability, which triggers a render of the UI.
This code that wraps the core only needs to be written once — it only grows when we need to support additional capabilities.
Edit iOS/SimpleCounter/core.swift
to look like the following. This code sends
our (UI-generated) events to the core, and handles any effects that the core
asks for. In this simple example, we aren't calling any HTTP APIs or handling
any side effects other than rendering the UI, so we just handle this render
effect by updating the published view model from the core.
import Foundation
import SharedTypes
@MainActor
class Core: ObservableObject {
@Published var view: ViewModel
init() {
self.view = try! .bincodeDeserialize(input: [UInt8](SimpleCounter.view()))
}
func update(_ event: Event) {
let effects = [UInt8](processEvent(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:
view = try! .bincodeDeserialize(input: [UInt8](SimpleCounter.view()))
}
}
}
That switch
statement, above, is where you would handle any other effects that
your core might ask for. For example, if your core needs to make an HTTP
request, you would handle that here. To see an example of this, take a look at
the
counter example
in the Crux repository.
Edit iOS/SimpleCounter/ContentView.swift
to look like the following:
import SharedTypes
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 create iOS/SimpleCounter/SimpleCounterApp.swift
to look like this:
import SwiftUI
@main
struct SimpleCounterApp: App {
var body: some Scene {
WindowGroup {
ContentView(core: Core())
}
}
}
Run xcodegen
again to update the Xcode project with these newly created source
files (or add them manually in Xcode to the SimpleCounter
group), and then
open iOS/SimpleCounter/SimpleCounter.xcodeproj
in Xcode. You might need to
select the SimpleCounter
scheme, and an appropriate simulator, in the
drop-down at the top, before you build.