Use this guide when you want to add Optimization, Analytics, screen tracking, entry interaction
tracking, and preview overrides to a UIKit application using the ContentfulOptimization Swift
Package.
Use the SwiftUI guide instead when your app renders optimized entries through SwiftUI views: Integrating the Optimization iOS SDK in a SwiftUI app.
This path proves the SDK can initialize in a UIKit scene and emit one screen event. It assumes
application policy permits accepted SDK startup. If your app must wait for a CMP or consent UI,
leave defaults unset and wire consent in the consent handoff section. Strict
opt-in apps must also pass allowedEventTypes: [] before enabling gated events.
ContentfulOptimization Swift Package from
https://github.com/contentful/optimization.swift to the app target.OptimizationClient for the scene or app lifetime.viewDidAppear(_:).Optimization screen event accepted.Copy this:
import ContentfulOptimization
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
// Own one SDK client for the scene or app lifetime, then inject this same
// instance into UIKit controllers that resolve entries or track events.
private let client = OptimizationClient()
func scene(
_ scene: UIScene,
willConnectTo _: UISceneSession,
options _: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
try? client.initialize(config: OptimizationConfig(
clientId: "your-client-id",
environment: "main",
// Use accepted startup only when your app's consent policy permits
// SDK event emission before showing a consent UI.
defaults: StorageDefaults(consent: true)
))
let home = HomeViewController(client: client)
window = UIWindow(windowScene: windowScene)
window?.rootViewController = UINavigationController(rootViewController: home)
window?.makeKeyAndVisible()
}
}
final class HomeViewController: UIViewController {
private let client: OptimizationClient
private let statusLabel = UILabel()
init(client: OptimizationClient) {
self.client = client
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
statusLabel.text = "Waiting for Optimization"
statusLabel.textAlignment = .center
statusLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(statusLabel)
NSLayoutConstraint.activate([
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task { @MainActor in
// Track the current screen after UIKit has made it visible, so
// verification matches a real navigation lifecycle and repeated
// callbacks are deduplicated by the SDK.
let result = try? await client.trackCurrentScreen(name: "Home")
if result?.accepted == true {
statusLabel.text = "Optimization screen event accepted"
} else {
statusLabel.text = "Optimization screen event blocked"
}
}
}
}
Use this setup inventory for the full UIKit guide:
| Setup item | Category | Required for quick start | Where to configure |
|---|---|---|---|
ContentfulOptimization Swift Package |
Required for first integration | Yes | Xcode Swift Package Manager or the app target's Package.swift |
| Optimization client ID and Contentful environment | Required for first integration | Yes | OptimizationConfig(clientId:environment:) |
| App-owned Contentful Delivery API client, credentials, and concrete locale | Required for first integration | No | Application Contentful service or repository layer |
| Single-locale Contentful entry payloads with linked optimization entries | Required for first integration | No | CDA or CPA requests with include depth and a non-wildcard locale |
Scene or app coordinator that owns one OptimizationClient |
Required for first integration | Yes | SceneDelegate, AppDelegate, or an app-level dependency container |
| UIKit view, cell, or wrapper that resolves entries before rendering | Required for first integration | No | UIViewController, UITableViewCell, UICollectionViewCell, or UIView |
| Consent and privacy startup policy | Common but policy-dependent | Conditional | StorageDefaults, app consent UI, CMP callback, or account preference |
| Pre-consent event allow-list | Common but policy-dependent | Conditional | OptimizationConfig.allowedEventTypes |
| Screen lifecycle hook | Required for first integration | Yes | viewDidAppear(_:), navigation coordinator, or app router |
| Route-key naming for duplicate screen prevention | Common but policy-dependent | No | trackCurrentScreen(name:properties:routeKey:) or app router |
| Entry tap and view-tracking metadata | Common but policy-dependent | Conditional | UIKit control actions, gesture recognizers, scroll-view geometry, or cells |
| Identity and profile-continuity policy | Common but policy-dependent | No | Sign-in, account, consent, and reset flows |
| Custom Flag reads and analytics debug streams | Optional | No | Feature surfaces, debug views, or app analytics forwarding layer |
| Preview-panel Contentful client and internal access gate | Optional | No | Debug or internal-build preview setup |
| Queue policy, offline diagnostics, and app-owned content cache policy | Advanced or production-only | No | OptimizationConfig(queuePolicy:), app telemetry, and content cache code |
The SDK does not replace the app's Contentful client. Your UIKit app still owns Contentful fetching, link resolution, consent UX, identity policy, navigation, caching, and rendering.
Integration category: Required for first integration
Add the package from https://github.com/contentful/optimization.swift, then initialize the SDK
with the Optimization client ID and the environment that matches your Contentful setup. The package
requires iOS 15 or later.
Copy this:
dependencies: [
.package(url: "https://github.com/contentful/optimization.swift.git", from: "<version>"),
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "ContentfulOptimization", package: "optimization.swift"),
]
),
],
ContentfulOptimization product to the app target."en-US".locale when Experience API requests and event context need to use
the same language as the rendered Contentful entries.logLevel at its default .error for production unless your operational policy explicitly
allows more verbose logging.Copy this:
let appLocale = "en-US"
let config = OptimizationConfig(
clientId: "your-client-id",
environment: "main",
// Keep SDK event and Experience locale aligned with rendered CDA entries
// when the screen uses localized Contentful content.
locale: appLocale
)
Only clientId is required by the initializer. environment defaults to "main", and locale is
omitted unless you pass it. Use API base URL overrides only for mock, test, or non-default API
endpoints. For package-level installation notes, see the
Optimization iOS SDK README.
Integration category: Required for first integration
UIKit integrations use OptimizationClient directly. Keep one initialized client alive for the
scene or app lifetime, then inject that instance into every controller or view that resolves entries
or tracks events.
SceneDelegate, AppDelegate, or an app-level dependency container.initialize(config:) before presenting content that uses Optimization.OptimizationClient is @MainActor.Adapt this to your use case:
import ContentfulOptimization
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
// Keep this initialized client alive across UIKit navigation.
private let client = OptimizationClient()
func scene(
_ scene: UIScene,
willConnectTo _: UISceneSession,
options _: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
// Initialize before presenting screens that resolve entries or track events.
try? client.initialize(config: config)
let root = HomeViewController(client: client)
window = UIWindow(windowScene: windowScene)
window?.rootViewController = UINavigationController(rootViewController: root)
window?.makeKeyAndVisible()
}
}
Use destroy() for test teardown or a deliberate SDK teardown flow, not for normal navigation
between UIKit screens. For lifecycle and main-actor mechanics, see
iOS SDK runtime and interaction mechanics.
Integration category: Common but policy-dependent
Consent policy remains application-owned. The SDK provides the runtime gate; your app or CMP owns notice, user choices, consent records, jurisdiction logic, and withdrawal behavior.
defaults unset when the app must collect a choice before gated Analytics events can emit.allowedEventTypes: [] when strict opt-in policy means no Optimization event can emit
before consent.client.consent(true) or client.consent(false) from the app's consent controls.client.$state when the UI needs to reflect event consent or persistence consent.Copy this:
let config = OptimizationConfig(
clientId: "your-client-id",
// Accepted startup consent enables gated Analytics events immediately.
defaults: StorageDefaults(consent: true)
)
Copy this:
let config = OptimizationConfig(
clientId: "your-client-id",
// Replaces the native default pre-consent allow-list of identify and screen.
allowedEventTypes: []
)
Adapt this to your use case:
@objc private func acceptTapped() {
// Wire this to the app-owned consent UI or CMP callback.
client.consent(true)
}
@objc private func rejectTapped() {
client.consent(false)
}
@objc private func allowEventsOnlyTapped() {
client.consent(events: true, persistence: false)
}
Boolean consent controls both event emission and durable profile-continuity persistence by default.
When allowedEventTypes is unset, the native default allow-list lets identify and screen emit
before consent so a mobile journey can establish profile context and anonymous screen analytics.
Custom allowedEventTypes replaces that default, and allowedEventTypes: [] blocks every SDK event
until consent is accepted. For the full consent responsibility model, see
Consent management in the Optimization SDK Suite.
Integration category: Required for first integration
The SDK resolves entries locally after your app has fetched Contentful data and the SDK has selected optimizations for the visitor.
locale=* or all-locale SDK helpers into entry resolution.fields.nt_experiences, the referenced optimization
entries, and fields.nt_variants to be present as resolved dictionaries.locale when rendered content and events need
to use the same locale.result.entry. Use result.selectedOptimization and result.optimizationContextId only
when building tracking payloads.Follow this pattern:
let entry = try await contentfulEntryService.fetchEntry(
id: entryId,
include: 10,
// Resolve and pass one concrete CDA locale, not locale=* payloads.
locale: appLocale
)
let result = client.resolveOptimizedEntry(
baseline: entry,
selectedOptimizations: client.selectedOptimizations
)
// Always render result.entry; it falls back to the baseline entry when no
// selected optimization can be applied.
contentView.configure(with: result.entry)
resolveOptimizedEntry(baseline:selectedOptimizations:) is synchronous and fail-soft. It returns
the baseline entry when no selected optimization matches, when the entry is not optimized, when the
linked optimization data is missing, or when the selected variant is not present in the Contentful
payload. For deeper resolver mechanics, see
Entry optimization and variant resolution.
Integration category: Common but policy-dependent
UIKit apps decide when a screen is visible, when a user interacted with a Contentful entry, and when an entry has met the app's visibility threshold.
viewDidAppear(_:) for screens that represent navigation destinations.Use trackCurrentScreen(name:properties:routeKey:) for UIKit lifecycle and navigation tracking
because it deduplicates the current route. Use screen(name:properties:) only for intentional
one-off raw screen events.
Copy this:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task { @MainActor in
// Emit from viewDidAppear so UIKit has completed the visible transition.
_ = try? await client.trackCurrentScreen(
name: "ProductDetail",
properties: ["entryId": entryId],
// Use a stable route key to prevent duplicate current-screen events
// when UIKit lifecycle callbacks repeat for the same destination.
routeKey: "product-detail-\(entryId)"
)
}
}
Use custom events for business actions that are not tied to a Contentful entry replacement.
Copy this:
Task { @MainActor in
// This event is useful for local verification through eventStream or the
// iOS reference app event display.
_ = try? await client.track(
event: "Purchase Completed",
properties: ["sku": sku]
)
}
ResolvedOptimizedEntry produced during render or view configuration.client.trackClick(TrackClickPayload(...)) from a UIControl action or gesture recognizer.
For gesture recognizers, gate the dispatch to the completed gesture state instead of suppressing
later taps for the view lifetime.Adapt this to your use case:
private var latestBaselineEntry: [String: Any]?
private var latestResolution: ResolvedOptimizedEntry?
func configure(entry: [String: Any]) {
let result = client.resolveOptimizedEntry(
baseline: entry,
selectedOptimizations: client.selectedOptimizations
)
latestBaselineEntry = entry
latestResolution = result
contentView.configure(with: result.entry)
}
@objc private func primaryCTATapped() {
guard let entry = latestBaselineEntry, let result = latestResolution else { return }
// Use the same optimization context that produced the rendered variant.
let metadata = TrackingMetadata(
entry: entry,
optimizationContextId: result.optimizationContextId,
selectedOptimization: result.selectedOptimization
)
Task { @MainActor in
try? await client.trackClick(TrackClickPayload(
componentId: metadata.componentId,
experienceId: metadata.experienceId,
optimizationContextId: metadata.optimizationContextId,
variantIndex: metadata.variantIndex
))
}
}
UIKit does not automatically infer component visibility. Use app-owned scroll-view, table-view, or
collection-view geometry and either call client.trackView(TrackViewPayload(...)) directly or use
ViewTrackingController to apply the SDK's visibility timing model.
Follow this pattern:
final class OptimizedEntryView: UIView {
private let client: OptimizationClient
private let entry: [String: Any]
private weak var scrollView: UIScrollView?
private var trackingController: ViewTrackingController?
private func rebuildTracking(result: ResolvedOptimizedEntry) {
// End the previous visibility cycle before replacing tracking metadata
// for a newly resolved variant.
trackingController?.onDisappear()
trackingController = ViewTrackingController(
client: client,
entry: entry,
optimizationContextId: result.optimizationContextId,
selectedOptimization: result.selectedOptimization
)
}
private func emitVisibility() {
guard let controller = trackingController, let scrollView else { return }
let frameInScroll = convert(bounds, to: scrollView)
// UIKit owns geometry; the SDK controller owns timing, consent checks,
// and duplicate duration-event prevention for this visibility cycle.
controller.updateVisibility(
elementY: frameInScroll.minY,
elementHeight: bounds.height,
scrollY: scrollView.contentOffset.y,
viewportHeight: scrollView.bounds.height
)
}
}
ViewTrackingController uses the same default model documented for the iOS SDK: an initial view
event after 2 seconds at 80% visibility, periodic duration updates every 5 seconds while visible,
and a final duration update after the entry leaves view once a view event has emitted. For shared
tracking mechanics and event delivery, see
iOS SDK runtime and interaction mechanics.
Integration category: Common but policy-dependent
Identity policy belongs to the application. The SDK can identify a visitor, update selected optimizations from Experience API responses, persist profile-continuity state when allowed, and reset SDK-managed profile state.
identify(userId:traits:) after sign-in or when the app has a stable application user ID.reset() when the app's logout or privacy flow must clear SDK-managed profile,
selected-optimization, change, and anonymous ID state.Copy this:
Task { @MainActor in
_ = try? await client.identify(
userId: user.id,
traits: ["plan": user.plan]
)
}
Copy this:
client.reset()
When durable profile-continuity persistence is allowed, SDK state from an Experience response is
published after the corresponding UserDefaults write settles. In tests and relaunch flows, wait
for SDK-derived UI or state instead of adding arbitrary storage delays.
Integration category: Optional
UIKit apps choose whether optimized content updates live or locks to the first selected variant for the screen.
client.selectedOptimizations ?? [] and set a separate hasLockedOptimizations flag.resolveOptimizedEntry call on the locked screen. Do not
pass nil for locked screens because nil tells the resolver to use current SDK state.client.$selectedOptimizations and redraw affected views for live behavior.client.isPreviewPanelOpen as a reason to redraw live while previewing.Adapt this to your use case:
private var lockedOptimizations: [[String: Any]] = []
private var hasLockedOptimizations = false
func lockVariantsForScreen() {
guard !hasLockedOptimizations else { return }
// Call this after the screen or identity event this screen locks to
// has resolved.
// Capture an explicit screen-level snapshot. Empty array means lock to no
// selected variants; nil asks the resolver to use current SDK state.
lockedOptimizations = client.selectedOptimizations ?? []
hasLockedOptimizations = true
}
func handleScreenEventResolved(entry: [String: Any]) {
lockVariantsForScreen()
render(entry: entry)
}
func render(entry: [String: Any]) {
guard hasLockedOptimizations else { return }
let result = client.resolveOptimizedEntry(
baseline: entry,
selectedOptimizations: lockedOptimizations
)
contentView.configure(with: result.entry)
}
Adapt this to your use case:
client.$selectedOptimizations
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard self?.client.isPreviewPanelOpen == true || self?.liveUpdates == true else {
return
}
self?.reloadVisibleContent()
}
.store(in: &cancellables)
For the precedence between live updates, locked variants, and preview-panel state, see iOS SDK runtime and interaction mechanics.
Integration category: Optional
PreviewPanelViewController hosts the SDK preview panel from a UIKit navigation stack. Gate it
behind a debug or internal-build condition so production users cannot open local audience and
variant overrides.
PreviewContentfulClient that can fetch nt_audience and nt_experience
entries.PreviewPanelViewController yourself.OptimizationClient used by the rest of the app.PreviewContentfulClient when the panel needs audience and experience override controls.Adapt this to your use case:
#if DEBUG
let previewContentfulClient = ContentfulHTTPPreviewClient(
spaceId: "your-space-id",
accessToken: "your-cda-token",
environment: "main"
)
PreviewPanelViewController.addFloatingButton(
to: homeViewController,
// Pass the app-owned SDK instance so preview overrides affect the same
// resolver and event state used by the screen.
client: client,
contentfulClient: previewContentfulClient
)
#endif
Passing contentfulClient is optional only for profile and debug state. Without it, the panel can
still open, but no audience or experience definitions are loaded: the audience section is empty,
audience and variant override controls are unavailable, and existing override summaries can fall
back to identifiers.
Integration category: Optional
Use Custom Flags when your Contentful optimization data includes inline variable changes rather than entry replacement. Use event streams for local diagnostics, app-owned debug views, or governed analytics forwarding.
getFlag(_:) when a synchronous value is enough.flagPublisher(_:) when the UI needs to update as the SDK receives changed flag
values.eventStream only for diagnostics or application-owned forwarding that has passed
consent and destination governance review.blockedEventStream, or configure onEventBlocked at startup, to debug consent or
pre-consent allow-list blocks during integration.Copy this:
let flagValue = client.getFlag("boolean")
Adapt this to your use case:
client.flagPublisher("boolean")
.receive(on: RunLoop.main)
.sink { [weak self] value in
self?.applyBooleanFlag(value)
}
.store(in: &cancellables)
Adapt this to your use case:
client.eventStream
.sink { event in
analyticsDebugStore.append(event)
}
.store(in: &cancellables)
When forwarding SDK events to third-party destinations, apply the same app-owned consent policy, deduplication, and data-minimization rules that govern the destination.
Use EventEmissionResult, queue callbacks, logs, and app-owned diagnostics for other guard or
suppression cases.
Integration category: Optional
Use this section when the app can change language or locale after SDK startup. The SDK locale and the Contentful CDA locale are separate inputs, even when they usually carry the same value.
setLocale(_:) to update the SDK Experience/event locale.Adapt this to your use case:
let nextLocale = "de-DE"
try client.setLocale(nextLocale)
entries = try await contentfulEntryService.fetchEntries(
ids: entryIds,
include: 10,
// Refetch CDA content in the same locale used for SDK event context.
locale: nextLocale
)
reloadVisibleContent()
For the full locale model, see Locale handling in the Optimization SDK Suite.
Integration category: Advanced or production-only
The iOS SDK monitors network reachability, queues events while offline, flushes when connectivity returns, and flushes as the app moves toward the background. No setup is required for the default offline path.
QueuePolicy only when production telemetry needs queue limits or queue lifecycle callbacks.flush() only for deliberate release, test, or lifecycle flows. The SDK already flushes on
background and reconnect events.Adapt this to your use case:
let config = OptimizationConfig(
clientId: "your-client-id",
queuePolicy: QueuePolicy(
offlineMaxEvents: 500,
onOfflineDrop: { event in
diagnostics.record("optimization-offline-drop", context: event.context)
},
onFlushFailure: { event in
diagnostics.record("optimization-flush-failure", context: event.context)
},
onFlushRecovered: { event in
diagnostics.record("optimization-flush-recovered", context: event.context)
}
)
)
Before release, verify the UIKit integration against these checks:
locale, and CDA locale. Non-default API base URLs and .debug
logging are absent from production builds unless explicitly approved.reset() behavior match the app's legal and privacy requirements.APP_SHELL=uikit ./scripts/run-e2e.sh from implementations/ios-sdk/.include depth for nt_experiences and nt_variants, initialized the client,
and has non-empty client.selectedOptimizations for the visitor.allowedEventTypes, the component ID from
TrackingMetadata, UIKit gesture wiring, and whether the view reached the configured visibility
threshold long enough to emit.viewDidAppear(_:) calls for modal, tab, and
navigation-controller transitions, then add route-key or app-level guards that match your
analytics model.PreviewContentfulClient that can fetch
audience and experience entries from the correct space and environment.true, wait for
SDK-published profile or selected-optimization state before terminating tests, and confirm logout
or withdrawal flows are not calling reset().