Use this guide when you want to add personalization and analytics to a UIKit 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 SwiftUI guide instead if your app is SwiftUI-based: Integrating the Optimization iOS SDK in a SwiftUI App.
The UIKit integration is more explicit than the SwiftUI one: the SDK does not ship UIKit-native
views equivalent to OptimizedEntry or OptimizationScrollView. Instead, you work with
OptimizationClient directly and attach tracking yourself.
UIKit apps typically use:
OptimizationClient as a long-lived property on the SceneDelegate, passed down into view
controllers.client.personalizeEntry(baseline:personalizations:) called in cell configuration or view
controller setup.client.trackView(_:) and client.trackClick(_:) called from visibility callbacks and
UIControl actions.client.screen(name:) called from viewDidAppear(_:).PreviewPanelViewController (a UIHostingController subclass) mounted behind
PreviewPanelViewController.addFloatingButton(to:client:contentfulClient:) for developer
overrides.The preview panel's UI is itself SwiftUI, but PreviewPanelViewController wraps it in a
UIHostingController so it drops cleanly into a UIKit navigation stack.
See the UIKit demo at
Colorful-Team-Org/OptimizationiOSSDKDemo — UIKitDemo
(local checkout at
../../optimization-ios-demo/UIKitDemo). It is
functionally identical to the SwiftUI demo so you can compare side-by-side.
A typical UIKit integration is:
OptimizationConfig.OptimizationClient in SceneDelegate and call initialize(config:).include: 10.client.personalizeEntry(baseline:personalizations:) and render the
resolved entry.UIControl actions with TrackClickPayload; track views by reporting visible
duration to TrackViewPayload.client.screen(name:) from viewDidAppear(_:).PreviewPanelViewController.addFloatingButton(...).Own the OptimizationClient from SceneDelegate so its lifetime matches the scene and its instance
is easy to pass into view controllers.
import ContentfulOptimization
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
let client = OptimizationClient()
let contentfulClient = ContentfulHTTPPreviewClient(
spaceId: AppConfig.contentfulSpaceId,
accessToken: AppConfig.contentfulAccessToken,
environment: AppConfig.contentfulEnvironment
)
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
try? client.initialize(config: OptimizationConfig(
clientId: AppConfig.optimizationClientId,
environment: AppConfig.optimizationEnvironment,
defaults: StorageDefaults(consent: true), // demo pre-grant
debug: true
))
let home = HomeViewController(client: client)
let nav = UINavigationController(rootViewController: home)
window = UIWindow(windowScene: windowScene)
window?.rootViewController = nav
window?.makeKeyAndVisible()
}
}
OptimizationClient is @MainActor, so initialize(config:) must be called on the main thread.
scene(_:willConnectTo:options:) already runs on the main thread, so the call above is safe.
Pass client into each view controller's initializer. This gives every screen access to the
singleton instance for calling personalizeEntry, tracking events, and observing
selectedPersonalizations.
See Consent in the fundamentals for the consent model. In UIKit, typical patterns are:
StorageDefaults(consent: true) for demos.Example consent actions:
@objc private func acceptTapped() { client.consent(true) }
@objc private func rejectTapped() { client.consent(false) }
To observe the consent state reactively, subscribe to client.$state:
client.$state
.map(\.consent)
.removeDuplicates()
.receive(on: RunLoop.main)
.sink { [weak self] value in
self?.updateConsentUI(value)
}
.store(in: &cancellables)
Fetch Contentful entries into [String: Any] dictionaries (the demo app's ContentfulService uses
raw URLSession; any JSON-returning Contentful client works). Call
personalizeEntry(baseline:personalizations:) wherever you render the entry — typically in
tableView(_:cellForRowAt:):
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: BlogPostCardCell.reuseIdentifier,
for: indexPath
) as! BlogPostCardCell
let baseline = posts[indexPath.row]
let resolved = client.personalizeEntry(
baseline: baseline,
personalizations: client.selectedPersonalizations
)
cell.configure(with: resolved.entry)
return cell
}
personalizeEntry is synchronous and returns a PersonalizedResult:
| Field | Type | Description |
|---|---|---|
entry |
[String: Any] |
The resolved variant entry (or the baseline if nothing matched). |
personalization |
[String: Any]? |
The matched personalization metadata, or nil when baseline. |
Use personalization != nil to decide whether a user saw a personalized variant — useful when
composing tracking payloads.
When client.selectedPersonalizations changes (for example, after the user's audience qualification
shifts), re-resolve and redraw affected cells. Observe the property via Combine:
client.$selectedPersonalizations
.dropFirst()
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
self.tableView.reloadData()
}
.store(in: &cancellables)
UIKit does not have an automatic "lock to first variant" mechanism — you decide when to re-resolve based on whether you want to stay locked or update live. Two common patterns:
personalizeEntry inside cellForRowAt and reload the table when
selectedPersonalizations changes (as above). The user sees the current best variant.client.selectedPersonalizations at the time your screen loads,
store it in the view controller, and pass that snapshot into every personalizeEntry call. Do not
reload on change.A common compromise is to live-update while the preview panel is open (for developer feedback) and
lock in production. You can check client.isPreviewPanelOpen to decide.
Wire up a tap action on the control and call client.trackClick(_:) with a TrackClickPayload:
ctaView.onButtonTap = { [weak self] in
guard let self else { return }
let sys = cta["sys"] as? [String: Any] ?? [:]
let componentId = sys["id"] as? String ?? ""
Task {
try? await self.client.trackClick(TrackClickPayload(
componentId: componentId,
variantIndex: resolved.personalization != nil ? 1 : 0
))
}
}
TrackClickPayload fields:
| Field | Type | Description |
|---|---|---|
componentId |
String |
Typically entry.sys.id. |
experienceId |
String? |
The ID of the matching experience, if any. |
variantIndex |
Int |
0 for baseline; 1+ for a personalized variant. |
UIKit does not have a visibility modifier, so you detect visibility yourself (e.g. via
collectionView(_:willDisplay:forItemAt:) / didEndDisplaying or by observing cell visibility
changes) and call client.trackView(_:) with a TrackViewPayload:
try? await client.trackView(TrackViewPayload(
componentId: entryId,
viewId: UUID().uuidString,
experienceId: experienceId,
variantIndex: variantIndex,
viewDurationMs: durationMs,
sticky: nil
))
Strategy for computing viewDurationMs:
willDisplay, record the timestamp and start a periodic timer.TrackViewPayload with the running duration.didEndDisplaying, send a final payload and cancel the timer.If this level of visibility accounting is more than you need, the simpler path is to send one event
per display with a short configurable duration. The SwiftUI OptimizedEntry uses the
threshold-based algorithm described in the fundamentals; UIKit apps that want parity can port that
logic or read ViewTrackingController in the SDK source as a reference.
Call client.screen(name:) from viewDidAppear(_:):
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task { try? await client.screen(name: "Home") }
}
screen is async and throws, which is why it runs inside a Task. Use try? to silence errors
unless you need to handle them.
To include extra properties:
Task {
try? await client.screen(
name: "BlogPostDetail",
properties: ["postId": postId]
)
}
Attach the floating action button in the scene delegate (or from a root view controller's
viewDidLoad), gated on a debug flag:
#if DEBUG
PreviewPanelViewController.addFloatingButton(
to: homeVC,
client: client,
contentfulClient: contentfulClient
)
#endif
addFloatingButton(to:client:contentfulClient:) adds a pinned button in the bottom-trailing corner
of the host view controller and wires it up to present PreviewPanelViewController on tap. The
preview panel's UI is SwiftUI wrapped in a UIHostingController, so it lives happily inside a UIKit
navigation stack.
While the panel is open, client.isPreviewPanelOpen is true. PreviewPanelViewController updates
this for you in viewDidAppear / viewWillDisappear. Use it to decide whether to re-resolve
entries live:
client.$isPreviewPanelOpen
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.tableView.reloadData() }
.store(in: &cancellables)
The contentfulClient parameter is optional — without it the panel displays audiences and
experiences by ID. Passing ContentfulHTTPPreviewClient enables rich names, variant labels, and
traffic percentages. You can also implement PreviewContentfulClient directly if you already have a
Contentful client you want to reuse.
The UIKit demo's scene delegate and home view controller together show the full pattern — SDK init
in scene(_:willConnectTo:options:), a UITableView that calls personalizeEntry in
cellForRowAt, click tracking on the CTA button, screen tracking in viewDidAppear, and the
preview panel FAB attached behind debug: true:
// UIKitDemo/UIKitDemo/SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
let client = OptimizationClient()
let contentfulClient = ContentfulHTTPPreviewClient(
spaceId: AppConfig.contentfulSpaceId,
accessToken: AppConfig.contentfulAccessToken,
environment: AppConfig.contentfulEnvironment
)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
try? client.initialize(config: OptimizationConfig(
clientId: AppConfig.optimizationClientId,
environment: AppConfig.optimizationEnvironment,
defaults: StorageDefaults(consent: true),
debug: true
))
let homeVC = HomeViewController(client: client)
let nav = UINavigationController(rootViewController: homeVC)
window = UIWindow(windowScene: windowScene)
window?.rootViewController = nav
window?.makeKeyAndVisible()
PreviewPanelViewController.addFloatingButton(
to: homeVC,
client: client,
contentfulClient: contentfulClient
)
}
}
// UIKitDemo/UIKitDemo/Screens/HomeViewController.swift (excerpt)
final class HomeViewController: UIViewController {
private let client: OptimizationClient
private let tableView = UITableView(frame: .zero, style: .grouped)
private var cancellables = Set()
override func viewDidLoad() {
super.viewDidLoad()
// ... setup ...
client.$selectedPersonalizations
.dropFirst()
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.tableView.reloadData() }
.store(in: &cancellables)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task { try? await client.screen(name: "Home") }
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(/* ... */) as! BlogPostCardCell
let resolved = client.personalizeEntry(
baseline: posts[indexPath.row],
personalizations: client.selectedPersonalizations
)
cell.configure(with: resolved.entry)
return cell
}
}
Clone the demo repo, run ./scripts/setup.sh, and open UIKitDemo.xcworkspace to step through the
rest of the code alongside the SDK sources.