Render server-driven native UI campaigns delivered by CleverTap — using Jetpack Compose on Android and SwiftUI on iOS. No WebViews. The SDK receives a JSON campaign config from the CleverTap backend and renders it as fully native UI. Layouts, styles, themes, and dynamic variables are all controlled server-side without app updates.
| Platform | Minimum |
|---|---|
| Android | API 23+, Kotlin 1.9+, Jetpack Compose |
| iOS | iOS 15+, Swift 5.9+, SwiftUI |
Prerequisite — CleverTap Core SDK. The Native Display SDK is a renderer; it expects display units to be delivered by the CleverTap Core SDK. Install and initialize it first: Android Core SDK · iOS Core SDK · General docs
You can also run the Display SDK in standalone mode (no Core SDK) and feed JSON manually — see Approach 2 below.
Add the SDK to your module's build.gradle.kts:
dependencies {
implementation("com.clevertap.android:native-display-sdk:<version>")
// Required only if your campaigns include video elements
implementation("androidx.media3:media3-exoplayer:<version>")
}Add the package in Xcode via File → Add Package Dependencies:
https://github.com/CleverTap/clevertap-native-display-ios
Or add it to your Package.swift:
.package(url: "https://github.com/CleverTap/clevertap-native-display-ios", from: "<version>")The Native Display SDK is a renderer — display unit JSON is delivered by the CleverTap Core SDK. Install and initialize it before going further: Android Core SDK · iOS Core SDK.
Two integration paths are supported. Approach 1 (slot-based) is recommended for most apps. Approach 2 (custom rendering) is for hosts that need to inspect units, place them in custom layouts, or run standalone without the Core SDK.
The slot flow is the shortest path to a working integration. The SDK manages discovery, listening, attribution, and lifecycle — your only job is to declare where a unit can appear and which slot ID maps to it.
Step 1 — Initialize the bridge in your app entry point.
The snippets below show the recommended path — Jetpack Compose on Android and SwiftUI on iOS. Pick a different stack from the collapsible sections.
Android — Kotlin (Application.onCreate()):
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
NativeDisplayBridge.initialize(this)
}
}iOS — SwiftUI (App.init()):
@main
struct MyApp: App {
init() {
NativeDisplayBridge.shared.initialize()
}
var body: some Scene { WindowGroup { ContentView() } }
}iOS — UIKit (Swift, AppDelegate)
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
NativeDisplayBridge.shared.initialize()
return true
}iOS — Objective-C (AppDelegate.m)
#import <CleverTapNativeDisplay/CleverTapNativeDisplay-Swift.h>
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[[NativeDisplayBridge shared] initialize];
return YES;
}Step 2 — Link with CleverTap Core so server-pushed units flow into the bridge.
- Android:
NativeDisplayBridge.initialize(context)auto-detects the Core SDK on the classpath via reflection — Step 1 already linked you, no extra call needed. - iOS: explicitly bind the Core SDK instance once it's available.
iOS — Swift:
if let cleverTap = CleverTap.sharedInstance() {
NativeDisplayBridge.shared.bind(cleverTap)
}iOS — Objective-C
CleverTap *cleverTap = [CleverTap sharedInstance];
if (cleverTap != nil) {
[[NativeDisplayBridge shared] bind:cleverTap forwardTo:nil];
}Step 3 — Drop a slot view in your UI with the slot ID configured on the dashboard. The SDK looks it up, picks the matching unit, and renders it. While no unit is present, the slot shows your placeholder (or stays empty by default).
Android — Jetpack Compose:
NativeDisplaySlot(
slotId = "hero_banner",
modifier = Modifier.fillMaxWidth(),
loading = { /* optional placeholder, e.g. shimmer or Box */ },
)iOS — SwiftUI:
NativeDisplaySlot(slotId: "hero_banner") {
// optional placeholder view, e.g. ProgressView()
}Android — XML / Views (no Compose)
Declare the slot in your layout:
<com.clevertap.android.nativedisplay.placement.NativeDisplaySlotView
android:id="@+id/hero_slot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:slotId="hero_banner" />Then optionally wire listeners from your Activity/Fragment:
findViewById<NativeDisplaySlotView>(R.id.hero_slot).apply {
setActionListener(myActionListener)
setComponentListener(myComponentListener)
}iOS — UIKit (Swift)
NativeDisplaySlotUIView is a regular UIView — no UIHostingController needed:
final class HomeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let slot = NativeDisplaySlotUIView(slotId: "hero_banner")
slot.actionListener = myActionListener
slot.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(slot)
NSLayoutConstraint.activate([
slot.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
slot.leadingAnchor.constraint(equalTo: view.leadingAnchor),
slot.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
}
}iOS — UIKit (Objective-C)
#import <CleverTapNativeDisplay/CleverTapNativeDisplay-Swift.h>
- (void)viewDidLoad {
[super viewDidLoad];
NativeDisplaySlotUIView *slot = [[NativeDisplaySlotUIView alloc] initWithSlotId:@"hero_banner"];
slot.actionListener = self.myActionListener;
slot.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:slot];
[NSLayoutConstraint activateConstraints:@[
[slot.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[slot.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[slot.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
]];
}For list-driven UIs, the SDK ships cell wrappers: NativeDisplaySlotTableViewCell.configure(slotId:) for UITableView and NativeDisplaySlotCollectionViewCell.configure(slotId:) for UICollectionView. Both are usable from Swift and Objective-C, e.g. [cell configureWithSlotId:@"hero_banner" actionListener:nil componentListener:nil];.
When do I need
UIHostingController? Only if you want to embed the SwiftUINativeDisplayViewdirectly in a UIKit screen. The UIKit wrappers above (NativeDisplaySlotUIView,NativeDisplayUIView, and the cell variants) are realUIView/UITableViewCell/UICollectionViewCellsubclasses — they already wrap aUIHostingControllerinternally, so you don't have to.
Slot views auto-register with the bridge on attach and auto-unregister on detach — there's no listener to manage.
Choose this when you need to inspect units before rendering, place them in custom layouts (carousels, RecyclerViews, dynamic Compose graphs), filter by metadata, or run standalone without the Core SDK.
iOS — Swift only.
NativeDisplayUnitis a Swiftstruct, so theNativeDisplayBridgeListenerAPI and the unit-basedNativeDisplayView/NativeDisplayUIViewinitializers cannot be called from Objective-C. Pure Obj-C apps that need this level of control should stay on Approach 1 (slot-based) — the slot view does the listening for you.
After Steps 1 & 2 above, attach a NativeDisplayBridgeListener and render each unit with the renderer that fits your UI layer.
Step A — attach the listener
Android:
val bridgeListener = object : NativeDisplayBridgeListener {
override fun onNativeDisplaysLoaded(units: List<NativeDisplayUnit>) {
// Store in your state, then render with one of the options below
}
}
NativeDisplayBridge.getInstance().addListener(bridgeListener)iOS:
class MyBridgeListener: NativeDisplayBridgeListener {
func onNativeDisplaysLoaded(_ units: [NativeDisplayUnit]) {
// Store in your state, then render with one of the options below
}
}
NativeDisplayBridge.shared.addListener(myBridgeListener)Hold a strong reference to your listener — if it's a local variable, it will be released before any callback fires.
Step B — render each unit
Android — Jetpack Compose:
@Composable
fun CampaignBanner(unit: NativeDisplayUnit) {
NativeDisplayView(
unit = unit,
modifier = Modifier.fillMaxWidth(),
actionListener = myActionListener,
)
}iOS — SwiftUI:
struct CampaignBanner: View {
let unit: NativeDisplayUnit
var body: some View {
NativeDisplayView(unit: unit, actionListener: myActionListener)
}
}Android — XML / Views (no Compose)
Add NativeDisplayViewGroup to your layout:
<com.clevertap.android.nativedisplay.view.NativeDisplayViewGroup
android:id="@+id/campaign_banner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />Then, inside the listener you attached in Step A, push each unit into the view:
override fun onNativeDisplaysLoaded(units: List<NativeDisplayUnit>) {
val unit = units.firstOrNull() ?: return
findViewById<NativeDisplayViewGroup>(R.id.campaign_banner)
.setUnit(unit, actionListener = myActionListener)
}iOS — UIKit (Swift)
NativeDisplayUIView is a regular UIView, so you can drop it into any view hierarchy. Instantiate it in your listener callback:
func onNativeDisplaysLoaded(_ units: [NativeDisplayUnit]) {
guard let unit = units.first else { return }
let banner = NativeDisplayUIView(unit: unit, actionListener: myActionListener)
banner.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(banner)
NSLayoutConstraint.activate([
banner.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
banner.leadingAnchor.constraint(equalTo: view.leadingAnchor),
banner.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
}Standalone mode (no Core SDK): feed units yourself. Same onNativeDisplaysLoaded callback fires.
// Android
val bridge = NativeDisplayBridge.create()
bridge.addListener(bridgeListener)
bridge.processDisplayUnits(jsonStrings)// iOS
NativeDisplayBridge.shared.addListener(myBridgeListener)
NativeDisplayBridge.shared.processDisplayUnits(jsonStrings)By default the Core SDK pushes units when they're ready. To pull on demand (e.g. screen open, pull-to-refresh):
// Android
NativeDisplayBridge.getInstance().fetchNativeDisplays(cleverTapApi)// iOS (Swift)
NativeDisplayBridge.shared.fetchNativeDisplays(CleverTap.sharedInstance())iOS — Objective-C
[[NativeDisplayBridge shared] fetchNativeDisplays:[CleverTap sharedInstance]];Both calls return a Bool indicating that the request was dispatched — not that the fetch completed. Results arrive asynchronously via the same onNativeDisplaysLoaded callback. This works orthogonally to either approach above: slots in Approach 1 refresh automatically, custom listeners in Approach 2 fire again.
The renderer surfaces two listeners. Attach one or both to a slot or to NativeDisplayView to react to user interactions and run your own logic.
Semantic callbacks that describe what the user did:
| Callback | Purpose |
|---|---|
onOpenUrl(url, openInBrowser) -> Bool |
Return true if your app handled it (e.g. deep-link router); false to let the SDK open it. |
onCustomAction(key, value, metadata) |
Handle custom actions defined in the campaign JSON. |
onNavigate(destination, params) |
In-app navigation actions. |
onTrackEvent(eventName, properties) |
Forward to your analytics layer if needed. |
onDisplayUnitViewed(unitId) / onDisplayUnitClicked(unitId) |
Attribution callbacks — Core SDK already tracks these automatically; implement only if you need a copy. |
Raw gestures on specific nodes by ID. Use this when you need to intercept individual taps, long presses, or double-taps before the SDK handles them.
| Member | Purpose |
|---|---|
onComponentInteraction(nodeId, interactionType, hasServerAction) -> Bool |
Return true to consume the interaction; false to let the SDK proceed with default behavior. |
getInterestedNodeIds(): Set<String>? |
Narrow callbacks to specific node IDs; null (default) means all nodes. |
InteractionType |
CLICK / LONG_PRESS / DOUBLE_TAP (Android) · .click / .longPress / .doubleTap (iOS). |
Both listeners can be attached to a slot view or directly to NativeDisplayView:
// Android — Jetpack Compose
NativeDisplaySlot(
slotId = "hero_banner",
actionListener = myActionListener,
componentListener = myComponentListener,
)// iOS — SwiftUI
NativeDisplaySlot(
slotId: "hero_banner",
actionListener: myActionListener,
componentListener: myComponentListener,
)Android — XML / Views
findViewById<NativeDisplaySlotView>(R.id.hero_slot).apply {
setActionListener(myActionListener)
setComponentListener(myComponentListener)
}iOS — UIKit (Swift)
let slot = NativeDisplaySlotUIView(slotId: "hero_banner")
slot.actionListener = myActionListener
slot.componentListener = myComponentListeneriOS — UIKit (Objective-C)
NativeDisplaySlotUIView *slot = [[NativeDisplaySlotUIView alloc] initWithSlotId:@"hero_banner"];
slot.actionListener = self.myActionListener;
slot.componentListener = self.myComponentListener;Implementing a listener in Objective-C
Both NativeDisplayActionListener and NativeDisplayComponentListener are @objc protocols — implement them from an NSObject subclass:
@interface MyActionListener : NSObject <NativeDisplayActionListener>
@end
@implementation MyActionListener
- (BOOL)onOpenUrlWithUrl:(NSString *)url openInBrowser:(BOOL)openInBrowser {
// return YES if you handled it; NO to let the SDK open it
return NO;
}
- (void)onCustomActionWithKey:(NSString *)key
value:(id)value
metadata:(NSDictionary<NSString *,NSString *> *)metadata {
// handle custom action
}
- (void)onNavigateWithDestination:(NSString *)destination
params:(NSDictionary<NSString *,NSString *> *)params { }
- (void)onTrackEventWithEventName:(NSString *)eventName
properties:(NSDictionary<NSString *,id> *)properties { }
@endCampaigns are composed of containers (which hold children) and elements (leaf nodes):
Containers
| Type | Description |
|---|---|
VERTICAL |
Stack children vertically |
HORIZONTAL |
Stack children horizontally |
BOX |
Overlay / absolute positioning |
GALLERY |
Scrollable carousel (snapping or free-flow) |
Elements
| Type | Description |
|---|---|
TEXT |
Styled text, supports {{variable}} templates |
IMAGE |
Remote image or GIF |
BUTTON |
Tappable button with actions |
VIDEO |
Inline video with optional controls and autoplay |
HTML |
WebView-rendered rich content |
SPACER |
Fixed or flexible spacing |
DIVIDER |
Visual separator |
This SDK is the renderer. The most complete way to drive it is the Native Display feature of the CleverTap Core SDK — authored on the dashboard, delivered by the Core SDK at runtime. Going through the dashboard gives you targeting, scheduling, A/B testing, and end-to-end attribution out of the box.
To create one:
- Sign in to the CleverTap dashboard and open Campaigns → Create → Native Display.
- Use the Advanced Builder to compose the layout — pick containers (
VERTICAL,HORIZONTAL,BOX,GALLERY), drop in elements (TEXT,IMAGE,BUTTON,VIDEO,HTML), and bind variables. - Target the slot ID (or audience segment) and publish — the campaign reaches users through the Core SDK's display unit pipeline that this SDK listens to.
Full dashboard documentation: Native Display — CleverTap docs.
If your use case calls for it, you can also feed JSON to the renderer directly — see Approach 2 — Custom rendering for standalone mode. You'll lose the dashboard-side targeting and attribution loop, so this is rarely the right call.
The examples below show the JSON shape this SDK consumes. In a typical setup you won't hand-write these — the CleverTap dashboard authors them and the Core SDK delivers them — but the format is open and you can feed JSON to the renderer directly (see Approach 2 — Custom rendering) if you need to.
{
"theme": {
"textColor": "#111111",
"fontSize": 16
},
"variables": {
"userName": "Alex"
},
"root": {
"type": "VERTICAL",
"layout": { "width": "match_parent", "padding": 16 },
"children": [
{
"type": "TEXT",
"bindings": { "text": "Welcome back, {{userName}}!" },
"style": { "fontSize": 22, "fontWeight": "bold" }
},
{
"type": "BUTTON",
"bindings": { "text": "Shop Now" },
"actions": {
"onClick": { "type": "open_url", "url": "https://example.com" }
}
}
]
}
}{
"root": {
"type": "BOX",
"layout": { "width": "match_parent", "height": { "value": 200, "unit": "dp" } },
"children": [
{
"type": "IMAGE",
"bindings": { "url": "https://example.com/banner.jpg" },
"layout": { "width": "match_parent", "height": "match_parent" }
},
{
"type": "TEXT",
"bindings": { "text": "Limited Time Offer" },
"layout": { "width": "match_parent" },
"style": {
"textColor": "#FFFFFF",
"fontSize": 24,
"fontWeight": "bold",
"backgroundColor": "#00000066"
}
}
]
}
}{
"root": {
"type": "HORIZONTAL",
"layout": {
"width": "match_parent",
"padding": 12,
"arrangement": { "strategy": "spaced", "spacing": 8 }
},
"children": [
{
"type": "IMAGE",
"bindings": { "url": "https://example.com/product.jpg" },
"layout": { "width": { "value": 80, "unit": "dp" }, "height": { "value": 80, "unit": "dp" } },
"style": { "borderRadius": 8 }
},
{
"type": "VERTICAL",
"layout": { "width": "match_parent" },
"children": [
{
"type": "TEXT",
"bindings": { "text": "Premium Sneakers" },
"style": { "fontWeight": "bold", "fontSize": 16 }
},
{
"type": "TEXT",
"bindings": { "text": "$79.99" },
"style": { "textColor": "#E53935", "fontSize": 14 }
},
{
"type": "BUTTON",
"bindings": { "text": "Add to Cart" },
"actions": {
"onClick": { "type": "custom", "key": "add_to_cart", "value": "sku_123" }
}
}
]
}
]
}
}Pass a FontFamily to NativeDisplayView:
NativeDisplayView(
unit = unit,
fontFamily = FontFamily(Font(R.font.my_font))
)Provide a font resolver via the SwiftUI environment:
NativeDisplayView(unit: unit)
.environment(\.nativeDisplayFontResolver) { name, size, weight in
Font.custom(name, size: size).weight(weight)
}Listener not called / campaigns never arrive
- Confirm Step 2 of Approach 1 is complete — without a Core SDK link, no units will be pushed. For standalone testing, feed JSON yourself via
processDisplayUnits(...)(Approach 2). - Hold a strong reference to your listener. If it's a local variable, it will be released before any callback fires.
Layout looks wrong or view has zero height
- Always provide
layout.widthon the root node — use"match_parent"to fill the available space. HTMLelements require an explicitlayout.height; they cannot auto-size.
Videos not playing on Android
- Add
androidx.media3:media3-exoplayerto your dependencies. Without it, video elements are silently skipped.
Text wraps differently on Android vs iOS
- Roboto (Android) and San Francisco (iOS) have different character widths. Always specify
lineHeightin your campaign JSON for consistent results across platforms, and consider supplying the same custom font on both platforms via the custom font APIs.
- Documentation: docs.clevertap.com
- Issues: GitHub Issues
- Email: support@clevertap.com
