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.

Tip

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.

Note

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.

Note

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:

  1. Globally, with cargo install --force cargo-xcode --version 1.7.0

  2. Locally, using cargo-run-bin, after ensuring that your Cargo.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 optionally cargo-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

Note

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

Example

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.

Note

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()))
        }
    }
}

Tip

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.

Success

You should then be able to run the app in the simulator or on an iPhone, and it should look like this:

simple counter app