Use this guide when you want to add Personalization, Analytics, screen tracking, and preview overrides to a SwiftUI application using the Optimization iOS SDK.
For shared runtime behavior, consent gates, tracking thresholds, live-update precedence, and offline delivery, see iOS SDK runtime and interaction mechanics. For cross-SDK consent policy guidance, see Consent management in the Optimization SDK Suite. Use the UIKit guide instead if your app is UIKit-based: Integrating the Optimization iOS SDK in a UIKit app.
The SwiftUI integration uses the SDK's SwiftUI-native API surface:
OptimizationRoot initializes OptimizationClient, injects it into the environment, and defines
global tracking and live-update defaults.OptimizedEntry resolves a personalized Contentful entry and can attach view and tap tracking.OptimizationScrollView provides viewport context for view tracking inside scrollable content..trackScreen(name:) emits screen events when SwiftUI views appear.PreviewPanelOverlay renders a developer-only preview panel entry point.The SDK does not replace your Contentful delivery client. Your application still owns Contentful fetching, consent UX, identity policy, navigation, and rendering.
Most SwiftUI integrations follow this sequence:
OptimizationConfig.OptimizationRoot.OptimizedEntry..trackScreen(name:).Optional additions include live updates when entries need to react to optimization state changes after initial render, and the preview panel when authors or engineers need local audience and variant overrides.
The iOS reference implementation in this repository demonstrates the same SDK behavior in SwiftUI and UIKit shells:
Add ContentfulOptimization through Swift Package Manager as described in the
Optimization iOS SDK README. Then create an
OptimizationConfig with the Optimization client ID and the Contentful locale information your app
uses when fetching entries:
let config = OptimizationConfig(
clientId: "your-client-id",
environment: "master",
contentfulLocales: ContentfulLocales(default: "en-US"),
locale: "en-US",
debug: true
)
Only clientId is required. If application policy permits Optimization by default and no end-user
consent UI is rendered, set defaults: StorageDefaults(consent: true). Otherwise, leave defaults
unset and connect client.consent(true) and client.consent(false) to the app's consent UI.
Use contentfulLocales and locale when the same screen renders localized Contentful entries. For
the full locale model, see
Locale handling in the Optimization SDK Suite.
Wrap your root SwiftUI content in OptimizationRoot. It owns the OptimizationClient, initializes
the SDK, and provides the ready client to descendant views as an environment object.
import ContentfulOptimization
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
OptimizationRoot(
config: config,
trackViews: true,
trackTaps: false,
liveUpdates: false
) {
RootView()
}
}
}
}
Inside the provider tree, read the client from the environment:
struct AccountControls: View {
@EnvironmentObject private var client: OptimizationClient
var body: some View {
Button("Identify") {
Task {
try? await client.identify(userId: "user-123", traits: ["plan": "pro"])
}
}
}
}
OptimizationClient is @MainActor. Call it from SwiftUI view tasks, event handlers, or explicit
main-actor tasks. For lifecycle details, see
iOS SDK runtime and interaction mechanics.
If your application policy permits Optimization by default, seed accepted consent in
OptimizationConfig and omit the consent gate:
let config = OptimizationConfig(
clientId: "your-client-id",
defaults: StorageDefaults(consent: true)
)
That starts all gated SDK events immediately and permits durable profile-continuity storage for profile, selected optimizations, changes, and the anonymous ID.
When application policy depends on user choice, leave consent unset and connect the app's controls
to client.consent(true | false). identify and screen remain allowed before consent so a mobile
journey can establish profile context and anonymous screen analytics.
struct ConsentBanner: View {
@EnvironmentObject private var client: OptimizationClient
var body: some View {
VStack {
Text("We use analytics to personalize content.")
HStack {
Button("Accept") { client.consent(true) }
Button("Reject") { client.consent(false) }
}
}
}
}
Use client.state.consent to decide whether the consent UI still needs to render:
struct ConsentGate<Content: View>: View {
@EnvironmentObject private var client: OptimizationClient
@ViewBuilder var content: () -> Content
var body: some View {
if client.state.consent == nil {
ConsentBanner()
} else {
content()
}
}
}
Boolean consent updates both event emission and durable profile-continuity persistence by default.
If your policy allows events but not durable continuity, call
client.consent(events: true, persistence: false) and read client.state.persistenceConsent when
the UI needs to show that separate state.
OptimizedEntry is the SwiftUI component for rendering Contentful entries through the Optimization
resolver. It passes non-personalized entries through unchanged, resolves personalized entries
against the selected variants for the visitor, and can attach view and tap tracking.
Fetch entries from Contentful as single-locale JSON-shaped dictionaries and include linked
optimization references in the payload. Pass those dictionaries to OptimizedEntry.
The resolver expects the same single-locale CDA entry contract used by the other SDK runtimes. For details, see Entry personalization and variant resolution.
import ContentfulOptimization
import SwiftUI
struct CTASection: View {
let entry: [String: Any]
var body: some View {
OptimizedEntry(entry: entry, trackTaps: true) { resolvedEntry in
CTAHeader(entry: resolvedEntry)
}
}
}
The render closure receives the resolved entry dictionary. The application owns converting fields from that dictionary into the view model or SwiftUI view hierarchy it wants to render.
Inside a plain ScrollView, OptimizedEntry treats entries as visible because it cannot read the
scroll viewport. Wrap scrollable content in OptimizationScrollView when view tracking needs
viewport-aware timing.
OptimizationScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(posts, id: \.id) { post in
OptimizedEntry(entry: post) { resolved in
BlogPostCard(entry: resolved)
}
}
}
}
For full-screen heroes, modal content, or single-screen layouts, a regular container is enough.
OptimizationRoot defines defaults for every OptimizedEntry in its tree:
OptimizationRoot(
config: config,
trackViews: true,
trackTaps: false
) {
RootView()
}
View tracking defaults to on. Tap tracking defaults to off because taps are usually tied to application-specific navigation or business actions.
OptimizedEntry(entry: hero, trackViews: false) { resolved in
Hero(entry: resolved)
}
OptimizedEntry(entry: cta, trackTaps: true) { resolved in
CTAHeader(entry: resolved)
}
OptimizedEntry(entry: cta, onTap: { resolved in
navigate(to: resolved)
}) { resolved in
CTAHeader(entry: resolved)
}
Passing trackTaps: false disables tap tracking even when onTap is present. For timing thresholds
and event delivery behavior, see
iOS SDK runtime and interaction mechanics.
Attach .trackScreen(name:) to the root view for each screen:
struct HomeScreen: View {
var body: some View {
HomeContent()
.trackScreen(name: "Home")
}
}
For dynamic names or tracking after data loads, call the client directly:
struct DetailsScreen: View {
@EnvironmentObject private var client: OptimizationClient
let postId: String
var body: some View {
DetailsContent()
.task {
try? await client.screen(
name: "BlogPostDetail",
properties: ["postId": postId]
)
}
}
}
By default, OptimizedEntry locks to the first variant it resolves so content does not change while
a visitor is reading it. Enable live updates when a screen needs to react to profile or preview
changes without a reload:
OptimizationRoot(config: config, liveUpdates: true) {
RootView()
}
OptimizedEntry(entry: dashboard, liveUpdates: true) { resolved in
Dashboard(entry: resolved)
}
The preview panel forces live updates while it is open. For precedence rules, see iOS SDK runtime and interaction mechanics.
Gate the preview panel behind a debug or internal-build flag. PreviewPanelOverlay renders a
floating button and presents the panel when tapped.
#if DEBUG
let showPreviewPanel = true
#else
let showPreviewPanel = false
#endif
OptimizationRoot(config: config) {
if showPreviewPanel {
PreviewPanelOverlay(contentfulClient: contentfulClient) {
RootView()
}
} else {
RootView()
}
}
The contentfulClient parameter is optional. Passing a PreviewContentfulClient enables audience
and experience names in the panel; without it, the panel displays identifiers.
This example combines initialization, preview-panel gating, screen tracking, viewport-aware entry tracking, and tap tracking:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
OptimizationRoot(
config: config,
trackViews: true,
trackTaps: false
) {
PreviewPanelOverlay(contentfulClient: contentfulClient) {
NavigationStack {
HomeScreen()
}
}
}
}
}
}
struct HomeScreen: View {
let posts: [[String: Any]]
let cta: [String: Any]?
var body: some View {
OptimizationScrollView {
LazyVStack {
ForEach(Array(posts.enumerated()), id: \.offset) { index, post in
OptimizedEntry(entry: post) { resolved in
BlogPostCard(entry: resolved)
}
if index == 0, let cta {
OptimizedEntry(entry: cta, trackTaps: true) { resolved in
CTAHeader(entry: resolved)
}
}
}
}
}
.trackScreen(name: "Home")
}
}