iOS Low-Code Integration

Use the LowCodeMobile iOS SDK to display personalized content in your iOS app with minimal setup. The SDK renders out-of-the-box Banner and Recommendations components and supports custom components for fully custom UI.

  1. Install the SDK
  2. Initialize the SDK
  3. Display a Content Zone
  4. Out-of-the-Box Components
  5. Custom Components
  6. Engagement Tracking
  7. Preview
  8. Design-Time Rendering
  9. Error Handling

Install the SDK 

Swift Package Manager 

In Xcode, add the LowCodeMobile package to your project. The Personalization, Data 360, and Salesforce Marketing Cloud SDK packages are pulled in transitively. Xcode limits updates to within the same major version by default when you specify the package.

1https://github.com/salesforce-marketingcloud/LowCode-IOS

Selecting your app target when adding the package should automatically add the LowCodeMobile framework to it, but it’s worth confirming under General > Frameworks, Libraries, and Embedded Content.

CocoaPods 

Add the pods to your app’s Podfile. The Personalization, Data 360, and Salesforce Marketing Cloud SDK packages are pulled in transitively. Limit updates to within the same major version to avoid unexpected breaking changes:

1pod 'Salesforce-LowCodeMobile', '~> 1.0'

Then run:

1pod install

Open the .xcworkspace file, then clean and build.

Required SDK Levels 

Your app’s SDK levels must meet or exceed the following minimums.

SettingRequired
iOS deployment target15.0+
Xcode26.2+
Swift5.9+

Initialize the SDK 

The host app owns SDK initialization. Initialize both the Data 360 (CDP) and Personalization modules early in your application lifecycle: in SwiftUI, call this from your App type’s init(); in UIKit, call it from AppDelegate.application(_:didFinishLaunchingWithOptions:). The SDK queues any content zone requests made before initialization completes, so you do not need to gate your UI on init status.

1import SFMCSDK
2import Cdp
3import Personalization
4
5// Send every event to Data 360 in Real Time
6let flushRate = EventFlushRateQuantity(quantity: 1)
7
8let cdpConfig = CdpConfigBuilder(
9    appId: "<Your Data 360 App ID>",
10    endpoint: "<Your Tenant Specific Endpoint>")
11    .eventFlushRate(flushRate)
12    .build()
13
14let personalizationConfig = PersonalizationConfigBuilder()
15    .dataspace("default") // Replace with your dataspace if not using default
16    .cdnUrl("<Your CDN URL>")
17    .build()
18
19let sdkConfig = ConfigBuilder()
20    .setCdp(config: cdpConfig)
21    .setPersonalization(config: personalizationConfig)
22    .build()
23
24SFMCSdk.initializeSdk(sdkConfig) { statuses in
25    statuses.forEach { status in
26        print("Module \(status.moduleName.rawValue) initialized: \(status.initStatus.rawValue)")
27    }
28}

In your production application, you must explicitly manage user consent using the solution provided in the Engagement Mobile SDK. See Data 360 Consent Management.

Note

Considerations 

  • Replace <Your CDP App ID>, <Your CDP Endpoint>, and <Your CDN URL> with the values provided by your Salesforce Marketing Cloud administrator.
  • To avoid committing credentials, read these values from Info.plist or a build configuration file.
  • To enable debug logging during development, add SFMCSdk.setLogger(logLevel: .debug) before SFMCSdk.initializeSdk(...), so logging is active for the whole init sequence. Remove this before building for production.

Display a Content Zone 

Once the SDK is initialized, use ContentZone in your SwiftUI views to display personalized content.

Pass the personalization point name and a list of components the zone is allowed to render. The SDK fetches a decision from the backend, matches the component name returned, and renders the matching component.

After adding a ContentZone to your app code, create the matching content zone record in Salesforce Personalization. See Set Up Mobile Content Zones for instructions on defining personalization points, assigning components, and configuring engagement definitions.

SwiftUI 

1import LowCodeMobile
2
3struct HomeView: View {
4    var body: some View {
5        ContentZone(
6            name: "HomeScreen",
7            allowedComponents: [
8                SalesforceBanner(),
9                SalesforceRecommendations()
10            ]
11        )
12    }
13}

ContentZone is a standard SwiftUI view - place it anywhere in your layout alongside other content. The Pull-to-refresh example below shows it embedded within a scrollable screen.

ContentZone Parameters 

ParameterRequiredDescription
nameYesThe personalization point name, matching your backend configuration (case-sensitive).
allowedComponentsYesThe Components the zone may render. If the backend returns a name not in this list, the fallback is shown.
decisionsRequestContextNoOptional context to bias personalization decisions.
timeoutSecondsNoFetch timeout in seconds. Default: 10.
controllerNoContentZoneController for programmatic refresh.
loadingNoView shown while content is loading.
fallbackNoView shown when content cannot be loaded or no component matches.

Pull-to-refresh 

Use a ContentZoneController to refresh the zone programmatically:

1struct HomeScreen: View {
2    @StateObject var zoneController = ContentZoneController()
3
4    var body: some View {
5        ScrollView {
6            Text("Welcome back!") // Other content in your screen
7
8            ContentZone(
9                name: "HomeScreen",
10                controller: zoneController,
11                allowedComponents: [SalesforceBanner(), SalesforceRecommendations()]
12            )
13
14            // Additional content below the zone
15        }
16        .refreshable {
17            await zoneController.refresh()
18        }
19    }
20}

Out-of-the-Box Components 

The SDK ships with two pre-built components - SalesforceBanner and SalesforceRecommendations - that handle layout, styling, and configuration-driven engagement tracking out of the box. Use these when you want to display personalized content without writing a custom UI. Both support an onTap callback for custom tap handling; by default, tapping opens the ctaUrl if one is provided by the backend.

These components are available out-of-the-box in the UI and are registered under the names "Salesforce_Banner" and "Salesforce_Recommendations".

SalesforceBanner 

1ContentZone(
2    name: "HomeScreen",
3    allowedComponents: [
4        SalesforceBanner(
5            onTap: { model in
6                // Custom tap handling - default behavior opens ctaUrl if provided
7                print("Banner tapped: \(model.header)")
8            }
9        )
10    ],
11    loading: { ProgressView() },
12    fallback: { _ in Text("Content unavailable") }
13)

SalesforceRecommendations 

Pass a DecisionsRequestContext to bias recommendations based on what the user is currently viewing. All fields are optional - if you only supply anchorId, the system infers the type automatically.

1let context = DecisionsRequestContextBuilder()
2    .anchorId("PRODUCT_123")
3    // Optional: explicitly specify the anchor type. If omitted, the system infers it from anchorId.
4    .anchorDmoName("ssot__GoodsProduct__dlm")
5    .contextualAttribute(name: "category", value: "shoes")
6    .build()
7
8ContentZone(
9    name: "ProductRecommendations",
10    allowedComponents: [
11        SalesforceRecommendations(
12            onTap: { event in
13                // Custom tap handling - default behavior opens ctaUrl if provided
14                print("Tapped item: \(event.item.name) at index \(event.index)")
15            }
16        )
17    ],
18    decisionsRequestContext: context
19)

Custom Styling Example for SalesforceBanner 

Override the default appearance of out-of-the-box components by passing a style object:

1import LowCodeMobile
2
3ContentZone(
4    name: "HomeScreen",
5    allowedComponents: [
6        SalesforceBanner(
7            style: BannerStyle(
8                backgroundColor: Color(red: 0.96, green: 0.96, blue: 0.96),
9                headerTextColor: Color(red: 0.1, green: 0.1, blue: 0.1),
10                contentPadding: 16
11            )
12        )
13    ]
14)

Custom Components 

Implement the Component protocol to render a content zone with your own UI. Each component defines:

  • static var name: String - The component name that matches the backend experience template name.
  • validateAndCreateComponentModel(unvalidatedJson:componentContext:) - Decodes and validates the JSON payload and returns a typed model, or throws a PersonalizationError
  • compose(model:componentContext:) - Returns the SwiftUI view for the component.

Define a custom component 

Define a custom component with its own component name, model, and associated experience template in the backend. Give the component a unique name (for example, CustomHero), then create a matching experience template in the Core UI and a matching custom component in your app. Your model’s fields and types must match the experience template’s schema, though you don’t need to use all of them.

1import SwiftUI
2import LowCodeMobile
3import Personalization
4
5struct HeroModel: ComponentModel {
6    let header: String
7    let imageUrl: String
8    let ctaText: String?
9}
10
11struct HeroView: View {
12    let model: HeroModel
13    let componentContext: ComponentContext
14
15    var body: some View {
16        VStack(alignment: .leading) {
17            AsyncImage(url: URL(string: model.imageUrl)) { image in
18                image.resizable().aspectRatio(contentMode: .fill)
19            } placeholder: {
20                Color.gray.opacity(0.2)
21            }
22            .frame(height: 200)
23            .clipped()
24            Text(model.header)
25                .font(.headline)
26                .frame(maxWidth: .infinity, alignment: .center)
27            if let cta = model.ctaText {
28                Text(cta)
29                    .font(.subheadline)
30                    .frame(maxWidth: .infinity, alignment: .center)
31                    .foregroundColor(.blue)
32            }
33        }
34        .onTapGesture {
35            // Track clicks when the user taps
36            componentContext.trackEngagement(action: EngagementAction.click)
37            // You could add a tapUrl to the model and open/route as well
38        }
39        // Track a view once per personalization
40        .sfpTrackEngagementViewOnce(componentContext)
41    }
42}
43
44class CustomHero: Component {
45    public static let name = "CustomHero"
46    public typealias Content = HeroView
47    public typealias Model = HeroModel
48
49    func validateAndCreateComponentModel(unvalidatedJson: Data, componentContext: ComponentContext) throws -> HeroModel {
50        let model = try JSONDecoder().decode(HeroModel.self, from: unvalidatedJson)
51        // Optional validations here, e.g. throw when a required field is blank:
52        // guard !model.header.isEmpty else {
53        //     throw PersonalizationError.responseInvalid("header cannot be blank")
54        // }
55        return model
56    }
57
58    func compose(model: HeroModel, componentContext: ComponentContext) -> Content {
59        return HeroView(model: model, componentContext: componentContext)
60    }
61}
62
63// Usage
64ContentZone(
65    name: "HomeScreen",
66    allowedComponents: [CustomHero()]
67)

Define a multi-item custom component 

For a component that renders a list (like SalesforceRecommendations), use the per-item variants of the engagement functions to report engagement for each item in the list.

1import SwiftUI
2import LowCodeMobile
3import Personalization
4
5struct ItemListModel: ComponentModel {
6    let sectionHeader: String?
7    let items: [ItemModel]
8}
9
10struct ItemModel: Decodable {
11    let id: String
12    let title: String
13    let imageUrl: String
14    let description: String
15}
16
17struct ItemListView: View {
18    let model: ItemListModel
19    let componentContext: ComponentContext
20
21    var body: some View {
22        VStack(alignment: .leading, spacing: 16) {
23            // Optional header above all the items
24            if let sectionHeader = model.sectionHeader {
25                Text(sectionHeader)
26                    .font(.headline)
27                    .frame(maxWidth: .infinity, alignment: .center)
28            }
29            // Each item
30            ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in
31                ItemView(index: index, model: item, componentContext: componentContext)
32            }
33        }
34    }
35}
36
37struct ItemView: View {
38    let index: Int
39    let model: ItemModel
40    let componentContext: ComponentContext
41
42    var body: some View {
43        HStack(alignment: .center) {
44            // Image on the left
45            AsyncImage(url: URL(string: model.imageUrl)) { image in
46                image
47                    .resizable()
48                    .aspectRatio(contentMode: .fill)
49                    .clipped()
50            } placeholder: {
51                Color.gray.opacity(0.2)
52            }
53            .frame(width: 100, height: 100)
54            .clipped()
55            // Text on the right
56            VStack(alignment: .leading, spacing: 8) {
57                Text(model.title)
58                    .font(.headline)
59                    .lineLimit(1)
60                    .truncationMode(.tail)
61                Text(model.description)
62                    .font(.subheadline)
63                    .lineLimit(1)
64                    .truncationMode(.tail)
65            }
66            .frame(maxWidth: .infinity, alignment: .leading)
67        }
68        .frame(minHeight: 100)
69        .onTapGesture {
70            // Track clicks when the user taps
71            componentContext.trackEngagementPerItem(index: index, action: EngagementAction.click)
72            // You could add a tapUrl to the model and open/route as well
73        }
74        // Track a view once per personalization, per item
75        .sfpTrackEngagementViewOncePerItem(componentContext, index: index)
76    }
77}
78
79/// Expected to be used in a ContentZone within a scrollable area, ie has a parent ScrollView
80class CustomItemList: Component {
81    public static let name = "CustomItemList"
82    public typealias Content = ItemListView
83    public typealias Model = ItemListModel
84
85    func validateAndCreateComponentModel(unvalidatedJson: Data, componentContext: ComponentContext) throws -> ItemListModel {
86        let model = try JSONDecoder().decode(ItemListModel.self, from: unvalidatedJson)
87        // Optional validations here, e.g. throw if an item is missing a required field
88        // throw PersonalizationError.responseInvalid("...")
89        return model
90    }
91
92    func compose(model: ItemListModel, componentContext: ComponentContext) -> Content {
93        return ItemListView(model: model, componentContext: componentContext)
94    }
95}
96
97// Usage
98ContentZone(
99    name: "HomeScreen",
100    allowedComponents: [CustomItemList()]
101)

Engagement Tracking 

Out-of-the-box components (SalesforceBanner, SalesforceRecommendations) track View and Click engagement automatically. These are the only two actions currently supported end-to-end. The SDK accepts custom action strings for future expansion.

For custom components, engagement is not automatic - you must call these methods explicitly from your component code:

FunctionUse for
componentContext.trackEngagement(action:)Clicks on a single-item component (e.g. CustomHero)
componentContext.trackEngagementPerItem(index:action:)Clicks on an item within a multi-item component (e.g. CustomItemList)
.sfpTrackEngagementViewOnce(componentContext)Views of a single-item component - a View modifier, applied once per personalization
.sfpTrackEngagementViewOncePerItem(componentContext, index:)Views of an item within a multi-item component - a View modifier, applied once per item per personalization

See Define a custom component and Define a multi-item custom component above for both in use.

Preview 

The SDK supports previewing personalized content via QR code or preview URL. When a URL containing the sfp-preview parameter is opened, the SDK renders preview content in all active content zones.

Before using preview, ensure:

  1. Your app is configured to handle URL schemes or Universal Links (via Associated Domains and your app’s entitlements).
  2. The base URL is set in your Data 360 mobile connector configuration.

SwiftUI 

1@main
2struct MyApp: App {
3    var body: some Scene {
4        WindowGroup {
5            ContentView() // Your app's root View
6                .onOpenURL { url in
7                    PersonalizationModule.handlePreviewURL(url)
8                }
9        }
10    }
11}

UIKit 

The appropriate URL-handling method depends on your app’s lifecycle and the iOS versions you support (e.g. application(_:open:options:), scene(_:openURLContexts:) for SceneDelegate, or Universal Links via application(_:continue:restorationHandler:)). The example below uses the AppDelegate method:

1func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
2    PersonalizationModule.handlePreviewURL(url)
3    return true
4}

Design-Time Rendering 

Use MockDataContentZone to render components with mock data for styling and layout without any backend setup or networking. This is not related to QR-code based previewing - it simply lets you see how components look during development.

1#Preview { // SwiftUI design-time rendering - not related to QR-code based previewing
2    MockDataContentZone(
3        name: "HomeScreen",
4        allowedComponents: [SalesforceBanner(), SalesforceRecommendations()],
5        mockContent: {
6            BannerModel(
7                header: "Summer Sale",
8                imageUrl: "https://example.com/banner.png",
9                ctaText: "Shop Now",
10                ctaUrl: "https://example.com/sale"
11            )
12        }
13    )
14}

To try out your fallback view, throw an error from the closure:

1#Preview("Error State") {
2    MockDataContentZone(
3        name: "HomeScreen",
4        allowedComponents: [SalesforceBanner()],
5        mockContent: { throw PersonalizationError.timeout("Preview timeout") }
6    )
7}

Error Handling 

The SDK uses the PersonalizationError enum for error reporting:

Error CaseDescription
.unknownAn unknown error occurred.
.initializationThe SDK has not been initialized or failed to initialize.
.consentUser consent is not set to opt-in.
.requestInvalidThe request parameters are invalid.
.networkA network error occurred during the fetch.
.responseInvalidThe server response could not be parsed.
.timeoutThe fetch request exceeded the timeout duration.

ContentZone handles errors internally. If no fallback is provided, the zone logs the error and shows nothing - this is the typical production behavior. If you want to display fallback content, pass a fallback view. The following example is for illustration only - consider what behavior is appropriate for your production app:

1ContentZone(
2    name: "HomeScreen",
3    allowedComponents: [SalesforceBanner()],
4    fallback: { error in
5        if let persError = error as? PersonalizationError {
6            switch persError {
7            case .timeout:
8                Text("Request timed out. Pull to refresh.")
9            case .network:
10                Text("No network connection.")
11            default:
12                Text("Content unavailable.")
13            }
14        }
15    }
16)