Chris Tam · Last updated 27 October 2026
SwiftUI apps can keep one analytics client shared across their views. Amplitude product analytics turns those interactions into events you can inspect by device, account, and report date range.
In this tutorial, you'll build a small usage-report screen, track its two buttons, connect an existing sign-in flow, and find the resulting events in Amplitude.
An iOS report screen that sends Report Previewed and Report Range Changed with report_type, days, and platform. The app shows its device ID so you can find the same events in Amplitude, then verify account attribution when you connect authentication.
Prerequisites
- Xcode with an iOS 17+ simulator or device. The sample uses SwiftUI and Swift 5 language mode; use the SwiftUI App template. The SDK package requires Swift tools 5.9 or later.
- A project API key from Organization settings > API Keys. Select the project and note its US or EU region.
- For signed-in checks, an existing authentication system and the same canonical account ID used by your backend, at least five characters long.
No Amplitude account yet? A free account is enough for the explicit events in this tutorial. Sign up for free to get your project API key.
1. Create the SwiftUI app and add the SDK
I create an iOS App project named AmplitudeReports in Xcode, with interface SwiftUI, language Swift, and no persistence framework. Set the deployment target to iOS 17 and the Swift language version to Swift 5. Keep Xcode's generated project, asset catalog, bundle identifier and signing settings. The nine custom files on this page supply the complete sample; no sibling tutorial is required.
The complete SwiftUI example includes the app files, project generator, locked dependencies and tests.
In an existing app, merge the dependency, configuration and event calls into its current structure. Preserve its app entry point, authentication, navigation, build settings and other instrumentation.
Following the mobile installation guide, choose File > Add Package Dependencies and enter:
Xcode package URL
https://github.com/amplitude/AmplitudeUnified-SwiftChoose Exact Version 0.0.4 and add the AmplitudeUnified product to the AmplitudeReports app target. Keep the Package.resolved file that Xcode creates so the resolved dependency versions travel with the project. Don't add another analytics client alongside this one.
The Unified package includes analytics, experiment support and the iOS replay plugin. I explicitly keep replay sampling, remote replay overrides and automatic capture off in step 3; this page adds event tracking without starting a recording.
2. Read the project key from the build configuration
I add a Config folder at the project root and copy Local.xcconfig.example to Local.xcconfig. Enter the project API key in that local file and set AMPLITUDE_SERVER_ZONE to US or EU. Keep the example blank in source control. A mobile app's project API key ships in the app bundle; use the ingestion API key, never a secret key or server deployment key.
Config/Amplitude.xcconfig
#include? "Local.xcconfig"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = AmplitudeReports/Info.plist
INFOPLIST_EXPAND_BUILD_SETTINGS = YESConfig/Local.xcconfig.example
AMPLITUDE_API_KEY =
AMPLITUDE_SERVER_ZONE = US.gitignore
.build/
.swiftpm/
Config/Local.xcconfig
xcuserdata/In the project editor's Info > Configurations table, select Config/Amplitude.xcconfig as the app target's base configuration for both Debug and Release. In an existing project, include these settings from its current configuration instead of replacing it.
In the app target's Build Settings, inspect both Debug and Release. Target-level values override an xcconfig: remove any target-level overrides for GENERATE_INFOPLIST_FILE and INFOPLIST_FILE so they inherit the configuration, or explicitly set them to NO and AmplitudeReports/Info.plist. Confirm those two resolved values and set Expand Build Settings in Info.plist File (INFOPLIST_EXPAND_BUILD_SETTINGS) to YES before building, so the AMPLITUDE variables are substituted. Apple explains this precedence in adding a build configuration file.
Add this Info.plist file at AmplitudeReports/Info.plist. The configuration points to it and disables generated Info.plist output. Keep the file out of Copy Bundle Resources; Xcode processes it as the target's information property list. Merge only the two AMPLITUDE keys into an existing app's plist.
AmplitudeReports/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>UIApplicationSceneManifest</key>
<dict><key>UIApplicationSupportsMultipleScenes</key><false/></dict>
<key>UILaunchScreen</key><dict/>
<key>UISupportedInterfaceOrientations</key>
<array><string>UIInterfaceOrientationPortrait</string></array>
<key>AMPLITUDE_API_KEY</key><string>$(AMPLITUDE_API_KEY)</string>
<key>AMPLITUDE_SERVER_ZONE</key><string>$(AMPLITUDE_SERVER_ZONE)</string>
</dict>
</plist>AmplitudeReports/Core/AnalyticsSettings.swift
import Foundation
struct AnalyticsSettings {
enum Zone: String { case US, EU }
let apiKey: String
let zone: Zone
init?(info: [String: Any]) {
let key = (info["AMPLITUDE_API_KEY"] as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let region = (info["AMPLITUDE_SERVER_ZONE"] as? String ?? "US")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !key.contains("$("), let zone = Zone(rawValue: region) else {
return nil
}
self.apiKey = key
self.zone = zone
}
}The settings guard leaves analytics off when the key is blank, a build variable is unresolved, or the region is invalid. Rebuild after changing the xcconfig so the values reach the app bundle.
3. Initialize one shared client and connect account callbacks
I keep the client in an app-wide service, outside View.body and view appearance callbacks. All calls from the UI and authentication layer use this main-actor service. Explicit event calls keep the example independent of automatic view tracking; remote autocapture configuration is disabled too.
AmplitudeReports/Core/ReportAnalytics.swift
import AmplitudeUnified
import Foundation
@MainActor
final class ReportAnalytics {
static let shared = ReportAnalytics(settings: AnalyticsSettings(info: Bundle.main.infoDictionary ?? [:]))
private let client: Amplitude?
var configured: Bool { client != nil }
convenience init(settings: AnalyticsSettings?) {
guard let settings else {
self.init(client: nil)
return
}
let analytics = AnalyticsConfig(autocapture: [], enableAutoCaptureRemoteConfig: false)
#if os(iOS)
let replay = SessionReplayPlugin.Config(
sampleRate: 0,
enableRemoteConfig: false,
autoStart: false
)
let client = Amplitude(
apiKey: settings.apiKey,
serverZone: settings.zone == .EU ? .EU : .US,
analyticsConfig: analytics,
sessionReplayConfig: replay
)
#else
// The same event/auth helper can be checked on macOS; replay is iOS-only.
let client = Amplitude(
apiKey: settings.apiKey,
serverZone: settings.zone == .EU ? .EU : .US,
analyticsConfig: analytics
)
#endif
self.init(client: client)
}
init(client: Amplitude?) { self.client = client }
func reportPreviewed(days: Int) {
client?.track(eventType: "Report Previewed", eventProperties: properties(days: days))
}
func reportRangeChanged(days: Int) {
client?.track(eventType: "Report Range Changed", eventProperties: properties(days: days))
}
// Call after the existing authentication system confirms the account.
func accountSignedIn(userID: String, plan: String) {
precondition(userID.count >= 5, "Use your canonical user ID, at least five characters")
client?.setUserId(userId: userID)
let identify = Identify()
identify.set(property: "plan", value: plan)
client?.identify(identify: identify)
}
// Call only after confirmed logout, never for an anonymous/loading auth state.
func accountSignedOut() {
client?.reset()
// A fresh UUID separates logged-out events even when IDFV is available.
client?.setDeviceId(deviceId: UUID().uuidString)
}
func flush() { client?.flush() }
func deviceID() -> String? { client?.getDeviceId() }
private func properties(days: Int) -> [String: Any] {
["report_type": "usage", "days": days, "platform": "iOS"]
}
}After your existing authentication confirms a sign-in, call ReportAnalytics.shared.accountSignedIn(userID:plan:) before tracking actions for that account. Use the same canonical ID as the backend. Call accountSignedOut() only after confirmed logout, before the next anonymous action. An anonymous or loading auth state doesn't call reset. The fresh sample runs anonymously until you connect those callbacks.
Why. I set the plan before the account's next report event so that event can carry the updated user property. The service resets identity only at a confirmed logout boundary, then explicitly assigns a fresh UUID device ID before the next event. This keeps logged-out events separate even when the SDK can reuse Apple's identifier for vendors (IDFV).
MCP. Your agent can use Amplitude's MCP server to inspect the project's existing taxonomy before choosing event names. Keep existing names when you merge this sample into an instrumented app.
4. Track the two report actions
I track each action in its button handler. SwiftUI can evaluate a view's body repeatedly; rendering the same report isn't another button action. The sample's numbers are local demo data.
AmplitudeReports/ReportModel.swift
import Combine
import Foundation
@MainActor
final class ReportModel: ObservableObject {
@Published private(set) var days = 7
@Published private(set) var opened = false
@Published private(set) var status: String
let analytics: ReportAnalytics
init(analytics: ReportAnalytics? = nil) {
let analytics = analytics ?? .shared
self.analytics = analytics
status = analytics.configured
? "Analytics configured; verify delivery separately"
: "Analytics off: check the API key and region in the build configuration"
}
var report: String {
opened ? "\(days)-day report: \(days * 12) sample actions" : "Open the sample report"
}
func openReport() {
opened = true
analytics.reportPreviewed(days: days)
}
func changeRange() {
days = days == 7 ? 30 : 7
opened = true
analytics.reportRangeChanged(days: days)
}
func flushAndShowDevice() {
analytics.flush()
status = "Device ID: \(analytics.deviceID() ?? "not available yet"); flush requested"
}
}AmplitudeReports/ContentView.swift
import SwiftUI
struct ContentView: View {
@ObservedObject var model: ReportModel
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
Text(model.status).font(.footnote).textSelection(.enabled)
Text(model.report)
Button("Open report", action: model.openReport)
Button("Change date range", action: model.changeRange)
Button("Flush and show device ID", action: model.flushAndShowDevice)
Spacer()
}
.padding()
.navigationTitle("Usage reports")
}
}
}The first button emits Report Previewed with days 7. Changing the range emits Report Range Changed and switches days between 30 and 7. Flush and show device ID requests an upload without adding another report event.
5. Own the model at the app entry point
I replace the fresh template's app and ContentView files with the versions on this page, keeping only one @main entry point. Add the Core and model files to the app target. The app owns its model with StateObject and passes that model into the view. A background transition requests a flush; flush is asynchronous and doesn't guarantee delivery before iOS suspends or terminates the process.
AmplitudeReports/AmplitudeReportsApp.swift
import SwiftUI
@main
@MainActor
struct AmplitudeReportsApp: App {
@StateObject private var model = ReportModel()
@Environment(\.scenePhase) private var phase
var body: some Scene {
WindowGroup { ContentView(model: model) }
.onChange(of: phase) { _, next in
if next == .background { model.analytics.flush() }
}
}
}Select the app scheme and an iOS simulator, then choose Product > Build and run the app. In an existing application, merge the shared model and lifecycle hook into its current App or scene structure.
Before distributing your app, review its privacy disclosures and the SDK packages' privacy manifests against your actual data collection. The SDK's default manifest doesn't describe every app-specific event property you choose to send. See the Swift SDK privacy guidance.
Verify it worked
Run with a development project key. Tap Open report once, Change date range once, then Flush and show device ID. Keep the app open while you check:
- App state. The report changes from 7 to 30 days and the status shows a device ID. A configured client or a requested flush alone doesn't prove delivery.
- Xcode logs and ingestion. Check for SDK/network errors, then use the Ingestion Debugger to check successful requests and throttling for the project. Allow time for queued events and downstream processing.
- User Profiles. Find the exact device ID in Amplitude's User Profiles and open its event stream. Confirm one Report Previewed and one Report Range Changed from this interaction, with report_type usage, platform iOS, and days 7 then 30.
- Account attribution. With your real auth connected, sign in and open a report. Find the canonical user ID and confirm plan on that event. After confirmed logout, open another report and verify the event uses the new anonymous device identity. Don't treat a code review as proof of this flow.
- Capture scope. Keep the explicit replay configuration unchanged for this analytics-only app. Verify the effective capture configuration before adding recording or real sensitive content in another tutorial.
Troubleshooting
The app says analytics is off
Check the app target's base configuration, the local xcconfig filename, the two plist values and the exact US/EU region. Rebuild the app; editing a local file doesn't change an already installed bundle.
The compiler cannot find AmplitudeUnified
Add the AmplitudeUnified package product to the app target and resolve version 0.0.4. Keep the generated Package.resolved. A package listed in the project without a linked product isn't enough.
Events appear more than once
Keep track calls in button handlers, reuse the shared client, and check for duplicate manual calls or other instrumentation in the existing app. This sample disables autocapture and its remote overrides.
Events are queued but not visible yet
Check the API key's project and region, device connectivity, ingestion errors and throttling. Request a flush and keep the app open. User Profiles and analytics queries can take longer to reflect events than the upload response.
The account changes while authentication is loading
Remove reset calls from generic nil-user observers. Connect the account callbacks to confirmed sign-in and sign-out events. Don't use an email address or a changing presentation name when your backend already has a canonical account ID.
Frequently asked questions
Does this record the screen?
The sample passes autoStart false, sampleRate 0 and enableRemoteConfig false to replay configuration and never calls start. Keep those settings until you add and verify a separate consent-aware recording flow.
Can I use this in a UIKit app?
The analytics calls are the same, but this walkthrough's app entry point and UI use SwiftUI. Merge the shared service into your UIKit lifecycle instead of adding a second application entry point.
Is flush a completion signal?
No. It requests an asynchronous upload. Check ingestion and the processed user event stream before treating delivery as verified.
Full code reference
Nine custom files. Keep the generated Xcode project and assets alongside them. The local key file is created from the blank example and stays outside source control.
Complete sample
// Config/Amplitude.xcconfig
#include? "Local.xcconfig"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = AmplitudeReports/Info.plist
INFOPLIST_EXPAND_BUILD_SETTINGS = YES
// Config/Local.xcconfig.example
AMPLITUDE_API_KEY =
AMPLITUDE_SERVER_ZONE = US
// .gitignore
.build/
.swiftpm/
Config/Local.xcconfig
xcuserdata/
// AmplitudeReports/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>UIApplicationSceneManifest</key>
<dict><key>UIApplicationSupportsMultipleScenes</key><false/></dict>
<key>UILaunchScreen</key><dict/>
<key>UISupportedInterfaceOrientations</key>
<array><string>UIInterfaceOrientationPortrait</string></array>
<key>AMPLITUDE_API_KEY</key><string>$(AMPLITUDE_API_KEY)</string>
<key>AMPLITUDE_SERVER_ZONE</key><string>$(AMPLITUDE_SERVER_ZONE)</string>
</dict>
</plist>
// AmplitudeReports/Core/AnalyticsSettings.swift
import Foundation
struct AnalyticsSettings {
enum Zone: String { case US, EU }
let apiKey: String
let zone: Zone
init?(info: [String: Any]) {
let key = (info["AMPLITUDE_API_KEY"] as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let region = (info["AMPLITUDE_SERVER_ZONE"] as? String ?? "US")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !key.contains("$("), let zone = Zone(rawValue: region) else {
return nil
}
self.apiKey = key
self.zone = zone
}
}
// AmplitudeReports/Core/ReportAnalytics.swift
import AmplitudeUnified
import Foundation
@MainActor
final class ReportAnalytics {
static let shared = ReportAnalytics(settings: AnalyticsSettings(info: Bundle.main.infoDictionary ?? [:]))
private let client: Amplitude?
var configured: Bool { client != nil }
convenience init(settings: AnalyticsSettings?) {
guard let settings else {
self.init(client: nil)
return
}
let analytics = AnalyticsConfig(autocapture: [], enableAutoCaptureRemoteConfig: false)
#if os(iOS)
let replay = SessionReplayPlugin.Config(
sampleRate: 0,
enableRemoteConfig: false,
autoStart: false
)
let client = Amplitude(
apiKey: settings.apiKey,
serverZone: settings.zone == .EU ? .EU : .US,
analyticsConfig: analytics,
sessionReplayConfig: replay
)
#else
// The same event/auth helper can be checked on macOS; replay is iOS-only.
let client = Amplitude(
apiKey: settings.apiKey,
serverZone: settings.zone == .EU ? .EU : .US,
analyticsConfig: analytics
)
#endif
self.init(client: client)
}
init(client: Amplitude?) { self.client = client }
func reportPreviewed(days: Int) {
client?.track(eventType: "Report Previewed", eventProperties: properties(days: days))
}
func reportRangeChanged(days: Int) {
client?.track(eventType: "Report Range Changed", eventProperties: properties(days: days))
}
// Call after the existing authentication system confirms the account.
func accountSignedIn(userID: String, plan: String) {
precondition(userID.count >= 5, "Use your canonical user ID, at least five characters")
client?.setUserId(userId: userID)
let identify = Identify()
identify.set(property: "plan", value: plan)
client?.identify(identify: identify)
}
// Call only after confirmed logout, never for an anonymous/loading auth state.
func accountSignedOut() {
client?.reset()
// A fresh UUID separates logged-out events even when IDFV is available.
client?.setDeviceId(deviceId: UUID().uuidString)
}
func flush() { client?.flush() }
func deviceID() -> String? { client?.getDeviceId() }
private func properties(days: Int) -> [String: Any] {
["report_type": "usage", "days": days, "platform": "iOS"]
}
}
// AmplitudeReports/ReportModel.swift
import Combine
import Foundation
@MainActor
final class ReportModel: ObservableObject {
@Published private(set) var days = 7
@Published private(set) var opened = false
@Published private(set) var status: String
let analytics: ReportAnalytics
init(analytics: ReportAnalytics? = nil) {
let analytics = analytics ?? .shared
self.analytics = analytics
status = analytics.configured
? "Analytics configured; verify delivery separately"
: "Analytics off: check the API key and region in the build configuration"
}
var report: String {
opened ? "\(days)-day report: \(days * 12) sample actions" : "Open the sample report"
}
func openReport() {
opened = true
analytics.reportPreviewed(days: days)
}
func changeRange() {
days = days == 7 ? 30 : 7
opened = true
analytics.reportRangeChanged(days: days)
}
func flushAndShowDevice() {
analytics.flush()
status = "Device ID: \(analytics.deviceID() ?? "not available yet"); flush requested"
}
}
// AmplitudeReports/ContentView.swift
import SwiftUI
struct ContentView: View {
@ObservedObject var model: ReportModel
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
Text(model.status).font(.footnote).textSelection(.enabled)
Text(model.report)
Button("Open report", action: model.openReport)
Button("Change date range", action: model.changeRange)
Button("Flush and show device ID", action: model.flushAndShowDevice)
Spacer()
}
.padding()
.navigationTitle("Usage reports")
}
}
}
// AmplitudeReports/AmplitudeReportsApp.swift
import SwiftUI
@main
@MainActor
struct AmplitudeReportsApp: App {
@StateObject private var model = ReportModel()
@Environment(\.scenePhase) private var phase
var body: some Scene {
WindowGroup { ContentView(model: model) }
.onChange(of: phase) { _, next in
if next == .background { model.analytics.flush() }
}
}
}Hand it to your agent
Use this prompt with your coding agent.
Agent prompt
Follow https://amplitude.com/build/ios-product-analytics. Create the complete iOS 17+ SwiftUI usage-report sample, or merge it into the existing app while preserving authentication, navigation, privacy settings, build configurations and instrumentation. Use AmplitudeUnified-Swift exactly 0.0.4 and one shared client. Add the local xcconfig and plist substitutions without committing a real key; match its US/EU region. Keep track calls in explicit buttons, outside View.body and appearance callbacks. Keep autocapture and remote autocapture changes disabled. Keep replay autoStart false, sampleRate 0 and enableRemoteConfig false, and never start capture for this page. Connect canonical account IDs and plan only after confirmed sign-in; reset and assign a fresh UUID device ID only on confirmed logout, before tracking another event. Preserve the event names and properties. Treat flush as asynchronous. Report syntax, compilation, simulator/device behavior and live service delivery separately. Do not claim mocked success or a displayed device ID proves processed ingestion.What this unlocks
Product analytics compares Report Previewed by days and plan. After separate setup, feature flags can vary report presentation, session replay can add consent-aware visual context, and guides and surveys can explain the report or collect feedback. Each product needs its own configuration and verification.
Next steps
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.