Use this guide when you want to add personalization and analytics to a SwiftUI application using the Contentful Optimization iOS SDK.
This guide assumes familiarity with the shared concepts covered in iOS SDK Fundamentals — installation, configuration, consent, reactive state, the tracking model, live updates, and the preview panel. Read that first if you have not already.
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 as an @EnvironmentObject, and
seeds global tracking defaults.OptimizedEntry renders a personalized Contentful entry and attaches view and tap tracking.OptimizationScrollView provides an accurate viewport context for view tracking inside scroll
views..trackScreen(name:) emits a screen event when a view appears.PreviewPanelOverlay renders a developer-only FAB that opens the preview panel sheet.See the SwiftUI demo at
Colorful-Team-Org/OptimizationiOSSDKDemo — SwiftUIDemo
(local checkout at
../../optimization-ios-demo/SwiftUIDemo). It exercises
every pattern in this guide end-to-end against real Contentful content and is worth reading
alongside this document.
A typical SwiftUI integration is:
OptimizationConfig.OptimizationRoot.include: 10.OptimizedEntry, optionally inside OptimizationScrollView.OptimizationRoot(trackViews:trackTaps:) and
OptimizedEntry(trackViews:trackTaps:)..trackScreen(name:).PreviewPanelOverlay on a debug flag.OptimizationRoot owns the OptimizationClient instance, initializes it in a .task {} block, and
shows a ProgressView until isInitialized flips to true. All SwiftUI views in the tree can then
read the client via @EnvironmentObject.
import ContentfulOptimization
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
OptimizationRoot(
config: OptimizationConfig(
clientId: "your-client-id",
environment: "master",
defaults: StorageDefaults(consent: true), // demo: pre-grant
debug: true
),
trackViews: true,
trackTaps: false,
liveUpdates: true
) {
RootView()
}
}
}
}
Available arguments:
| Argument | Type | Default | Description |
|---|---|---|---|
config |
OptimizationConfig |
— | Client ID, environment, API base URLs, debug flag, and defaults. |
trackViews |
Bool |
true |
Default for OptimizedEntry view tracking. |
trackTaps |
Bool |
false |
Default for OptimizedEntry tap tracking. |
liveUpdates |
Bool |
false |
Default for OptimizedEntry live update behavior. |
content |
@ViewBuilder |
— | App content that gets the injected client. |
Elsewhere, read the client with:
struct SomeView: View {
@EnvironmentObject private var client: OptimizationClient
// client.state, client.selectedPersonalizations, client.identify(...), ...
}
See Consent in the fundamentals guide for the consent model. In SwiftUI, a minimal banner looks like:
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) }
}
}
}
}
To gate the banner on whether a choice has been made, observe client.state.consent:
struct ConsentGate: View {
@EnvironmentObject private var client: OptimizationClient
@ViewBuilder var content: () -> Content
var body: some View {
if client.state.consent == nil {
ConsentBanner()
} else {
content()
}
}
}
For demos, pre-grant consent with StorageDefaults(consent: true) on the config you pass to
OptimizationRoot and skip the banner entirely.
OptimizedEntry is the SwiftUI view you render each Contentful entry through. It:
nt_experiences)client.selectedPersonalizationsimport 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 [String: Any] — the resolved entry dictionary. Pull fields out with
entry["fields"] as? [String: Any]. The demo app's CTAHeader and BlogPostCardContent views are
good references for destructuring.
OptimizedEntry(
entry: [String: Any],
viewTimeMs: Int = 2000,
threshold: Double = 0.8,
viewDurationUpdateIntervalMs: Int = 5000,
liveUpdates: Bool? = nil,
trackViews: Bool? = nil,
trackTaps: Bool? = nil,
accessibilityIdentifier: String? = nil,
onTap: (([String: Any]) -> Void)? = nil,
content: @escaping ([String: Any]) -> Content
)
All tracking and live-update flags are Optional<Bool> — nil means "inherit from
OptimizationRoot".
Inside a plain ScrollView, OptimizedEntry falls back to "always visible" because it cannot read
scroll position. Wrap the scroll region with OptimizationScrollView so view tracking reflects the
actual viewport:
OptimizationScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(posts, id: \.id) { post in
OptimizedEntry(entry: post) { resolved in
BlogPostCardContent(post: resolved)
}
}
}
}
.refreshable { await refresh() }
For full-screen content (heroes, modal cards, single-screen layouts), a plain container is fine — the entry is treated as always on screen.
The 80% / 2 seconds / 5 second update defaults are good for feed-style content. Override per entry when a specific component needs different behavior:
OptimizedEntry(
entry: largeBanner,
viewTimeMs: 3000,
threshold: 0.9
) { resolved in
LargeBannerView(entry: resolved)
}
OptimizationRoot(
config: config,
trackViews: true, // track visibility for every OptimizedEntry
trackTaps: true // track taps for every OptimizedEntry (opt-in)
) {
RootView()
}
The SDK defaults are trackViews: true, trackTaps: false. Views are safe to turn on everywhere;
taps are opt-in because they are more application-specific.
// Opt a specific entry out of view tracking
OptimizedEntry(entry: hidden, trackViews: false) { resolved in
HiddenView(entry: resolved)
}
// Enable taps for a single CTA
OptimizedEntry(entry: cta, trackTaps: true) { resolved in
CTAHeader(entry: resolved)
}
// Tap callback implicitly enables tap tracking
OptimizedEntry(entry: cta, onTap: { resolved in
navigate(to: resolved)
}) { resolved in
CTAHeader(entry: resolved)
}
Passing trackTaps: false always wins — even if onTap is provided.
See Live Updates in the fundamentals for the resolution rules. In SwiftUI:
// Global default
OptimizationRoot(config: config, liveUpdates: true) {
RootView()
}
// Per-entry overrides
OptimizedEntry(entry: hero, liveUpdates: false) { resolved in
Hero(entry: resolved) // always locked
}
OptimizedEntry(entry: dashboard, liveUpdates: true) { resolved in
Dashboard(entry: resolved) // always live
}
OptimizedEntry(entry: card) { resolved in
Card(entry: resolved) // inherits global
}
While the preview panel is open, every OptimizedEntry in the tree switches to live mode regardless
of these flags.
Attach .trackScreen(name:) to any view — typically the root view of a screen:
struct HomeScreen: View {
var body: some View {
Group {
// screen content
}
.trackScreen(name: "Home")
}
}
.trackScreen(name:) emits a client.screen(name:) event once, when the view first appears. For
dynamic screen names or delayed tracking (e.g. after data has loaded), call the client directly:
struct DetailsScreen: View {
@EnvironmentObject private var client: OptimizationClient
let postTitle: String
var body: some View {
Content()
.task {
try? await client.screen(
name: "BlogPostDetail",
properties: ["postTitle": postTitle]
)
}
}
}
Wrap your content in PreviewPanelOverlay, gated on a debug flag, to expose the developer FAB:
#if DEBUG
let shouldShowPreview = true
#else
let shouldShowPreview = false
#endif
let contentfulClient = ContentfulHTTPPreviewClient(
spaceId: AppConfig.contentfulSpaceId,
accessToken: AppConfig.contentfulAccessToken,
environment: AppConfig.contentfulEnvironment
)
OptimizationRoot(config: config, liveUpdates: true) {
Group {
if shouldShowPreview {
PreviewPanelOverlay(contentfulClient: contentfulClient) {
RootView()
}
} else {
RootView()
}
}
}
Tapping the FAB presents the panel as a sheet. While it is open, client.isPreviewPanelOpen is
true and all OptimizedEntry components switch to live mode so overrides apply immediately.
The contentfulClient parameter is optional — without it the panel shows audiences and experiences
by ID. Passing it enables rich names, variant labels, and traffic percentages.
The SwiftUI demo's app entry point ties all of this together — OptimizationRoot with pre-granted
consent and live updates enabled, PreviewPanelOverlay wrapping a NavigationStack, and a home
screen that uses OptimizationScrollView + OptimizedEntry to render a personalized CTA card
interleaved after the first blog post:
// SwiftUIDemo/SwiftUIDemo/SwiftUIDemoApp.swift
@main
struct SwiftUIDemoApp: App {
private let contentfulClient = ContentfulHTTPPreviewClient(
spaceId: AppConfig.contentfulSpaceId,
accessToken: AppConfig.contentfulAccessToken,
environment: AppConfig.contentfulEnvironment
)
var body: some Scene {
WindowGroup {
OptimizationRoot(
config: OptimizationConfig(
clientId: AppConfig.optimizationClientId,
environment: AppConfig.optimizationEnvironment,
defaults: StorageDefaults(consent: true),
debug: true
),
trackViews: true,
trackTaps: false,
liveUpdates: true
) {
PreviewPanelOverlay(contentfulClient: contentfulClient) {
NavigationStack { HomeScreen() }
}
}
}
}
}
// SwiftUIDemo/SwiftUIDemo/Screens/HomeScreen.swift (excerpt)
struct HomeScreen: View {
@EnvironmentObject private var client: OptimizationClient
@State private var cta: [String: Any]?
@State private var posts: [[String: Any]] = []
var body: some View {
OptimizationScrollView {
LazyVStack {
ForEach(Array(posts.enumerated()), id: \.offset) { index, post in
OptimizedEntry(entry: post) { _ in
NavigationLink(value: /* ... */) { BlogPostCardContent(post: post) }
}
if index == 0, let cta {
OptimizedEntry(entry: cta, trackTaps: true) { resolved in
CTAHeader(entry: resolved)
}
}
}
}
}
.trackScreen(name: "Home")
.task { await fetchData() }
}
}
Clone the demo, run the scripts/setup.sh helper, and open the .xcworkspace to step through the
rest of the code alongside the SDK sources.