How to Create an ML Model in SwiftUI
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
-
DataFrame construction —
TabularData.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. -
BoostedTreeClassifier(targetColumn:featureColumns:)— leavingfeatureColumnsasniltells CreateMLComponents to infer them from all non-target columns. Specifying them explicitly avoids accidentally including ID or timestamp columns in the model. -
Async
fitted(to:)— this suspends and trains on a background thread managed by CreateMLComponents. The Swift concurrency runtime ensures the@Observablemutation happens back on the main actor via theTask { await trainer.train() }call inside the button. -
Type-erased
predictFnclosure — 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. -
fitted.applied(to:)— prediction also takes aDataFrame; the transformer appends a new"activity"column containing the predicted labels. Batch-predicting an entire DataFrame at once is faster than callingappliedrow-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
-
iOS version floor.
CreateMLComponentsships in iOS 16+, but the asyncfitted(to:)overload and themakeTrainingEventsstreaming API require iOS 17+. Wrap calls in#available(iOS 17, *)if your deployment target is lower. -
Training on the main actor blocks the UI. Always initiate training inside
Task { await … }from a button action, nottry awaitdirectly in a@MainActorcontext — even though the call is async, long CPU-bound preambles before the first suspension point can still jank. -
Column schema must match at prediction time. If training columns were named
"heartRate"(camelCase), the prediction DataFrame must use the exact same spelling and type (Double, notFloat). A typo produces a silentnilprediction, not a thrown error. -
Too few samples causes overfitting or NaN metrics. Boosted trees need at least 5–10 rows per class for a meaningful validation split. If
trainingMetrics.accuracyis1.0with very few samples, the model is just memorising — add more varied data or use a simplerLinearClassifier. -
No VoiceOver on raw prediction strings. When displaying the predicted label, always supply an
.accessibilityLabelthat includes context, e.g. "Predicted activity: running", so screen-reader users receive a complete sentence rather than an isolated word.
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.