How to Create an ML Model in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: CreateML · CreateMLComponents · TabularData Updated: May 12, 2026
TL;DR

Use CreateMLComponents — Apple's composable, on-device ML framework — to train a BoostedTreeClassifier from a TabularData.DataFrame inside an async context, then store the fitted model and call it for instant predictions.

import CreateMLComponents
import TabularData

// 1. Build training data
var df = DataFrame()
df.append(column: Column<Double>(name: "steps",     contents: [800,   4500,   9200  ]))
df.append(column: Column<Double>(name: "heartRate", contents: [62,    110,    158   ]))
df.append(column: Column<String>(name: "activity",  contents: ["rest","walk", "run" ]))

// 2. Train on-device (async)
let task    = BoostedTreeClassifier(targetColumn: "activity")
let fitted  = try await task.fitted(to: df)

// 3. Check accuracy
print("Train accuracy:", fitted.trainingMetrics.accuracy)

Full implementation

The view below drives an @Observable trainer class through four phases — idle → training → ready → predicting. Training runs in a Swift concurrency Task so the UI stays live. The fitted transformer is captured inside a type-erased prediction closure, sidestepping complex generic signatures while keeping everything strongly typed at the call site.

import SwiftUI
import CreateMLComponents
import TabularData

// MARK: – Observable trainer

@Observable
final class ActivityTrainer {
    enum Phase: Equatable {
        case idle, training, ready, failed(String)
        static func == (l: Phase, r: Phase) -> Bool {
            switch (l, r) {
            case (.idle, .idle), (.training, .training), (.ready, .ready): return true
            case (.failed(let a), .failed(let b)):                         return a == b
            default:                                                        return false
            }
        }
    }

    var phase: Phase = .idle
    var trainAccuracy: Double = 0
    var prediction: String = ""

    // Type-erased so the view doesn't need to spell out generic types
    private var predictFn: ((Double, Double) throws -> String)?

    // Compact training dataset (steps/min, bpm → activity label)
    private let samples: [(steps: Double, hr: Double, label: String)] = [
        (550, 60, "resting"),  (700, 64, "resting"),
        (3100, 105, "walking"),(3800, 115, "walking"),
        (8700, 152, "running"),(9400, 162, "running"),
        (400,  72, "standing"),(480,  70, "standing"),
        (5200, 130, "cycling"),(5800, 135, "cycling"),
    ]

    func train() async {
        phase = .training
        do {
            var df = DataFrame()
            df.append(column: Column<Double>(name: "steps",
                                              contents: samples.map(\.steps)))
            df.append(column: Column<Double>(name: "heartRate",
                                              contents: samples.map(\.hr)))
            df.append(column: Column<String>(name: "activity",
                                              contents: samples.map(\.label)))

            let task   = BoostedTreeClassifier(targetColumn: "activity",
                                               featureColumns: ["steps", "heartRate"])
            let fitted = try await task.fitted(to: df)
            trainAccuracy = fitted.trainingMetrics.accuracy

            // Capture fitted model; build prediction DataFrame on demand
            predictFn = { [fitted] steps, hr in
                var row = DataFrame()
                row.append(column: Column<Double>(name: "steps",     contents: [steps]))
                row.append(column: Column<Double>(name: "heartRate", contents: [hr]))
                let result = try fitted.applied(to: row)
                return result["activity", String.self].first?? ?? "unknown"
            }

            phase = .ready
        } catch {
            phase = .failed(error.localizedDescription)
        }
    }

    func predict(steps: Double, heartRate: Double) {
        guard let fn = predictFn else { return }
        prediction = (try? fn(steps, heartRate)) ?? "—"
    }
}

// MARK: – View

struct CreateMLModelView: View {
    @State private var trainer    = ActivityTrainer()
    @State private var stepsText  = "5000"
    @State private var hrText     = "120"

    var body: some View {
        NavigationStack {
            Form {
                // Status
                Section("Model") {
                    HStack(spacing: 10) {
                        Image(systemName: phaseIcon)
                            .foregroundStyle(phaseColor)
                            .accessibilityHidden(true)
                        Text(phaseLabel)
                        Spacer()
                        if trainer.phase == .training { ProgressView() }
                    }
                    .accessibilityElement(children: .combine)
                    .accessibilityLabel("Model status: \(phaseLabel)")

                    if trainer.phase == .ready {
                        Label(
                            "Train accuracy: \(trainer.trainAccuracy, format: .percent.precision(.fractionLength(1)))",
                            systemImage: "chart.bar.fill"
                        )
                        .foregroundStyle(.secondary)
                        .font(.callout)
                    }

                    Button("Train on Device") {
                        Task { await trainer.train() }
                    }
                    .disabled(trainer.phase == .training)
                }

                // Prediction
                if trainer.phase == .ready {
                    Section("Predict") {
                        LabeledContent("Steps / min") {
                            TextField("5000", text: $stepsText)
                                .keyboardType(.decimalPad)
                                .multilineTextAlignment(.trailing)
                        }
                        LabeledContent("Heart rate (bpm)") {
                            TextField("120", text: $hrText)
                                .keyboardType(.decimalPad)
                                .multilineTextAlignment(.trailing)
                        }
                        Button("Classify Activity") {
                            trainer.predict(
                                steps:     Double(stepsText) ?? 5000,
                                heartRate: Double(hrText) ?? 120
                            )
                        }
                        if !trainer.prediction.isEmpty {
                            Label(trainer.prediction.capitalized,
                                  systemImage: "figure.walk")
                                .font(.headline)
                                .accessibilityLabel("Predicted activity: \(trainer.prediction)")
                        }
                    }
                }
            }
            .navigationTitle("Activity Classifier")
            .navigationBarTitleDisplayMode(.large)
        }
    }

    private var phaseLabel: String {
        switch trainer.phase {
        case .idle:            return "Not trained"
        case .training:        return "Training…"
        case .ready:           return "Model ready"
        case .failed(let msg): return "Error: \(msg)"
        }
    }
    private var phaseIcon: String {
        switch trainer.phase {
        case .idle:     return "brain"
        case .training: return "arrow.triangle.2.circlepath"
        case .ready:    return "checkmark.circle.fill"
        case .failed:   return "xmark.circle.fill"
        }
    }
    private var phaseColor: Color {
        switch trainer.phase {
        case .ready:  return .green
        case .failed: return .red
        default:      return .secondary
        }
    }
}

#Preview {
    CreateMLModelView()
}

How it works

  1. DataFrame constructionTabularData.Column<T> is the typed building block. Appending columns with the same row count produces a valid training frame. Mismatched counts throw at runtime, so asserting equal lengths in tests is worthwhile.
  2. BoostedTreeClassifier(targetColumn:featureColumns:) — leaving featureColumns as nil tells CreateMLComponents to infer them from all non-target columns. Specifying them explicitly avoids accidentally including ID or timestamp columns in the model.
  3. Async fitted(to:) — this suspends and trains on a background thread managed by CreateMLComponents. The Swift concurrency runtime ensures the @Observable mutation happens back on the main actor via the Task { await trainer.train() } call inside the button.
  4. Type-erased predictFn closure — the fitted transformer's full Swift type is a deeply nested generic. Capturing it inside a ((Double, Double) throws -> String)? closure keeps the view free of generic complexity while still leveraging type safety inside the closure body.
  5. fitted.applied(to:) — prediction also takes a DataFrame; the transformer appends a new "activity" column containing the predicted labels. Batch-predicting an entire DataFrame at once is faster than calling applied row-by-row.

Variants

Export to a CoreML package for shipping

Once trained you can write the model to disk as an .mlpackage bundle, then load it through CoreML for inference — ideal when you want to bundle a personalised model with an app update or share it via iCloud.

import CoreML
import CreateMLComponents

// After fitted(to:) succeeds…
let docsURL   = URL.documentsDirectory
let modelURL  = docsURL.appending(path: "ActivityClassifier.mlpackage")

// Export – CreateMLComponents writes a self-contained package
try fitted.export(to: modelURL)

// Later: load with CoreML for zero-overhead inference
let mlModel   = try MLModel(contentsOf: modelURL)
let provider  = try MLDictionaryFeatureProvider(dictionary: [
    "steps":     MLFeatureValue(double: 8500),
    "heartRate": MLFeatureValue(double: 152),
])
let output    = try mlModel.prediction(from: provider)
let label     = output.featureValue(for: "activity")?.stringValue ?? "unknown"
print("CoreML says:", label) // → "running"

Streaming training events for granular progress

For longer training runs (large datasets or deeper trees), swap fitted(to:) for the streaming API to surface per-iteration metrics:

// Stream progress events instead of awaiting the final result
let eventStream = task.makeTrainingEvents(on: df)

for try await event in eventStream {
    switch event {
    case .iterationCompleted(let checkpoint):
        // checkpoint.metrics.accuracy — update a @Published progress value
        await MainActor.run { self.iterationAccuracy = checkpoint.metrics.accuracy }
    case .completed(let final):
        await MainActor.run {
            self.trainAccuracy = final.trainingMetrics.accuracy
            self.phase = .ready
        }
    @unknown default: break
    }
}

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to scaffold this feature:

Implement an on-device ML classifier in SwiftUI for iOS 17+.
Use CreateMLComponents (BoostedTreeClassifier) and TabularData.DataFrame.
Wrap training in an @Observable class with idle/training/ready/failed phases.
Expose a predict(steps:heartRate:) method using a type-erased closure.
Make it accessible (VoiceOver labels on status and prediction result).
Add a #Preview with realistic sample data showing the model-ready state.

In Soarias' Build phase, paste this prompt directly into the implementation prompt panel — Claude Code will generate the full ActivityTrainer class, the SwiftUI form, and the unit test scaffold in one pass, leaving you to wire it to your real data source.

Related

FAQ

Does this work on iOS 16?

Partially. CreateMLComponents was introduced in iOS 16 and macOS 13, but the fully async fitted(to:) and the makeTrainingEvents streaming API require iOS 17+. On iOS 16, you can use the completion-handler-based APIs, but the code in this guide will not compile without an #available(iOS 17, *) guard. For iOS 16 deployments, prefer shipping a pre-trained .mlmodel and running inference-only with CoreML.

Can I train on image data instead of tabular data?

Yes — replace the BoostedTreeClassifier pipeline with an image-feature pipeline using ImageFeaturePrint (a transfer-learned embedding from Apple's vision models) chained to a LogisticRegressionClassifier:

let pipeline = ImageFeaturePrint()
    .appending(LogisticRegressionClassifier(targetColumn: "label"))
let fitted = try await pipeline.fitted(to: annotatedImages)

Supply your training images as an array of AnnotatedFeature<CGImage, String>. This is the same technique behind Create ML's image classifier template and runs entirely on-device.

What's the UIKit / CoreML equivalent?

In UIKit, or when working with a pre-trained model, use CoreML directly: load an .mlmodel with MLModel(contentsOf:), wrap inputs in MLDictionaryFeatureProvider, and call model.prediction(from:). For on-device model updates (personalization) in UIKit, use MLUpdateTask with an updatable CoreML model. CreateMLComponents is the modern, composable alternative that skips the separate "train on Mac, export, bundle" workflow and is the recommended path for new projects targeting iOS 17+.

Last reviewed: 2026-05-12 by the Soarias team.