Skip to content

CleverTap/clevertap-native-ui-kit

Repository files navigation

CleverTap Native Display SDK

API 23+ Kotlin 1.9+ iOS 15.0+ Swift 5.9+

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.


Requirements

Platform Minimum
Android API 23+, Kotlin 1.9+, Jetpack Compose
iOS iOS 15+, Swift 5.9+, SwiftUI

Installation

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.

Android

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>")
}

iOS

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>")

Integration

Prerequisite: CleverTap Core SDK

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.


Approach 1 — Slot-based integration (recommended)

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 SwiftUI NativeDisplayView directly in a UIKit screen. The UIKit wrappers above (NativeDisplaySlotUIView, NativeDisplayUIView, and the cell variants) are real UIView/UITableViewCell/UICollectionViewCell subclasses — they already wrap a UIHostingController internally, 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.


Approach 2 — Custom rendering

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. NativeDisplayUnit is a Swift struct, so the NativeDisplayBridgeListener API and the unit-based NativeDisplayView / NativeDisplayUIView initializers 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)

Augmenting either approach — Fetch on demand

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.


Event hooks

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.

NativeDisplayActionListener — high-level outcomes

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.

NativeDisplayComponentListener — low-level node interactions

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).

Attaching listeners

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 = myComponentListener
iOS — 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 { }

@end

Supported Elements

Campaigns 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

Creating a Native Display campaign

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:

  1. Sign in to the CleverTap dashboard and open Campaigns → Create → Native Display.
  2. 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.
  3. 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.


Campaign JSON

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.

Minimal example — text + button

{
  "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" }
        }
      }
    ]
  }
}

Image banner with overlay text

{
  "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"
        }
      }
    ]
  }
}

Horizontal card row

{
  "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" }
            }
          }
        ]
      }
    ]
  }
}

Custom Fonts

Android

Pass a FontFamily to NativeDisplayView:

NativeDisplayView(
    unit = unit,
    fontFamily = FontFamily(Font(R.font.my_font))
)

iOS

Provide a font resolver via the SwiftUI environment:

NativeDisplayView(unit: unit)
    .environment(\.nativeDisplayFontResolver) { name, size, weight in
        Font.custom(name, size: size).weight(weight)
    }

Common Mistakes

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.width on the root node — use "match_parent" to fill the available space.
  • HTML elements require an explicit layout.height; they cannot auto-size.

Videos not playing on Android

  • Add androidx.media3:media3-exoplayer to 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 lineHeight in your campaign JSON for consistent results across platforms, and consider supplying the same custom font on both platforms via the custom font APIs.

Support

About

Server-driven native UI rendering system using Jetpack Compose and SwiftUI

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors