Models and ViewModels
Feature Model
First impressions sell the app. ShipThatApp ships a working onboarding sheet on day one — and the entire thing is data-driven by one tiny value type. To change what new users see on launch, you edit an array, not a view. No custom layout, no per-slide plumbing, no rebuild of the UI.
That value type is Feature, and it lives in Models/OnboardingFeature.swift.
File vs. type name
The file is OnboardingFeature.swift, but the struct it declares is named Feature (the original header even reads OnboardingFeatureModel.swift). When you reference it in code you use Feature; when you go looking for the source, open Models/OnboardingFeature.swift.
The model
Feature is a small, Identifiable struct — nothing more. That's the point: it carries exactly what an onboarding row needs and gets out of the way.
import Foundation
struct Feature: Identifiable {
let id = UUID()
let title: String
let description: String
let icon: String?
}
Properties
id— an auto-assignedUUID. Conforming toIdentifiableis what lets you drop a[Feature]straight into a SwiftUIForEachwith stable identity and noid:key path.title— the headline line for the row (e.g."Magic Links").description— the supporting copy under the title.icon— an optional SF Symbol name (e.g."apple.logo"). It's optional on purpose: rows without an icon simply render text-only, so you're never forced to invent a glyph for every feature.
How it's used
OnboardingView declares its content as a let features: [Feature] array and renders it. This is the real array shipped in the boilerplate — note how it doubles as a tour of what the template gives you for free:
struct OnboardingView: View {
@Environment(SettingsManager.self) private var settingsManager
@State private var showOnboardingSheet = false
let appName: String = "ShipThatApp 🚀"
let features: [Feature] = [
Feature(
title: "Magic Links",
description: "with Supabase bootstrapped, so you don't have to",
icon: "envelope.badge.shield.half.filled"
),
Feature(
title: "Login with Apple",
description: "ready to use, so you can focus on the content",
icon: "apple.logo"
),
Feature(
title: "Payments",
description: "initial setup for RevenueCat done, just setup Apple side, and start collecting payments",
icon: "creditcard"
),
Feature(
title: "Analytics",
description: "with TelemetryDeck bootstrapped, so you can discover user patterns and make you app better",
icon: "chart.bar.xaxis"
),
Feature(
title: "On-Device AI",
description: "Apple Foundation Models integration for private, offline AI assistance",
icon: "cpu"
)
]
// body presents the array in a sheet…
}
The view gates the sheet on SettingsManager.onboardingComplete so it shows once for new users, then renders the array with a single ForEach:
ScrollView {
VStack(spacing: 20) {
ForEach(features) { feature in
FeatureRow(feature: feature)
}
}
.padding(.horizontal, 24)
}
Each row is driven entirely by the model, and the optional icon is unwrapped before drawing — exactly why the property is an optional String?:
private struct FeatureRow: View {
let feature: Feature
var body: some View {
HStack(alignment: .top, spacing: 16) {
if let icon = feature.icon {
Image(systemName: icon)
.font(.system(size: 28))
.foregroundStyle(Color.accentColor)
.frame(width: 44, height: 44)
}
VStack(alignment: .leading, spacing: 4) {
Text(feature.title)
.font(.headline)
Text(feature.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
One source of truth
The same [Feature] array feeds both the on-screen rows and the layout. There is no second place to keep in sync — add, remove, or reorder entries in OnboardingView.features and the sheet updates verbatim.
Customize / extend it
Change the onboarding copy
The fastest, highest-impact edit in the whole app: open Views/Onboarding/OnboardingView.swift and edit the features array. Swap titles, rewrite descriptions, pick different SF Symbols. Because icon is optional, you can pass nil for a text-only row:
Feature(
title: "Works Offline",
description: "Your data is yours, on-device, no account required",
icon: nil
)
Add a field to the model
Feature is deliberately minimal — extend it when a row needs more. Want a per-row accent color or a deep link? Add the property (give new ones defaults so existing call sites keep compiling), then read it in FeatureRow:
struct Feature: Identifiable {
let id = UUID()
let title: String
let description: String
let icon: String?
var tint: Color = .accentColor // new, with a default
}
Keep Feature a plain data holder — no business logic. If a row needs to do something (open a paywall, request a permission), store the intent as data on the model and let the view decide how to act on it.
Related
- Authentication Flow — three of the onboarding rows (Magic Links, Login with Apple, password sign-in) map directly to the auth methods documented here.
- SignInViewModel — the view model that backs the sign-in screen users land on after onboarding completes.