Chris Tam · Last updated 29 October 2026
SwiftUI builds screens from state. With feature flags, you can choose a report layout remotely and use the same identity to measure what people do after seeing it.
In this tutorial, you'll add one shared client, fetch a layout, keep a safe fallback, and connect a report-export metric to an experiment. I'll show complete files for a new sample and explain where existing authentication belongs.
A seven-day report with compact and expanded layouts. Recognized assignments generate exposure when displayed; Report Previewed and Report Exported carry layout, layout_source, days, report_type, and platform.
Open the complete SwiftUI example for the Xcode project, blank configuration and local tests.
Prerequisites
- A Mac with Xcode and an installed iOS simulator. This sample targets iOS 17 or later.
- A development Amplitude project and its project API key from Organization settings > API Keys.
- A client deployment in that same project, with permission to create flags. Use a client deployment key, not a server deployment or Analytics secret key.
- Experiment access for the measurement step. Compare plans for your workspace's availability.
Sign up for free to create an account. Keep this exercise in a development project before changing production targeting.
1. Create the SwiftUI app
Create an iOS App project named AmplitudeReports, using SwiftUI and Swift. Add the Swift files below to the app target, including those in Core. Use the sample files in place of its two generated SwiftUI files so the target has one @main app entry. Set its deployment target to iOS 17. Add https://github.com/amplitude/AmplitudeUnified-Swift through Xcode's package dependencies, choose Exact Version 0.0.4, and add the AmplitudeUnified product to the app target. This follows the mobile SDK installation guide.
Create the source and configuration folders inside the Xcode project directory:
# Terminal, from the directory containing AmplitudeReports.xcodeproj
mkdir -p AmplitudeReports/Core ConfigI use one Unified client. It owns the analytics and experiment integration; don't add another independently initialized Experiment client. In an existing app, merge these files into the current startup and services without replacing authentication, navigation, consent controls, or instrumentation.
2. Configure the project and deployment keys
Create Config/Amplitude.xcconfig, set it as the app target's Debug and Release base configuration, and create an ignored Config/Local.xcconfig with the following contents. Fill the two blank values with the development project's ingestion key and the intended client deployment key. Select US or EU for the project's region.
# Config/Amplitude.xcconfig
#include? "Local.xcconfig"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = AmplitudeReports/Info.plist
INFOPLIST_EXPAND_BUILD_SETTINGS = YES# Config/Local.xcconfig
AMPLITUDE_API_KEY =
AMPLITUDE_DEPLOYMENT_KEY =
AMPLITUDE_SERVER_ZONE = US# .gitignore
Config/Local.xcconfig
.build/
DerivedData/
*.xcuserstate
xcuserdata/These client keys ship in the app. Keep server credentials out of the project. Both values are required by this sample, so an omitted deployment key can't silently select a different default deployment.
Use this complete plist for the sample. Keep Xcode's generated Info.plist setting off for the target because the configuration points to this file. Existing apps should add the three AMPLITUDE_* entries to their current plist instead of removing other app settings.
<?xml version="1.0" encoding="UTF-8"?>
<!-- AmplitudeReports/Info.plist -->
<!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>
<key>AMPLITUDE_DEPLOYMENT_KEY</key>
<string>$(AMPLITUDE_DEPLOYMENT_KEY)</string>
</dict>
</plist>// AmplitudeReports/Core/AnalyticsSettings.swift
import Foundation
struct AnalyticsSettings {
enum Zone: String { case US, EU }
let apiKey: String
let deploymentKey: String
let zone: Zone
init?(info: [String: Any]) {
func key(_ name: String) -> String {
(info[name] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
let apiKey = key("AMPLITUDE_API_KEY")
let deploymentKey = key("AMPLITUDE_DEPLOYMENT_KEY")
let region = key("AMPLITUDE_SERVER_ZONE")
guard !apiKey.isEmpty, !deploymentKey.isEmpty,
!apiKey.contains("$("), !deploymentKey.contains("$("),
let zone = Zone(rawValue: region) else { return nil }
self.apiKey = apiKey
self.deploymentKey = deploymentKey
self.zone = zone
}
}3. Fetch one layout for the current identity
I keep the display decision separate from the SDK cache. A failed fetch or an unknown value shows the compact fallback without counting it as a fetched assignment. Each refresh invalidates the old display revision; only one fetch runs at a time.
// AmplitudeReports/Core/FlagState.swift
enum ReportLayout: String {
case compact = "control"
case expanded
var displayName: String { self == .compact ? "compact" : "expanded" }
}
// Application policy: serialize fetches and reject results from an older identity.
struct FlagState {
private(set) var revision = 0
private(set) var layout: ReportLayout = .compact
private(set) var assigned = false
private(set) var pending = false
private(set) var activeRequest: Int?
var loading: Bool { pending || activeRequest != nil }
mutating func request() {
revision += 1
layout = .compact
assigned = false
pending = true
}
mutating func begin() -> Int? {
guard pending, activeRequest == nil else { return nil }
pending = false
activeRequest = revision
return revision
}
// False tells the adapter to clear an old response from the SDK cache.
mutating func complete(_ token: Int, value: String?, succeeded: Bool) -> Bool {
guard activeRequest == token else { return false }
activeRequest = nil
guard token == revision else { return false }
if succeeded, let value, let accepted = ReportLayout(rawValue: value) {
layout = accepted
assigned = true
}
return true
}
func canExpose(displayedRevision: Int) -> Bool {
assigned && !loading && displayedRevision == revision
}
}The shared service uses the Unified-owned experiment client. Automatic exposure is off so reading a variant doesn't count an unseen screen. Retries are off for this sample; Refresh flags explicitly tries again. Session replay and automatic analytics events stay off in this example. Connect your app's analytics consent policy before initializing or sending events.
// AmplitudeReports/Core/ReportAnalytics.swift
import AmplitudeUnified
import Combine
import Foundation
@MainActor
final class ReportAnalytics: ObservableObject {
static let shared = ReportAnalytics(settings: AnalyticsSettings(info: Bundle.main.infoDictionary ?? [:]))
static let flagKey = "report-layout"
private let client: Amplitude?
@Published private(set) var state = FlagState()
@Published private(set) var status = "Flags off: check both client keys and region"
private var started = false
var configured: Bool { client != nil }
convenience init(settings: AnalyticsSettings?) {
guard let settings else { self.init(client: nil); return }
let experiment = ExperimentPlugin.Config(
deploymentKey: settings.deploymentKey,
fetchTimeoutMillis: 5000,
retryFetchOnFailure: false,
automaticExposureTracking: false,
automaticFetchOnAmplitudeIdentityChange: false
)
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,
experimentConfig: experiment,
sessionReplayConfig: replay)
#else
let client = Amplitude(apiKey: settings.apiKey,
serverZone: settings.zone == .EU ? .EU : .US,
analyticsConfig: analytics,
experimentConfig: experiment)
#endif
self.init(client: client)
}
init(client: Amplitude?) { self.client = client }
func start() {
guard !started else { return }
started = true
refresh()
}
func refresh() {
guard client?.experiment != nil else { return }
state.request()
status = "Loading flags; compact fallback available"
fetchNext()
}
private func fetchNext() {
guard let experiment = client?.experiment, let token = state.begin() else { return }
experiment.clear()
experiment.fetch(user: nil) { [weak self] _, error in
let succeeded = error == nil
Task { @MainActor [weak self] in
guard let self else { return }
// A completion can arrive after confirmed logout changed identity.
let value = succeeded ? experiment.variant(Self.flagKey).value : nil
let accepted = self.state.complete(token, value: value, succeeded: succeeded)
if !accepted { experiment.clear() }
if !self.state.pending {
self.status = self.state.assigned
? "Assigned layout: \(self.state.layout.rawValue)"
: "Compact fallback: fetch failed or no supported variant"
}
self.fetchNext()
}
}
}
// Called by the report view's onAppear, not by a variant read in body.
func reportDisplayed(revision: Int) {
guard revision == state.revision, !state.loading else { return }
if state.canExpose(displayedRevision: revision) {
client?.experiment?.exposure(key: Self.flagKey)
}
client?.track(eventType: "Report Previewed", eventProperties: properties())
}
func exportReport() {
guard !state.loading else { return }
client?.track(eventType: "Report Exported", eventProperties: properties())
}
// Merge these callbacks into existing authentication; they don't implement login.
func accountSignedIn(userID: String) {
precondition(userID.count >= 5, "Use your canonical user ID, at least five characters")
client?.setUserId(userId: userID)
refresh()
}
func accountSignedOut() {
client?.reset()
client?.setDeviceId(deviceId: UUID().uuidString)
client?.experiment?.clear()
refresh()
}
func flushAndShowDevice() {
client?.flush()
status = "Device ID: \(client?.getDeviceId() ?? "not available"); flush requested"
}
private func properties() -> [String: Any] {
["report_type": "usage", "days": 7, "platform": "iOS",
"layout": state.layout.rawValue, "layout_source": state.assigned ? "assignment" : "fallback"]
}
}Why. An old fetch can finish after logout and write to the SDK cache. Ignoring its UI callback alone doesn't undo that write. This service rejects the old revision, clears the stale result, and finishes that request before fetching for the latest identity. Loading hides an open report until the decision is settled, avoiding a treatment flash.
Connect accountSignedIn(userID:) after your existing authentication confirms the canonical account. Connect accountSignedOut() after confirmed logout and before another anonymous event. The explicit UUID setter supplies a fresh anonymous device identity even with the pinned SDK's IDFV path. Don't reset for an anonymous or still-loading authentication state. If your app already includes replay, preserve its consent controls and stop capture before the logout reset.
4. Track the layout that appears
The view captures its display revision and records exposure in onAppear. It doesn't read variants in body. Open report shows the report; Export report records a synthetic export action for measurement. This sample doesn't generate a downloadable export file.
// AmplitudeReports/ContentView.swift
import SwiftUI
struct ContentView: View {
@ObservedObject var analytics: ReportAnalytics
@State private var opened = false
var body: some View {
let displayed = analytics.state
VStack(alignment: .leading, spacing: 16) {
Text("Usage report").font(.title)
Text(analytics.status).accessibilityIdentifier("flag-status")
Button("Refresh flags") { analytics.refresh() }
.disabled(!analytics.configured)
Button("Open report") { opened = true }
.disabled(analytics.state.loading)
if opened && !displayed.loading {
ReportPanel(layout: displayed.layout, analytics: analytics)
.id(displayed.revision)
.onAppear { analytics.reportDisplayed(revision: displayed.revision) }
}
Button("Flush and show device ID") { analytics.flushAndShowDevice() }
}
.padding()
.task { analytics.start() }
}
}
private struct ReportPanel: View {
let layout: ReportLayout
@ObservedObject var analytics: ReportAnalytics
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("7-day report: 84 sample actions")
Text("Layout: \(layout.displayName)").accessibilityIdentifier("report-layout")
if layout == .expanded {
Text("Daily average: 12 actions")
Text("Most active day: Tuesday")
}
Button("Export report") { analytics.exportReport() }
}
.padding()
.background(Color.blue.opacity(0.1))
}
}// AmplitudeReports/AmplitudeReportsApp.swift
import SwiftUI
@main
@MainActor
struct AmplitudeReportsApp: App {
@StateObject private var analytics = ReportAnalytics.shared
@Environment(\.scenePhase) private var phase
var body: some Scene {
WindowGroup { ContentView(analytics: analytics) }
.onChange(of: phase) { _, next in
if next == .background { analytics.flushAndShowDevice() }
}
}
}MCP. Connect Amplitude's MCP server to inspect the project's existing event taxonomy before adapting Report Previewed or Report Exported to your app.
5. Configure the flag and experiment
Create the remote flag in the same development project as the analytics key. Follow the flag rollout workflow to associate your client deployment, set targeting, and activate it after QA.
- Create a flag with key
report-layoutand Remote evaluation. - Associate the client deployment whose key is in
Local.xcconfig. - Configure variant values
controlandexpanded. Thecontrolvalue renders the compact layout. These are the exact strings the code accepts; a display name alone doesn't select the layout. - Limit targeting to your development testers. Keep All Other Users at zero rollout. Save and QA the flag before making it Active for those testers.
- Exercise one explicit control assignment and one expanded assignment, refreshing after each targeting change. Don't treat the compact fallback as a verified control assignment.
# Development flag contract
flag key: report-layout
evaluation: Remote
deployment: your iOS client deployment
variant values: control, expanded
All Other Users rollout: 0%To measure an experiment, convert the flag to an experiment, retain the same flag key and values, and set Report Exported as the primary conversion metric. Use the Amplitude-defined [Experiment] Exposure as the client-side exposure event. Review targeting and metrics before starting the experiment. A flag assignment by itself doesn't create a running experiment or establish a winning layout.
Verify it worked
Run the sample in this order, using only synthetic actions:
- Unconfigured app: leave either client key blank. The status says flags are off, Refresh is disabled, and Open report still shows compact. No client is constructed by this sample.
- Local fetch and display: add both keys, rebuild, and load the flag. Check
Assigned layout: controlorAssigned layout: expanded. Open the report and confirm that only expanded shows the daily average and most-active-day rows. A network error or unrecognized variant shows a labeled compact fallback instead. - Native logs and requests: use Xcode diagnostics to distinguish a failed evaluation request from a successful fetch. Don't log credentials or real user data. A successful evaluation request is not proof of an exposure or conversion.
- Processed events: press Flush and show device ID. In Users > User Profiles, search that exact device ID and inspect its events. Expect
[Experiment] Exposureforreport-layoutand the assigned variant when the report appears, followed byReport Previewed. Tap Export report and checkReport Exported,layout_source: assignment, and the same device/user identity. Flush requests upload; it doesn't acknowledge ingestion. The SDK can deduplicate repeated exposure calls, so don't require one processed exposure for every tap. - Fallback and identity: repeat with failed evaluation and an absent flag. The compact fallback must not emit an exposure for an assigned layout. In your authenticated integration, change accounts or log out while a fetch is pending. Confirm the previous layout disappears, the next fetch uses the current identity, and post-logout events use the new anonymous device ID.
- Experiment configuration: check the saved exposure and primary metric in the started development experiment. Confirm both variants' processed exposure and export events before interpreting its analysis. Synthetic QA traffic isn't evidence of statistical significance.
Troubleshooting
Flags are off after adding a key
Both client keys and the region are required. Confirm the target's base configuration, plist path, and build-setting substitutions, then rebuild. An unresolved $(...) value is rejected.
A fetch succeeds but the report stays compact
Check whether the status says assigned or fallback. Confirm report-layout, the exact variant value, client deployment association, active status, tester targeting, and region. A successful request can return no usable assignment.
Exposure appears before the report is visible
Keep automaticExposureTracking: false and the manual exposure call in the display callback. Remove earlier variant reads that track automatically and preserve the shared client. Don't add a custom exposure event beside the integrated one.
An old account's layout returns after logout
Route identity changes and flag fetches through the same service. Preserve revision invalidation and fetch serialization. clear() removes stored variants but doesn't cancel an already-running fetch; that response must be discarded and cleared before refetching.
The layout changes but experiment analysis is empty
Check the processed [Experiment] Exposure and Report Exported events, project, identity, experiment start time, saved exposure definition, and primary metric. An assignment or report label doesn't prove processed measurement.
Frequently asked questions
Does the compact fallback count as control exposure?
No. This sample records exposure only for a recognized fetched assignment after the report appears. A real fetched control variant is different from the app's compact fallback. The layout_source event property distinguishes them when inspecting preview and export events.
Do I need a separate Experiment client?
No. The Unified SDK owns the experiment integration, shared identity provider, and exposure provider. This sample uses its experiment property and an explicit client deployment key. Keep one shared client rather than adding a second initialization with its own identity or exposure tracking.
Can I use this in an existing authenticated app?
Yes. Merge the callbacks into confirmed account transitions without replacing your authentication or navigation. Use the canonical user ID, invalidate the displayed assignment, and refetch. The sample's logout helper resets identity and explicitly selects a new UUID before subsequent anonymous events. Verify the complete account-transition flow in your own app.
Does this enable session replay?
This sample configures replay with zero sampling, remote configuration disabled, and automatic start disabled. If your app already records replay, preserve its separately configured consent policy and privacy settings when merging the flag service. Verify capture behavior separately from experiment assignment and analytics delivery.
Full code reference
These are the complete application and configuration files shown above. Use the existing Xcode App scaffold and the exact Unified package dependency from Step 1.
# Config/Amplitude.xcconfig
#include? "Local.xcconfig"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = AmplitudeReports/Info.plist
INFOPLIST_EXPAND_BUILD_SETTINGS = YES# .gitignore
Config/Local.xcconfig
.build/
DerivedData/
*.xcuserstate
xcuserdata/<?xml version="1.0" encoding="UTF-8"?>
<!-- AmplitudeReports/Info.plist -->
<!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>
<key>AMPLITUDE_DEPLOYMENT_KEY</key>
<string>$(AMPLITUDE_DEPLOYMENT_KEY)</string>
</dict>
</plist>// AmplitudeReports/Core/AnalyticsSettings.swift
import Foundation
struct AnalyticsSettings {
enum Zone: String { case US, EU }
let apiKey: String
let deploymentKey: String
let zone: Zone
init?(info: [String: Any]) {
func key(_ name: String) -> String {
(info[name] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
let apiKey = key("AMPLITUDE_API_KEY")
let deploymentKey = key("AMPLITUDE_DEPLOYMENT_KEY")
let region = key("AMPLITUDE_SERVER_ZONE")
guard !apiKey.isEmpty, !deploymentKey.isEmpty,
!apiKey.contains("$("), !deploymentKey.contains("$("),
let zone = Zone(rawValue: region) else { return nil }
self.apiKey = apiKey
self.deploymentKey = deploymentKey
self.zone = zone
}
}// AmplitudeReports/Core/FlagState.swift
enum ReportLayout: String {
case compact = "control"
case expanded
var displayName: String { self == .compact ? "compact" : "expanded" }
}
// Application policy: serialize fetches and reject results from an older identity.
struct FlagState {
private(set) var revision = 0
private(set) var layout: ReportLayout = .compact
private(set) var assigned = false
private(set) var pending = false
private(set) var activeRequest: Int?
var loading: Bool { pending || activeRequest != nil }
mutating func request() {
revision += 1
layout = .compact
assigned = false
pending = true
}
mutating func begin() -> Int? {
guard pending, activeRequest == nil else { return nil }
pending = false
activeRequest = revision
return revision
}
// False tells the adapter to clear an old response from the SDK cache.
mutating func complete(_ token: Int, value: String?, succeeded: Bool) -> Bool {
guard activeRequest == token else { return false }
activeRequest = nil
guard token == revision else { return false }
if succeeded, let value, let accepted = ReportLayout(rawValue: value) {
layout = accepted
assigned = true
}
return true
}
func canExpose(displayedRevision: Int) -> Bool {
assigned && !loading && displayedRevision == revision
}
}// AmplitudeReports/Core/ReportAnalytics.swift
import AmplitudeUnified
import Combine
import Foundation
@MainActor
final class ReportAnalytics: ObservableObject {
static let shared = ReportAnalytics(settings: AnalyticsSettings(info: Bundle.main.infoDictionary ?? [:]))
static let flagKey = "report-layout"
private let client: Amplitude?
@Published private(set) var state = FlagState()
@Published private(set) var status = "Flags off: check both client keys and region"
private var started = false
var configured: Bool { client != nil }
convenience init(settings: AnalyticsSettings?) {
guard let settings else { self.init(client: nil); return }
let experiment = ExperimentPlugin.Config(
deploymentKey: settings.deploymentKey,
fetchTimeoutMillis: 5000,
retryFetchOnFailure: false,
automaticExposureTracking: false,
automaticFetchOnAmplitudeIdentityChange: false
)
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,
experimentConfig: experiment,
sessionReplayConfig: replay)
#else
let client = Amplitude(apiKey: settings.apiKey,
serverZone: settings.zone == .EU ? .EU : .US,
analyticsConfig: analytics,
experimentConfig: experiment)
#endif
self.init(client: client)
}
init(client: Amplitude?) { self.client = client }
func start() {
guard !started else { return }
started = true
refresh()
}
func refresh() {
guard client?.experiment != nil else { return }
state.request()
status = "Loading flags; compact fallback available"
fetchNext()
}
private func fetchNext() {
guard let experiment = client?.experiment, let token = state.begin() else { return }
experiment.clear()
experiment.fetch(user: nil) { [weak self] _, error in
let succeeded = error == nil
Task { @MainActor [weak self] in
guard let self else { return }
// A completion can arrive after confirmed logout changed identity.
let value = succeeded ? experiment.variant(Self.flagKey).value : nil
let accepted = self.state.complete(token, value: value, succeeded: succeeded)
if !accepted { experiment.clear() }
if !self.state.pending {
self.status = self.state.assigned
? "Assigned layout: \(self.state.layout.rawValue)"
: "Compact fallback: fetch failed or no supported variant"
}
self.fetchNext()
}
}
}
// Called by the report view's onAppear, not by a variant read in body.
func reportDisplayed(revision: Int) {
guard revision == state.revision, !state.loading else { return }
if state.canExpose(displayedRevision: revision) {
client?.experiment?.exposure(key: Self.flagKey)
}
client?.track(eventType: "Report Previewed", eventProperties: properties())
}
func exportReport() {
guard !state.loading else { return }
client?.track(eventType: "Report Exported", eventProperties: properties())
}
// Merge these callbacks into existing authentication; they don't implement login.
func accountSignedIn(userID: String) {
precondition(userID.count >= 5, "Use your canonical user ID, at least five characters")
client?.setUserId(userId: userID)
refresh()
}
func accountSignedOut() {
client?.reset()
client?.setDeviceId(deviceId: UUID().uuidString)
client?.experiment?.clear()
refresh()
}
func flushAndShowDevice() {
client?.flush()
status = "Device ID: \(client?.getDeviceId() ?? "not available"); flush requested"
}
private func properties() -> [String: Any] {
["report_type": "usage", "days": 7, "platform": "iOS",
"layout": state.layout.rawValue, "layout_source": state.assigned ? "assignment" : "fallback"]
}
}// AmplitudeReports/ContentView.swift
import SwiftUI
struct ContentView: View {
@ObservedObject var analytics: ReportAnalytics
@State private var opened = false
var body: some View {
let displayed = analytics.state
VStack(alignment: .leading, spacing: 16) {
Text("Usage report").font(.title)
Text(analytics.status).accessibilityIdentifier("flag-status")
Button("Refresh flags") { analytics.refresh() }
.disabled(!analytics.configured)
Button("Open report") { opened = true }
.disabled(analytics.state.loading)
if opened && !displayed.loading {
ReportPanel(layout: displayed.layout, analytics: analytics)
.id(displayed.revision)
.onAppear { analytics.reportDisplayed(revision: displayed.revision) }
}
Button("Flush and show device ID") { analytics.flushAndShowDevice() }
}
.padding()
.task { analytics.start() }
}
}
private struct ReportPanel: View {
let layout: ReportLayout
@ObservedObject var analytics: ReportAnalytics
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("7-day report: 84 sample actions")
Text("Layout: \(layout.displayName)").accessibilityIdentifier("report-layout")
if layout == .expanded {
Text("Daily average: 12 actions")
Text("Most active day: Tuesday")
}
Button("Export report") { analytics.exportReport() }
}
.padding()
.background(Color.blue.opacity(0.1))
}
}// AmplitudeReports/AmplitudeReportsApp.swift
import SwiftUI
@main
@MainActor
struct AmplitudeReportsApp: App {
@StateObject private var analytics = ReportAnalytics.shared
@Environment(\.scenePhase) private var phase
var body: some Scene {
WindowGroup { ContentView(analytics: analytics) }
.onChange(of: phase) { _, next in
if next == .background { analytics.flushAndShowDevice() }
}
}
}# Config/Local.xcconfig
AMPLITUDE_API_KEY =
AMPLITUDE_DEPLOYMENT_KEY =
AMPLITUDE_SERVER_ZONE = USHand it to your agent
Follow https://amplitude.com/build/ios-feature-flags. Build the complete iOS 17+ SwiftUI report sample or merge it into the existing app without replacing auth, navigation, consent or instrumentation. Use exactly AmplitudeUnified-Swift 0.0.4 and one shared client with its owned Experiment integration. Read both client keys and US/EU region from ignored xcconfig/plist substitutions. Never upload server credentials. Configure report-layout with control and expanded values (control renders compact) and an explicit client deployment in the same analytics project. Disable automatic exposure and background fetch retries; use serialized remote fetches, revision invalidation and stale-cache clearing across account changes. Show compact fallback without an assigned exposure on missing/failed/unknown variants. Track manual exposure only after the assigned report appears, then Report Previewed; track Report Exported only from its button. Keep layout_source on business events. Connect canonical IDs after confirmed sign-in and reset plus a new UUID on confirmed logout, before another event; don't reset anonymous/loading auth. Preserve replay consent and privacy when merging; this sample keeps replay off. Separately verify local rendering, actual SDK behavior, processed exposure/conversion identity and the saved experiment's metric configuration. Don't claim a successful fetch proves exposure, a running experiment or a winning result.What this unlocks
Product analytics compares report exports after each displayed layout. Experimentation uses [Experiment] Exposure and Report Exported after you configure and start the experiment. Session replay can add visual context through a separately consented integration.