---
title: "How to add feature flags and experiments to an iOS app with Swift"
description: "Fetch SwiftUI report-layout flags, keep a compact fallback, and track displayed assignments and report exports with one shared Amplitude client."
canonical: "https://amplitude.com/en-us/build/ios-feature-flags"
---

# How to add feature flags and experiments to an iOS app with Swift

Fetch SwiftUI report-layout flags, keep a compact fallback, and track displayed assignments and report exports with one shared Amplitude client.

Source: https://amplitude.com/en-us/build/ios-feature-flags

---

# How to add feature flags and experiments to an iOS app with Swift

Add a remote report-layout flag to a SwiftUI app with Amplitude's Unified SDK. I'll render compact and expanded reports, track exposure when the assigned layout appears, and send a report-export event for experiment analysis.

- iOS 17+
- SwiftUI
- Unified Swift SDK 0.0.4
- 35 min

Chris Tam · Last updated 29 October 2026

SwiftUI builds screens from state. With [feature flags](https://amplitude.com/feature-management), 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](https://github.com/amplitude/ampli-examples/tree/ac5c4097bb887c8595a1d71e493849091011a7e7/ios/swiftui-feature-flags) 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](https://amplitude.com/pricing) for your workspace's availability.

[Sign up for free](https://app.amplitude.com/signup) 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](https://amplitude.com/docs/get-started/install-mobile-sdk#ios).

Create the source and configuration folders inside the Xcode project directory:

Terminal, from the directory containing AmplitudeReports.xcodeproj

```sh
# Terminal, from the directory containing AmplitudeReports.xcodeproj
mkdir -p AmplitudeReports/Core Config
```

I 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

```text
# Config/Amplitude.xcconfig
#include? "Local.xcconfig"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = AmplitudeReports/Info.plist
INFOPLIST_EXPAND_BUILD_SETTINGS = YES
```

Config/Local.xcconfig

```text
# Config/Local.xcconfig
AMPLITUDE_API_KEY =
AMPLITUDE_DEPLOYMENT_KEY =
AMPLITUDE_SERVER_ZONE = US
```

.gitignore

```text
# .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
<?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

```swift
// 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

```swift
// 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](https://amplitude.com/docs/sdks/analytics/ios/unified-sdk#experiment). 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

```swift
// 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

```swift
// 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

```swift
// 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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) 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](https://amplitude.com/docs/feature-experiment/workflow/feature-flag-rollouts) to associate your client deployment, set targeting, and activate it after QA.

1. Create a flag with key `report-layout` and **Remote** evaluation.
2. Associate the client deployment whose key is in `Local.xcconfig`.
3. Configure variant values `control` and `expanded`. The `control` value renders the compact layout. These are the exact strings the code accepts; a display name alone doesn't select the layout.
4. 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.
5. 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

```text
# 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](https://amplitude.com/docs/feature-experiment/experiment-quick-start#design-the-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:

1. **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.
2. **Local fetch and display:** add both keys, rebuild, and load the flag. Check `Assigned layout: control` or `Assigned 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.
3. **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.
4. **Processed events:** press Flush and show device ID. In **Users > User Profiles**, search that exact device ID and inspect its events. Expect `[Experiment] Exposure` for `report-layout` and the assigned variant when the report appears, followed by `Report Previewed`. Tap Export report and check `Report 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.
5. **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.
6. **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.

Full code reference

```text
# Config/Amplitude.xcconfig
#include? "Local.xcconfig"
GENERATE_INFOPLIST_FILE = NO
INFOPLIST_FILE = AmplitudeReports/Info.plist
INFOPLIST_EXPAND_BUILD_SETTINGS = YES
```

Full code reference

```text
# .gitignore
Config/Local.xcconfig
.build/
DerivedData/
*.xcuserstate
xcuserdata/
```

Full code reference

```xml
<?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>
```

Full code reference

```swift
// 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
    }
```

Full code reference

```swift
// 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
    }
```

Full code reference

```swift
// 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"]
    }
```

Full code reference

```swift
// 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))
    }
```

Full code reference

```swift
// 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() }
            }
```

Full code reference

```text
# Config/Local.xcconfig
AMPLITUDE_API_KEY =
AMPLITUDE_DEPLOYMENT_KEY =
AMPLITUDE_SERVER_ZONE = US
```

## Hand it to your agent

Using Cursor or Claude Code? Paste this prompt

```text
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](https://amplitude.com/amplitude-analytics) compares report exports after each displayed layout. [Experimentation](https://amplitude.com/amplitude-experiment) uses `[Experiment] Exposure` and `Report Exported` after you configure and start the experiment. [Session replay](https://amplitude.com/session-replay) can add visual context through a separately consented integration.

## Next steps

[MobileTrack iOS product analyticsReview the shared identity and event setup.](https://amplitude.com/build/ios-product-analytics)[MobileAdd iOS session replayAdd consent controls and verify the processed recording.](https://amplitude.com/build/ios-session-replay)[PlatformDesign your experimentSet the primary metric before starting analysis.](https://amplitude.com/docs/feature-experiment/experiment-quick-start#design-the-experiment)

Reference docs

[Unified Swift SDK →](https://amplitude.com/docs/sdks/analytics/ios/unified-sdk)[iOS experiment SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-ios)[Deployments and variants →](https://amplitude.com/docs/feature-experiment/data-model)[Create and roll out a flag →](https://amplitude.com/docs/feature-experiment/workflow/feature-flag-rollouts)[Experiment exposure tracking →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Debug analytics delivery →](https://amplitude.com/docs/analytics/debug-analytics)

For agents

[View raw .md](/build/ios-feature-flags.md)[llms.txt](/llms.txt)

## Test the layout before rollout

Verify each displayed variant and its processed events in a development project before expanding targeting.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
