Skip to main content

iOS SwiftUI SDK (25.4.0)

Introduction

The Atomic iOS SwiftUI SDK is a dynamic framework for integrating an Atomic stream container into your SwiftUI app, presenting cards from a stream to your users. Built with SwiftUI at its core and written entirely in Swift, it delivers a seamless integration experience for SwiftUI-based apps.

The latest stable release of the SwiftUI SDK is 25.4.0.

If you're migrating from the legacy iOS SDK, see Migration from the legacy iOS SDK for a focused checklist and example.

Supported iOS version

The SDK supports iOS 16.0 and above.

Installation

The SDK can be installed using Swift Package Manager, CocoaPods, or manually.

Swift Package Manager

  1. Open your Xcode project, and choose File > Add Package Dependencies.
  2. Enter https://github.com/atomic-app/action-cards-swiftui-sdk-releases in the upper-right text field labeled "Search or Enter Package URL".
  3. Set the dependency rule and click 'Add Package'.
  4. Add both AtomicSDK and AtomicSwiftUISDK to your target.

CocoaPods

  1. Add the path to the SDK spec repo to your Podfile, along with the default specs repo:
source 'https://github.com/atomic-app/action-cards-ios-sdk-specs.git'
source 'https://github.com/CocoaPods/Specs.git'
  1. Add the SDK as a dependency.
pod 'AtomicCards', '25.4.0'
  1. Run pod update.

Alternative way to install Atomic SwiftUI SDK

Alternatively, you can install Atomic SDK directly through a Git path. This will install the latest Atomic SwiftUI SDK.

pod 'AtomicCards', :git => 'https://github.com/atomic-app/action-cards-swiftui-sdk-releases.git'

Note: You may see a known "Sandbox: rsync" failure message after integration. You can set the Xcode build option ENABLE_USER_SCRIPT_SANDBOXING to No to resolve this issue.

Manual installation

  1. You can download releases of the SDK from the Releases page on GitHub.
  2. Once you've downloaded the version you need, navigate to your project in Xcode and select the "General" settings tab.
  3. Drag both AtomicSDK.xcframework and AtomicSwiftUISDK.xcframework from the directory where you unzipped the release, to the Embedded Binaries section.
  4. When prompted, ensure that "Copy items if needed" is selected, and then click "Finish".

Migration from the legacy iOS SDK

This section explains the best-practice migration path from the legacy iOS SDK (see the legacy iOS SDK guide) to the SwiftUI SDK.

Migration checklist

  • Update imports to use AtomicSwiftUISDK.
  • Verify your initialization code compiles with the SwiftUI SDK.
  • Migrate UI to SwiftUI-native containers (see Displaying containers).
  • Review API-driven card containers behavior (see API-driven card containers).

API migration

Search for import AtomicSDK and replace it with import AtomicSwiftUISDK. In most cases this is a direct swap with no additional changes. The main exception is the API-driven card containers API, which has been refactored to use Swift-native data types and API conventions; follow the guidance in API-driven card containers if you use those APIs.

Note: If you already have both import AtomicSDK and import AtomicSwiftUISDK in the same file, remove import AtomicSDK and keep the SwiftUI SDK import.

Import migration (before/after)

// Legacy iOS SDK
import AtomicSDK
// SwiftUI SDK
import AtomicSwiftUISDK

Initialization migration (before/after)

// Legacy iOS SDK
AACSession.login(
withEnvironmentId: "<environmentId>",
apiKey: "<apiKey>",
sessionDelegate: <the session delegate>,
apiBaseUrl: URL(string: "SDK_API_BASE_URL")
)
// SwiftUI SDK
AACSession.login(
withEnvironmentId: "<environmentId>",
apiKey: "<apiKey>",
sessionDelegate: <the session delegate>,
apiBaseUrl: URL(string: "SDK_API_BASE_URL")
)

Card count observer migration (before/after)

// Legacy iOS SDK
let filters = [
AACCardListFilter.filter(byCardsIn: [.byPriority(1), .byPriority(2)]),
AACCardListFilter.filter(byCardsEqualTo: .byVariableName("is_urgent", boolean: true))
]
let token = AACSession.observeCardCountForStreamContainer(
withIdentifier: "<stream container id>",
interval: 10,
filters: filters
) { count in
print("Count: \(String(describing: count))")
}
// SwiftUI SDK
let filters = [
AACCardListFilter.filter(byCardsIn: [.byPriority(1), .byPriority(2)]),
AACCardListFilter.filter(byCardsEqualTo: .byVariableName("is_urgent", boolean: true))
]
let token = AACSession.observeCardCountForStreamContainer(
withIdentifier: "<stream container id>",
interval: 10,
filters: filters
) { count in
print("Count: \(String(describing: count))")
}

UI migration

Use the new SwiftUI-native UI components described in Displaying containers. This replaces the legacy UIKit-based container views and ensures your UI is fully SwiftUI-native.

Setup

Before you can display a stream container or single card, you will need to configure your API base URL, environment ID, session delegate, and API key.

Convenient initialization method

You can use a convenient method AACSession.login(withEnvironmentId:apiKey:sessionDelegate:apiBaseUrl:) to initialize API base URL, environment ID, session delegate, and API key all at once.

It's the equivalent of calling AACSession.initialise(withEnvironmentId:apiKey:), AACSession.setSessionDelegate(_:) and AACSession.setApiBaseUrl(_:) in sequence, all introduced in sections below.

The following code snippet shows how to call this method.

AACSession.login(
withEnvironmentId: "<environmentId>",
apiKey: "<apiKey>",
sessionDelegate: <the session delegate>,
apiBaseUrl: URL(string: "SDK_API_BASE_URL")
)

In SwiftUI, a common place to call this is the init method of your App type.

SDK API base URL

You must specify your SDK API base URL when configuring the Atomic SDK. This URL is found in the Atomic Workbench:

  1. In the Workbench, click the configuration icon in the left-hand sidebar;
  2. On the screen that appears, select "SDK API Host" under the "SDK" section in the left sidebar. Your SDK API base URL is displayed in the "SDK API Host" section.
SDK API base URL

The SDK API base URL is different from the API base URL endpoint, which is also available under Configuration. The SDK API base URL ends with client-api.atomic.io.

You can specify your SDK API base URL in two ways:

  1. By adding the following to your app's Info.plist file under the key AACRequestBaseURL, replacing SDK_API_BASE_URL with your URL:
<key>AACRequestBaseURL</key>
<string>SDK_API_BASE_URL</string>
  1. By declaring your API base URL in code, replacing SDK_API_BASE_URL with your URL:
if let url = URL(string: "SDK_API_BASE_URL") {
AACSession.setApiBaseUrl(url)
}

Environment ID and API key

Within your host app, you will need to call the AACSession.initialise(withEnvironmentId:apiKey:) method to configure the SDK. Your environment ID and API key can be found in the Atomic Workbench.

If you do not call this method and attempt to use any functionality in the SDK, an exception will be raised.

AACSession.initialise(withEnvironmentId: "<environmentId>", apiKey: "<apiKey>")

Authenticating requests using a JWT

The Atomic SDK uses a JSON Web Token (JWT) to perform authentication.

The SDK Authentication guide provides step-by-step instructions on how to generate a JWT and add a public key to the Workbench.

Within your host app, you will need to call AACSession.setSessionDelegate(_:) to provide an object conforming to the AACSessionDelegate protocol, which includes the methods:

  • cardSessionDidRequestAuthenticationToken(handler:)
  • cardSessionDidRequestAuthenticationToken() async

You must implement at least one of these methods, and the async method is recommended.

The SDK calls one of these methods when it needs a JWT for authentication. You are then responsible for generating the JWT and supplying it to the SDK. If you do not have a valid token, return nil, which will invoke error handling within the SDK. You must also return the token within 5 seconds, otherwise error handling will be triggered.

AACSession.setSessionDelegate(<the session delegate>)

Note: By calling AACSession.setSessionDelegate(_:), the SDK holds a strong reference to the session delegate until your app exits.

JWT Expiry interval

The Atomic SDK allows you to configure the time interval to determine whether the JSON Web Token (JWT) has expired. If the interval between the current time and the token's exp field is smaller than that interval, the token is considered to be expired. If this method is not set, the default value is 60 seconds. The interval must not be less than zero. You must return a constant value from this method, as the value is only retrieved by the SDK once.

func expiryInterval() -> TimeInterval {
return 60
}

JWT Retry interval

The Atomic SDK allows you to configure the time interval to determine whether the SDK should call the session delegate for a new JWT. The SDK won't try to call the delegate sooner than this interval after failing to fetch a valid JWT. If this method is not set, the default value is 0 seconds, which means the SDK will call for a new JWT immediately after failures. The interval must not be less than zero.

You must return a constant value from this method, as the value is only retrieved by the SDK once.

func retryInterval() -> TimeInterval {
return 60
}

JWT custom user ID field

To use a custom field in your JWT for the user ID used by the Atomic Platform, you must first configure this field in the Atomic Workbench for the relevant SDK API Key.

Once you have the API Key configuration in place, you need to return the custom field in AACSessionDelegate's tokenUserIdAttribute method:

Note: You must return a constant value from this method, as the value is only retrieved by the SDK once.

If this method is not set, the SDK will look for the default names in the following order: atomic_sub, atomic_id, sub, id.

func tokenUserIdAttribute() -> String? {
"custom-user-id-field"
}

WebSockets and HTTP API Protocols

The Atomic SDK uses WebSocket as the default protocol and HTTP as a backup. However, you can switch to HTTP by using AACSession.setApiProtocol(_:), which accepts a parameter of type AACApiProtocol. You can call this method at any time and it will take effect immediately. The setting will last until the host app restarts.

The AACApiProtocol enum has two values:

  • .webSockets: Represents the WebSocket protocol.
  • .http: Represents the HTTP protocol.
AACSession.setApiProtocol(.http)

Logging out the current user

The SDK provides AACSession.logout() for clearing user-related data when a previous user logs out or when the active user changes, ensuring that the cache (including JWT) is clear when a new user logs in to your app. This method also sends any pending analytics events back to the Atomic Platform.

Logout behaviors

The following behaviors apply to this method:

  • After logging out, you must log in to the SDK (by calling either AACSession.login(withEnvironmentId:apiKey:sessionDelegate:apiBaseUrl:) or the appropriate initialization methods) to proceed with another user. Otherwise, the Atomic SDK will raise exceptions.
  • This method also purges all cached card data stored by the SDK and disables all SDK activities.
  • This method also invalidates existing stream containers, single card views, and card count observers. However, they are not deallocated by the SDK to prevent visual flickering. For a complete logout, you must handle deallocation yourself.
  • There is another logout method that takes an extra parameter - withNotificationsDeregistered. Set this parameter to true to deregister push notifications when logging out. See below for the example code snippet.

The async method throws if pending analytics events cannot be sent. The following code snippet shows how to call this method and handle the error.

do {
try await AACSession.logout()
} catch {
let error = error as NSError
if let errorCode = AACSessionLogoutError.Code(rawValue: error.code) {
switch errorCode {
case .dataError:
// Deal with data error.
let dataError = error.userInfo[NSUnderlyingErrorKey] as? NSError
case .networkError:
// Deal with network error.
let networkError = error.userInfo[NSUnderlyingErrorKey] as? NSError
case .aborted:
// Deal with aborted logout.
let abortedError = error.userInfo[NSUnderlyingErrorKey] as? NSError
@unknown default:
// A new type of error is added in the future.
let unknownError = error.userInfo[NSUnderlyingErrorKey] as? NSError
}
}
}

The following code snippet shows how to deregister push notifications when logging out.

do {
try await AACSession.logout(withNotificationsDeregistered: true)
} catch {
// Handle the error.
}

If you need a completion handler, use AACSession.logout(_:) or AACSession.logout(withNotificationsDeregistered:completionHandler:) instead.

Error handling

If the operation throws, the error domain will be AACSessionLogoutErrorDomain - look for a specific error code in the AACSessionLogoutErrorCode enumeration to determine the cause of the error. NSUnderlyingErrorKey will also be populated in the error's userInfo dictionary.

Displaying containers

This section covers how to render Atomic containers in SwiftUI, including stream, horizontal, single card, and modal variants, plus the shared configuration options that apply across them.

About ContainerConfiguration

ContainerConfiguration is a central configuration class used to customize the behavior and appearance of all Atomic containers in the SwiftUI SDK. This includes vertical stream containers, horizontal containers, single card containers, and modal containers. By creating and modifying a ContainerConfiguration instance, you can control a wide range of options such as UI elements, refresh intervals, card styling, custom strings, and event handling.

You typically create a ContainerConfiguration object in your view model or view, set its properties or call its methods to adjust the container's features, and then pass it to the container you are displaying. Each container type accepts a configuration object as an optional parameter. If omitted, the container will use default settings.

Below are the main configuration options available in ContainerConfiguration:

Style and presentation

  • presentationStyle: How the container is displayed. Options include .withoutButton and .withActionButton.
  • launchBackgroundColor: Background color for the launch screen when the SDK theme is loaded for the first time. (default: white).
  • launchTextColor: Text color for the initial view (default: black at 50% opacity).
  • launchLoadingIndicatorColor: Color for the loading spinner (default: black).
  • launchButtonColor: Color for retry buttons on first load (default: black).
  • interfaceStyle: Interface style for the container (.automatic, .light, .dark).
  • enabledUIElements: Array of UI elements to enable. Options include:
    • .cardListToast: Show toast messages at the bottom of the screen.
    • .cardListFooterMessage: Show a footer message below the last card.
    • .cardListHeader: Show a header at the top of the card list (not in single card containers). This value applies to stream containers and horizontal containers, and has no effect in single card views and modal containers.
    • .requestCameraUsage: Show a toast prompting for camera access if needed. See File Upload for more details.
    • To disable all UI elements, set to an empty array: config.enabledUIElements = [].
  • cardMaxWidth: Specifies the width and horizontal alignment for each card displayed in the stream container. Defaults to .fill, keeping cards automatically sized with horizontal padding from the container. See Maximum card width for more details.
  • ignoresSafeAreaEdges: The set of edges to expand the stream container. Any edges that you don’t include in this set remain unchanged (default: nil). This option only applies to the variant StreamContainer.

The following options only apply to horizontal containers.

  • horizontalTitleAlignment: The option for aligning the title of the header of a horizontal container (default: .center).
  • horizontalEmptyStyle: The empty style of a horizontal container view. It determines how the view displays when there are no cards (default: .standard).
  • horizontalScrollMode: The option for controlling the scroll mode of the container (default: .snap on iOS 17 and later; .free on iOS 16).
  • horizontalLastCardAlignment: The option for aligning the card in a horizontal container when there is only one card in the container (default: .leading).

The following option only applies to modal containers.

  • modalContainerPosition: The vertical position of the modal container when presented (default: .center).

Functionality

  • cardListRefreshInterval: How often the card list auto-refreshes (default: 15 seconds, min: 1 second, 0 disables auto-refresh).
  • onEvent: An optional callback function that handles events occurring within a container (e.g., camera denied/restricted).
  • onRuntimeVariablesRequested: An optional callback function that resolves runtime variables on cards, when requested by the SDK.
  • runtimeVariableResolutionTimeout: Timeout for resolving runtime variables (default: 5 seconds).
  • sendRuntimeVariableAnalytics: Whether the runtime-vars-updated analytics event, which includes resolved values of each runtime variable, should be sent when runtime variables are resolved (default: false).
  • filters: Optional filters applied when fetching cards of the container, or nil to observe without filters (default: nil).

Custom strings

Set custom strings using setCustomValue(_:for:). Note that some custom strings do not apply to all types of containers. Check the definition file for more details.

  • .cardListTitle: The title to display at the top of the card list (default: "Cards"), applying to both stream containers and horizontal containers.
  • .snoozeTitle: The title to display for the card snooze functionality (default: "Remind me").
  • .dismissTitle: The title to display for the card dismiss action (default: "Dismiss").
  • .awaitingFirstCard: The message displayed over the card list, when the user has never received a card before (default: "Cards will appear here when there’s something to action.").
  • .allCardsCompleted: The message displayed when the user has received at least one card before, and there are no cards to show (default: "All caught up").
  • .votingUseful: The title to display for the action a user taps when they flag a card as useful (default: "This is useful").
  • .votingNotUseful: The title to display for the action a user taps when they flag a card as not useful (default: "This isn't useful").
  • .votingFeedbackTitle: Title displayed at the top of the screen where users can provide feedback on why they found a card useful or not useful (default: "Send feedback").
  • .cardListFooterMessage: Footer message below the last card (not in horizontal/single card containers).
  • .noInternetConnectionMessage: Error message for no internet (default: "No internet connection").
  • .dataLoadFailedMessage: Error message for API/theme load failure (default: "Couldn't load data").
  • .tryAgainTitle: Button title for retrying failed requests (default: "Try again").
  • .toastCardDismissedMessage: Toast for card dismissed (default: "Card dismissed").
  • .toastCardCompletedMessage: Toast for card completed (default: "Card completed").
  • .toastCardSnoozeMessage: Toast for card snoozed (default: "Snoozed until X").
  • .toastCardFeedbackMessage: Toast for card feedback (default: "Feedback received").
  • .thumbnailImageActionLinkTitle: CTA for thumbnail images (default: "View").
  • .thumbnailVideoActionLinkTitle: CTA for thumbnail videos (default: "Watch").
  • .processingStateMessage: Message during file upload (default: "Sending, please wait...").
  • .processingStateCancelButtonTitle: Cancel button during file upload (default: "Cancel process").
  • .toastFileUploadFailedMessage: Toast for failed file upload (default: "Couldn't upload file(s)").
  • .requestCameraAccessMessage: Toast for requesting camera access (default: "Access to your camera is required to take photos. Please enable camera access in your device settings").
  • .requestCameraAccessSettingsTitle: Button title for camera access settings (default: "Settings").
  • .votingFeedbackValidationMessage: Validation message shown when the user reaches the 280 character limit while providing feedback on the feedback page (default: "Feedback is limited to 280 characters").

You will find usage examples for ContainerConfiguration throughout this guide, in the context of each container type.

Displaying a vertical container

The Atomic SwiftUI SDK provides support for rendering a vertical stream container within your host application. The vertical container displays cards arranged from top to bottom.

To use this feature, create an instance of StreamContainer and supply the following parameters:

  1. Stream container ID: Identifier for the stream container you wish to display.
  2. Configuration (optional): Defines the desired appearance and behavior of the stream container.

The following code snippet displays a vertical container using the default configuration:

import AtomicSwiftUISDK

...

var body: some View {
NavigationStack {
VStack {
NavigationLink {
StreamContainer(containerId: "<stream container id>")
.navigationTitle("Atomic Stream")
} label: {
Text("Messages")
}
}
.navigationTitle("Atomic Boilerplate")
.navigationBarTitleDisplayMode(.large)
}
}

To set your configuration object, you could first do something like this in your view model:

    var config = ContainerConfiguration()

init() {
config.setCustomValue("test", for: .cardListTitle)
}

This example sets the stream header title to "test".

To use the configuration, pass it into the StreamContainer instance:

StreamContainer(containerId: "1234", configuration: viewModel.config)

Closing a vertical container

For dismissal patterns (including .sheet and .fullScreenCover), see Closing a stream container.

Displaying a horizontal container

The Atomic SwiftUI SDK provides support for rendering a horizontal stream container within your host application. The horizontal container displays cards arranged from left to right with the same height.

To use this feature, instantiate a HorizontalContainer, which is a SwiftUI View configured similarly to a standard stream container. On initialization, supply the following parameters:

  1. Stream container ID: The identifier for the stream you wish to display.
  2. Card width: The width of each card displayed (must be greater than 0).
  3. Configuration (optional): Defines the desired behavior and appearance of the horizontal container.

The available configuration options align with those of a standard stream container. However, note that certain properties are not applicable to horizontal containers. For detailed information, refer to the definition file of ContainerConfiguration.

The following example illustrates how to place a horizontal container above a LazyVStack, using a card width of 350 and the default configuration.

import AtomicSwiftUISDK

...

var body: some View {
NavigationStack {
ScrollView {
VStack {
HorizontalContainer(containerId: "<stream container id>", cardWidth: 350)
LazyVStack {
ForEach(0..<1000) { _ in Text("Placeholder") }
}
}
}
}
}

Note: Pull-to-refresh functionality and swipe gestures are disabled in horizontal containers.

Configuration options for a horizontal container

In addition to common properties from ContainerConfiguration, the following properties are specifically designed for horizontal containers:

  • horizontalTitleAlignment: Sets the alignment of the title in the horizontal header. Possible values:

    • center: Default value. Title is centered within the header.
    • leading: Title is aligned to the leading edge of the header.
  • horizontalEmptyStyle: Defines how the container appears when no cards are present. Possible values:

    • standard: Default value. Displays a UI indicating no cards are available.
    • shrink: The container shrinks when there are no cards.
  • horizontalScrollMode: Controls scrolling behavior in the container. Possible values:

    • snap: Available on iOS 17.0 and later. Default when available. Enables snapping, positioning each card in the center of the viewport upon scroll termination.
    • free: Default value for iOS 16. The container scrolls freely without snapping.
  • horizontalLastCardAlignment: Aligns the card when only one card is present in the container. Possible values:

    • leading: Default value. Aligns the card to the leading edge.
    • center: Aligns the card to the center of the container.
    • scaleToFill: Scales the card's width to fill the container with default padding.

Displaying a single card container

The Atomic SwiftUI SDK also supports rendering a single card in your host app.

To create an instance of SingleCardContainer, which is configured in the same way as a vertical stream container, supply the following parameters:

  1. Stream container ID: Identifier for the stream container you wish to display. The single card container renders only the first card that appears in that stream container.
  2. Configuration (optional): Defines the desired appearance and behavior of the single card container.

The configuration options, supplied using the configuration object above, are the same as those for a stream container. However, some properties are not applied to the single card container. For detailed information, refer to the definition file of ContainerConfiguration.

The code snippet below shows how to display a single card container with some configuration.

import AtomicSwiftUISDK

...

var body: some View {
var config = ContainerConfiguration()
config.enabledUIElements = [.cardListToast]
config.cardListRefreshInterval = 10
return NavigationStack {
ScrollView {
VStack {
Text("Header view")
SingleCardContainer(containerId: "<stream container id>", configuration: config)
Text("Footer view")
}
}
}
}

Displaying a modal container

The Atomic SwiftUI SDK provides support for displaying a modal container within your host application. The modal is displayed as soon as there are cards present in the associated stream container, and displays the first card over a full-screen overlay, preventing interactions with views behind it. Users may only exit the modal after removing all the presented cards.

To integrate this feature, attach the .modalContainer modifier to your SwiftUI view, providing the following parameters upon initialization:

  1. Stream container ID: Identifier for the stream container you wish to display.
  2. Configuration (optional): Defines the desired appearance and behavior of the modal container.

The available configuration options align closely with those of a standard stream container; however, note that certain properties are not applicable to modal presentations. For detailed information, refer to the definition file of ContainerConfiguration.

View Hierarchy Considerations

Note that attaching the .modalContainer modifier affects all subviews. As a result, the modal will be triggered even if the user has navigated to deeper subviews within your view hierarchy.

The background color of the modal container’s full-screen overlay is defined in the theme editor. For more details, see Modal container theme.

You can define the vertical position of the card by configuring the modalContainerPosition property in the ContainerConfiguration struct. There are three options:

  • top(offset) – Positions the modal container at the top of the screen, offset by the specified number of display points.
  • center – Positions the modal container in the vertical center of the screen.
  • bottom(offset) – Positions the modal container at the bottom of the screen, offset by the specified number of display points.

By default, modalContainerPosition is set to center.

Note: Negative offsets behave the same as 0.

If the content within a card exceeds the available screen height, the card becomes vertically scrollable, ignoring any vertical position and offset defined in modalContainerPosition. Scrollable content is always aligned to the top of the scroll view, using the container’s top and bottom padding defined in the Workbench theme. See Theme reference for more details on container padding.

The following example illustrates attaching a modal container to a SwiftUI view using the default configuration:

import AtomicSwiftUISDK

...

var body: some View {
Text("placeholder")
.modalContainer(containerId: "<stream container id>")
}

Note: Swipe gestures are disabled within modal containers.

Maximum card width

You can specify a maximum width for each card within the vertical stream container, a single card view, or a modal container, with specified alignments for the card(s).

To set this, use the cardMaxWidth property in ContainerConfiguration to define the desired width and alignment, and apply this configuration when initializing the stream container or the single card view.

The default value for cardMaxWidth is .fill, which means the card will automatically adjust its width to match that of the stream container with horizontal padding.

Another possible value is .fixed(width, alignment:), where width is the maximum width of the card(s), and alignment is an optional value that you can supply to position the card within its container. Omit alignment to keep cards centered.

However, there are a few considerations for setting the width:

  • Setting a value below 200 display points is not recommended, as it may trigger layout constraint warnings when content cannot fit.

  • Negative values behave the same as 0 display points.

  • The padding between the card and the container will never be less than the container padding specified in the theme, with a default value of 10 display points. For example, on a device with a 440-point-wide screen, any card width exceeding 400 points will be ignored if the container’s left and right padding are both set to 20 points.

Note: This option has no effect in horizontal containers.

The following code snippet sets the maximum card width to 500 with a center alignment.

var body: some View {
var config = ContainerConfiguration()
config.cardMaxWidth = .fixed(500)
return NavigationStack {
VStack {
NavigationLink {
StreamContainer(isInNavigationStack: true, containerId: "<stream container id>", configuration: config)
} label: {
Text("Messages")
}
}
}
}

The following code snippet sets the maximum card width to 500 with a leading alignment.

var body: some View {
var config = ContainerConfiguration()
config.cardMaxWidth = .fixed(500, alignment: .leading)
return NavigationStack {
VStack {
NavigationLink {
StreamContainer(isInNavigationStack: true, containerId: "<stream container id>", configuration: config)
} label: {
Text("Messages")
}
}
}
}

Dealing with SwiftUI lazy containers

When integrating Atomic containers within lazy containers like List or LazyVStack and embedding them inside a NavigationStack, you may encounter a SwiftUI navigation warning at runtime:

Do not put a navigation destination modifier inside a "lazy” container, like `List` 
or `LazyVStack`. These containers create child views only when needed to render on screen.
Add the navigation destination modifier outside these containers so that the navigation
stack can always see the destination. There's a misplaced `navigationDestination(item:destination:)`
modifier for type `Optional<ContainerDestination>`. It will be ignored in a future release.

This warning occurs because "lazy" containers create their child views on demand as they become visible. When an Atomic container with navigationDestination modifiers is placed inside these lazy containers within a NavigationStack, SwiftUI cannot properly register the navigation destinations, which can lead to navigation failures.

To resolve this issue, use LazySingleCardContainer or LazyHorizontalContainer instead of SingleCardContainer or HorizontalContainer in this scenario:

❌ Incorrect usage (will produce warnings):

NavigationStack {
ScrollView {
LazyVStack {
Text("Header view")
SingleCardContainer(containerId: "<stream container id>")
ForEach(0..<1000) { _ in Text("Placeholder") }
}
}
}

✅ Correct usage:

NavigationStack {
ScrollView {
LazyVStack {
Text("Header view")
LazySingleCardContainer(containerId: "<stream container id>")
ForEach(0..<1000) { _ in Text("Placeholder") }
}
}
}

Note: The vertical stream container doesn't have a lazy version since it's not typical to place such a view inside "lazy" containers. Vertical stream containers are designed to manage their own scrolling and content loading, making them unsuitable for this scenario.

It’s okay to continue using the normal version of containers if you’re not placing lazy containers inside a NavigationStack. For example, the following code snippet would work:

✅ Correct usage:

ScrollView {
LazyVStack {
Text("Header view")
SingleCardContainer(containerId: "<stream container id>")
ForEach(0..<1000) { _ in Text("Placeholder") }
}
}

Closing a stream container

In SwiftUI, vertical stream containers, horizontal containers, and single card views are dismissed the same way as any other SwiftUI view. There is no SDK-specific close method to call.

If you present a container in a sheet or full-screen cover, dismiss it using your own view state. For example, add a close button that updates the binding used to present the container.

struct MessagesView: View {
@Binding var isPresented: Bool

var body: some View {
StreamContainer(containerId: "<stream container id>")
.toolbar {
Button("Close") {
isPresented = false
}
}
}
}

For a full-screen cover, you can place a close button above the container and update the binding:

.fullScreenCover(isPresented: $isShowingMessagesView) {
VStack(alignment: .leading, spacing: 0) {
Button {
isShowingMessagesView = false
} label: {
Text("Close")
.font(.custom("Figtree-Medium", size: 16))
.foregroundStyle(.black)
}
.padding(15)
StreamContainer(containerId: "<stream container id>")
}
}

API-driven card containers

The SwiftUI SDK lets you observe a stream container purely through the SDK API, even when the container’s UI is not rendered. This is useful for custom UI or background logic that needs live card data.

The method contains a handler that receives an updated list of cards, or nil if cards are unavailable. When you observe a stream container using the WebSocket protocol (by default), updates arrive immediately when the feed changes. If WebSockets are unavailable, the SDK polls at the interval you specify in the configuration (default: 15 seconds). See WebSockets and HTTP API Protocols for how to change between WebSockets and HTTP protocols.

Note: When using the HTTP protocol, you must update your card list after API-driven card actions. Otherwise, the card feed only updates on the configured polling interval.

The following snippet shows the simplest use case:

// Observe the stream container
AACSession.observeStreamContainer(identifier: "1", configuration: nil) { cards in
if let cards = cards {
print("There are \(cards.count) cards in the container.")
}
}

This method returns a token that you can use to stop the observation (see Stopping the observation below).

Card model

The handler returns an array of Card objects. Each Card includes metadata (such as instanceId, updatedTime, and payloadMetadata) and render components for building your own UI.

Configuration options

Pass a StreamContainerObserverConfiguration to control observation behavior. All properties are optional.

  • cardListRefreshInterval: How frequently to poll for updates when WebSockets are unavailable. Defaults to 15 seconds. Set to 0 to disable polling, or 1 or more to set the interval in seconds.
  • filters: Optional filters applied when fetching cards, or nil for no filters. Filters come from static methods on AACCardListFilter.
  • onRuntimeVariablesRequested: Optional handler for resolving runtime variables.
  • runtimeVariableResolutionTimeout: Timeout for resolving runtime variables. Defaults to 5 seconds and cannot be negative.
  • sendRuntimeVariableAnalytics: Whether the runtime-vars-updated analytics event should be sent after resolving runtime variables. Defaults to false.

If you already have a ContainerConfiguration, you can derive observer settings with StreamContainerObserverConfiguration(containerConfiguration:).

Stopping the observation

Use the returned token to stop observing a stream container:

let token = AACSession.observeStreamContainer(identifier: "1", configuration: nil) { _ in }
AACSession.stopObservingStreamContainer(token)

Examples

Accessing card metadata

Card metadata is information about the card that isn’t part of the UI content itself, such as the instance ID, update time, and action properties.

The code snippet below shows how to access these metadata elements for a card instance.

AACSession.observeStreamContainer(identifier: "1", configuration: nil) { cards in
if let card = cards?.first {
print("Card instance ID: \(card.instanceId)")
print("Updated time: \(card.updatedTime)")
print("Has buttons: \(card.hasButtons)")
print("Overflow menu style: \(card.actionProperties.overflowMenuStyle)")
}
}

Traversing card components

Components represent the elements defined in Workbench. Each CardComponent exposes a Component enum case you can switch over to access element data.

The code snippet below shows how to traverse through components and read the category text.

AACSession.observeStreamContainer(identifier: "1", configuration: nil) { cards in
if let card = cards?.first {
for component in card.components {
switch component.data {
case .category(let node):
print("Category: \(node.text)")
case .headline(let node):
print("Headline: \(node.text)")
case .listItem(let node):
print("List item: \(node.text)")
default:
break
}
}
}
}

Accessing subviews

Subviews are alternate layouts defined on a card, each with a unique subview ID. Inspect the card editor in Atomic Workbench or use subviewIds to discover which subviews are available, then call subview(id:) to access a layout by ID.

The following code snippet shows how to retrieve a subview layout using a specific subview ID.

AACSession.observeStreamContainer(identifier: "1", configuration: nil) { cards in
if let card = cards?.first, let subview = card.subview(id: "subviewID") {
print("Accessing subview \(subview.coreData.title).")
}
}

Accessing card actions and buttons

Buttons are exposed through buttonContainer components. You can inspect each button’s action to determine what happens when the user taps it.

The code snippet below shows how to extract button names and action types.

AACSession.observeStreamContainer(identifier: "1", configuration: nil) { cards in
if let card = cards?.first {
for component in card.components {
if case .buttonContainer(let container) = component.data {
for button in container.buttons {
switch button.action {
case .submit(_, _, let buttonName):
print("Submit button: \(button.text) (\(buttonName))")
case .dismiss:
print("Dismiss button: \(button.text)")
case .snooze:
print("Snooze button: \(button.text)")
default:
break
}
}
}
}
}
}

Filtering cards

Apply filters in StreamContainerObserverConfiguration to narrow the cards you observe.

The code snippet below shows a filter that limits results by priority and a boolean variable.

var configuration = StreamContainerObserverConfiguration()
configuration.filters = [
AACCardListFilter.filter(byCardsIn: [.byPriority(1), .byPriority(2)]),
AACCardListFilter.filter(byCardsEqualTo: .byVariableName("is_urgent", boolean: true))
]

AACSession.observeStreamContainer(identifier: "1", configuration: configuration) { cards in
print("Filtered cards: \(cards?.count ?? 0)")
}

Resolving runtime variables

Provide an onRuntimeVariablesRequested handler to resolve runtime variables before cards are returned.

The code snippet below shows how to resolve a runtime variable called amount.

var configuration = StreamContainerObserverConfiguration()
configuration.onRuntimeVariablesRequested = { cardsToResolve in
cardsToResolve.map { card in
var resolvedCard = card
resolvedCard.resolveRuntimeVariable(name: "amount", value: "500")
return resolvedCard
}
}

AACSession.observeStreamContainer(identifier: "1", configuration: configuration) { cards in
print("Resolved cards: \(cards?.count ?? 0)")
}

API-driven card actions

(introduced in 25.4.0)

In version 25.4.0, we introduced a feature that executes card actions through the SDK API. The currently supported actions are dismiss, submit, and snooze. To execute these card actions, follow these two steps:

  1. Create a card action object: Use the corresponding initialization methods of the AACSessionCardAction struct. You'll need at least a container ID and a card instance ID for this. The container ID should be from the container where the card is published, and the card instance ID can be obtained from the API-driven stream container. See API-driven card containers for more details.

  2. Execute the action: Call the method try await AACSession.onCardAction(_:) to perform the card action.

Dismissing a card

The following code snippet shows how to dismiss a card.

let action = AACSessionCardAction(dismissActionWithContainerId: "1", cardId: "card-id")
do {
try await AACSession.onCardAction(action)
print("Card dismissed.")
} catch {
print("An error happened when dismissing the card.")
}

Submitting a Card

You have the option to submit certain values along with a card. These values are optional and should be encapsulated in a dictionary object, using String keys and values that are either strings, numbers, or booleans.

Input Components

While editing cards in Workbench, you can add input components onto cards and apply various validation rules, such as Required, Minimum length, or Maximum length. The input elements can be used to submit user-input values, where the validation rules are applied when submitting cards through UIs of stream containers.

However, for this non-UI version, support for input components is not available yet. There is currently no mechanism to store values in these input components through this API, and the specified validation rules won't be enforced when submitting cards.

Including a button name

Atomic cards include button names when they are submitted. The button name will be added to analytics to enable referencing the triggering button in an Action Flow. Therefore, you need to provide a button name when submitting cards.

The button name of a submit button can be acquired when receiving cards through API-driven cards. The following code snippet shows how to obtain button name(s) from the top-level of the first card.

AACSession.observeStreamContainer(identifier: "1", configuration: nil) { cards in
if let card = cards?.first {
// Each card has a single button container component.
if let buttonContainer = card.components.first(where: { $0.data.isButtonContainer }) {
if case .buttonContainer(let container) = buttonContainer.data {
for button in container.buttons {
if case .submit(_, _, let buttonName) = button.action {
print("The button name of submit button \(button.text) is \(buttonName)")
}
}
}
}
}
}

Submitting the card

With the button name obtained, you can now submit the card. The following code snippet shows how to submit a card with specific values.

let action = AACSessionCardAction(
submitActionWithContainerId: "1",
cardId: "card-id",
submitButtonName: "button-name",
submitValues: [
"submit-key": "submitted values",
"submit-key2": 999
]
)
do {
try await AACSession.onCardAction(action)
print("Card submitted.")
} catch {
print("An error happened when submitting the card.")
}

Snoozing a Card

When snoozing a card, you must specify a non-negative interval in seconds. Otherwise, an error will be returned.

The following code snippet shows how to snooze a card for a duration of 1 minute.

let action = AACSessionCardAction(snoozeActionWithContainerId: "1", cardId: "card-id", snoozeInterval: 60)
do {
try await AACSession.onCardAction(action)
print("Card snoozed.")
} catch {
print("An error happened when snoozing the card.")
}

Error handling

If the action executes successfully, no error is thrown. If there is an error, the thrown error will use the error domain AACSessionCardActionsErrorDomain.

To identify the specific cause of the error, refer to the AACSessionCardActionsErrorCode enumeration for the relevant error code. Additionally, the NSUnderlyingErrorKey will be populated in the userInfo dictionary of the error, providing further details.

If you need a completion handler, use AACSession.onCardAction(_:completionHandler:) instead.

Dark mode

Stream containers in the Atomic SwiftUI SDK support dark mode. You configure an (optional) dark theme for your stream container in the Atomic Workbench.

The interface style determines which theme is rendered:

  • .automatic: If the user's device is currently set to light mode, the stream container will use the light (default) theme. If the user's device is currently set to dark mode, the stream container will use the dark theme (or fall back to the light theme if this has not been configured). On iOS versions less than 13, this setting is equivalent to .light.
  • .light: The stream container will always render in light mode, regardless of the device setting.
  • .dark: The stream container will always render in dark mode, regardless of the device setting.

To change the interface style, set the corresponding value for the interfaceStyle property on the ContainerConfiguration object when creating the stream container.

If this property is left unset, it will default to .automatic.

Filtering cards

Stream containers (vertical or horizontal), single card views, modal containers, and container card count observers can have one or more filters applied. These filters determine which cards are displayed, or how many cards are counted.

A stream container filter consists of two parts: a filter value and an operator.

Filter values

The filter value is used to filter cards in a stream container. The following list outlines all card attributes that can be used as a filter value.

Card attributeDescriptionValue type
PriorityCard priority defined in Workbench, Card -> DeliveryInt
Card instance created dateThe date and time when a card instance is createdDate
Card template IDThe template ID of a card, see below for how to get itString
Card template nameThe template name of a cardString
Custom variableThe variables defined for a card in Workbench, Card -> VariablesMultiple

Use corresponding static methods of AACCardFilterValue to create a filter value.

Examples

Card priority

The following code snippet shows how to create a filter value that represents a card priority 6.

let filterValue = AACCardFilterValue.byPriority(6)
Custom variable

The following code snippet shows how to create a filter value that represents a boolean custom variable isSpecial.

let filterValue = AACCardFilterValue.byVariableName("isSpecial", boolean: true)

Note: It's important to specify the right value type when referencing custom variables for filter values. There are five types of variables in the Workbench; currently, four are supported: String, Number, Date, and Boolean. Use methods like AACCardFilterValue.byVariableName(_:string:) to create them.

How to get the card template ID

On the card editing page, click on the ID part of the overflow menu at the upper-right corner.

Card template ID

Filter operators

The operator is the operational logic applied to a filter value (some operators require 2 or more values).

The following table outlines available operators.

OperatorDescriptionSupported types
equalToEqual to the filter valueInt, Date, String, Bool
notEqualToNot equal to the filter valueInt, Date, String, Bool
greaterThanGreater than the filter valueInt, Date
greaterThanOrEqualToGreater than or equal to the filter valueInt, Date
lessThanLess than the filter valueInt, Date
lessThanOrEqualToLess than or equal to the filter valueInt, Date
inIn one of the filter valuesInt, Date, String
notInNot in one of the filter valuesInt, Date, String
betweenIn the range of start and end, inclusiveInt, Date

After creating a filter value, use the corresponding static method on the AACCardListFilter type to combine it with an operator.

Examples

Card priority range

The following code snippet shows how to create a filter that filters cards with priority between 2 and 6 inclusive.

let filterValue1 = AACCardFilterValue.byPriority(2)
let filterValue2 = AACCardFilterValue.byPriority(6)
let filter = AACCardListFilter.filter(byCardsBetweenStartValue: filterValue1, endValue: filterValue2)
Passing correct value type to an operator

Each operator supports different types of values. For example, operator lessThan only supports Int and Date. Passing string values to that operator will raise an exception.

Applying filters to a stream container or a card count observer

There are three steps to filter cards in a stream container or for a card count observer:

  1. Create one or more AACCardFilterValue objects.

  2. Combine filter values with filter operators to form a AACCardFilter.

  3. Apply filter(s).

    3.1. For stream containers and single card views, set ContainerConfiguration.filters to an array of filters. To delete all existing filters, pass either nil or an empty list [] to ContainerConfiguration.filters.

    3.2. For card count observers, pass an array of filters to the filters parameter when creating an observer using AACSession.observeCardCountForStreamContainer(withIdentifier:interval:filters:handler:).

Examples

Card priority 5 and above

The following code snippet shows how to only display cards with priority > 5 in a stream container.

let filterValue = AACCardFilterValue.byPriority(5)
let filter = AACCardListFilter.filter(byCardsGreaterThan: filterValue)

var configuration = ContainerConfiguration()
configuration.filters = [filter]

StreamContainer(containerId: "<stream container id>", configuration: configuration)
Earlier than a set date

The following code snippet shows how to only display cards created earlier than 9/Jan/2023 inclusive in a stream container.

let filterValue = AACCardFilterValue.byCreatedDate(Date(timeIntervalSince1970: 1673226000))
let filter = AACCardListFilter.filter(byCardsLessThanOrEqualTo: filterValue)

var configuration = ContainerConfiguration()
configuration.filters = [filter]

StreamContainer(containerId: "<stream container id>", configuration: configuration)
Card template names

The following code snippet shows how to only display cards with the template names "card1", "card2", or "card3" in a stream container.

let filterValue1 = AACCardFilterValue.byCardTemplateName("card1")
let filterValue2 = AACCardFilterValue.byCardTemplateName("card2")
let filterValue3 = AACCardFilterValue.byCardTemplateName("card3")
let filter = AACCardListFilter.filter(byCardsIn: [filterValue1, filterValue2, filterValue3])

var configuration = ContainerConfiguration()
configuration.filters = [filter]

StreamContainer(containerId: "<stream container id>", configuration: configuration)
Combination of filter values

The following code snippet shows how to only display cards with priority != 6 and custom variable isSpecial == true in a stream container.

Note: isSpecial is a Boolean custom variable defined in Workbench.

let filterValue1 = AACCardFilterValue.byPriority(6)
let filter1 = AACCardListFilter.filter(byCardsNotEqualTo: filterValue1)

let filterValue2 = AACCardFilterValue.byVariableName("isSpecial", boolean: true)
let filter2 = AACCardListFilter.filter(byCardsEqualTo: filterValue2)

var configuration = ContainerConfiguration()
configuration.filters = [filter1, filter2]

StreamContainer(containerId: "<stream container id>", configuration: configuration)

Legacy filter

The legacy filter is still supported - AACCardListFilter.filter(byCardInstanceId:). This filter requests that the stream container or single card view show only a card matching the specified card instance ID, if it exists. An instance of this filter can be created using the corresponding static method on AACCardListFilter.

The card instance ID can be found in the push notification payload, allowing you to apply the filter in response to a push notification being tapped.

let filter = AACCardListFilter.filter(byCardInstanceId: "ABCD-1234")

var configuration = ContainerConfiguration()
configuration.filters = [filter]

SingleCardContainer(containerId: "<stream container id>", configuration: configuration)

Removing all filters

  • For stream containers and single card views, set ContainerConfiguration.filters to nil or an empty list [].
  • For card count observers, create a new card count observer with nil passed to the filters parameter.

Supporting custom actions on buttons and images

In the Atomic Workbench, you can create submit buttons, link buttons, and images with custom action payloads.

  • When a link button is tapped, the container triggers the .linkButtonTapped(cardInstanceId:payload:) event via ContainerConfiguration.onEvent.
  • When a submit button is tapped and the card is successfully submitted, the container triggers the .submitButtonTappedWithName(cardInstanceId:buttonName:payload:) event via ContainerConfiguration.onEvent.
  • When an image is tapped, the container triggers the .imageTapped(cardInstanceId:payload:) event via ContainerConfiguration.onEvent.

The payload in each event contains the custom JSON payload defined in the Workbench. You can use this payload to determine the action to take within your app. The events also include the card instance ID, and the submit event includes the button name.

var config = ContainerConfiguration()
config.onEvent = { event in
switch event {
case .linkButtonTapped(_, let payload):
if let screenName = payload["screen"] as? String, screenName == "home-screen" {
// Perform an action.
}
case .submitButtonTappedWithName(_, let buttonName, let payload):
if buttonName == "primary",
let outcome = payload["outcome"] as? String,
outcome == "success" {
// Perform an action.
}
case .imageTapped(_, let payload):
if let screenName = payload["screen"] as? String, screenName == "home-screen" {
// Perform an action.
}
default:
break
}
}

Customizing toast messages for card events

You can customize any of the toast messages used when dismissing, completing, snoozing, and placing feedback on a card. This is configurable for each stream container. Supply a string for each custom message. If you do not supply a string, the defaults will be used. See the Custom strings section to read more about other custom strings that can be specified in the configuration object using the setCustomValue(_:for:) method.

Options are:

  • .toastCardDismissedMessage: Customized toast message for when the user dismisses a card - defaults to "Card dismissed".
  • .toastCardCompletedMessage: Customized toast message for when the user completes a card - defaults to "Card completed".
  • .toastCardSnoozeMessage: Customized toast message for when the user snoozes a card - defaults to "Snoozed until X" where X is the time the user dismissed the card until.
  • .toastCardFeedbackMessage: Customized toast message for when the user sends feedback (votes) for a card - defaults to "Feedback received".
  • .toastFileUploadFailedMessage: Customized toast message shown when file(s) fail to upload during card submission. Defaults to "Couldn't upload file(s)".
  • .requestCameraAccessMessage: Customized toast message shown when requesting camera access permission from the user. Defaults to "Access to your camera is required to take photos. Please enable camera access in your device settings".
  • .requestCameraAccessSettingsTitle: The title for the button in the toast message prompting for camera access permission, which navigates to the Settings app. Defaults to "Settings".

Card snoozing

The Atomic SDK provides the ability to snooze a card from a stream container or single card view. Snooze functionality is exposed through the card’s action buttons, overflow menu, and the quick actions menu (exposed by swiping a card to the left on iOS and Android).

Tapping on the snooze option from either location brings up the snooze date and time selection screen. The user selects a date and time in the future until which the card will be snoozed. Snoozing a card results in the card disappearing from the user’s card list or single card view, and reappearing at the selected date and time. A user can snooze a card more than once.

When a card comes out of a snoozed state and has an associated push notification, and the user has push notifications enabled, the user will see another notification with the title prefixed with Snoozed:.

You can customize the title of the snooze functionality, as displayed in a card’s overflow menu and in the title of the card snooze screen. The default title, if none is specified, is Remind me.

On the ContainerConfiguration object, call the setCustomValue(_:for:) method to customize the title for the card snooze functionality:

config.setCustomValue(snoozeTitle, for: .snoozeTitle)

Prevent snoozing beyond expiry date

Selecting a date and time combination beyond a card's expiry date is prevented when snoozing a card using the SDK's built-in interface.

There are three ways to snooze a card in the Atomic SDK:

  1. Overflow menu: Select the overflow menu item (default "Remind me"), then choose a date and time in the built-in selector.
  2. Snooze button: Tap a snooze button on the card and select a date and time via the built-in selector.
  3. Preset snooze button: Tap a snooze button with a preset snooze period, which snoozes the card immediately without showing the selector.

If an expiry date is set on the card, you cannot select dates beyond this expiry in scenarios 1 and 2. Scenario 3 remains unaffected, as the snooze period is explicitly preconfigured in Workbench.

Card voting

SDK-side card voting configuration is deprecated in the Atomic SwiftUI SDK and will be removed in a future release. Use the card voting menu configuration in the Atomic Workbench instead.

Responding to card events

The SDK allows you to perform custom actions in response to events occurring on a card, such as when a user:

  • submits a card;
  • dismisses a card;
  • snoozes a card;
  • indicates a card is useful (when card voting is enabled);
  • indicates a card is not useful (when card voting is enabled);
  • tries to use the camera but camera usage has been denied or restricted.

To be notified when these happen, assign an event handler to your container configuration:

// 1. Assign the event handler
var config = ContainerConfiguration()
config.onEvent = { event in
// Perform a custom action in response to the card event.
}

Sending custom events

You can send custom events directly to the Atomic Platform for the logged-in user via the AACSession.send(_:) method.

A custom event can be used in the Workbench to create segments for card targeting. For more details of custom events, see Custom Events.

The event will be created for the user defined by the authentication token returned in the session delegate. As such, you cannot specify target user IDs using this method.

// Create the custom event, the properties are optional.
let customEvent = AACCustomEvent(name: "myEvent", properties: ["firstName": "John", "lastName": "Smith"])

do {
try await AACSession.send(customEvent)
// Event successfully sent.
} catch {
// Handle error here.
}

If you need a completion handler, use AACSession.send(_:completionHandler:) instead.

Error handling

If the operation throws, the error domain will be AACSessionSendCustomEventErrorDomain - look for a specific error code in the AACSessionSendCustomEventErrorCode enumeration to determine the cause of the error. NSUnderlyingErrorKey will also be populated in the error's userInfo dictionary.

Push notifications

To use push notifications in the SDK, you'll need to set up your notification preferences and add your iOS push certificate in the Workbench (see: Notifications in the Configuration area), then request push notification permission in your app.

Once this is done, you can configure push notifications via the SDK.

For a working example of integrating push notifications in a native iOS app, see the notifications branch of our boilerplate app.

The steps below can be configured in any order in your app.

1. Register the user against specific stream containers for push notifications

You need to signal to the Atomic Platform which stream containers are eligible to receive push notifications in your app for the current device:

try await AACSession.registerStreamContainers(forPushNotifications: ["1"])

You will need to do this each time the logged-in user changes.

Error handling

If the operation throws, the error domain will be AACSessionPushRegistrationErrorDomain - look for a specific error code in the AACSessionPushRegistrationErrorCode enumeration to determine the cause of the error. NSUnderlyingErrorKey will also be populated in the error's userInfo dictionary.

do {
try await AACSession.registerStreamContainers(forPushNotifications: [containerId])
} catch {
let error = error as NSError
if let errorCode = AACSessionPushRegistrationError.Code(rawValue: error.code) {
switch errorCode {
case .domainDataError:
// Deal with data error.
let dataError = error.userInfo[NSUnderlyingErrorKey] as? NSError
case .codeNetworkError:
// Deal with network error.
let networkError = error.userInfo[NSUnderlyingErrorKey] as? NSError
@unknown default:
// A new type of error is added in the future.
let unknownError = error.userInfo[NSUnderlyingErrorKey] as? NSError
}
}
}

There is another optional version that takes a parameter - notificationsEnabled. This parameter updates the user's notificationsEnabled preference in the Atomic Platform. You can also inspect and update this preference using the Atomic API - consult the Atomic API documentation for user preferences for more information.

If you pass false for this parameter, the user's notificationsEnabled preference will be set to false, which means that they will not receive notifications on any eligible devices, even if their device is registered in this step, and the device push token is passed to Atomic in the next step. If you pass true, the user's notificationsEnabled preference will be set to true, which is the default, and allows the user to receive notifications. This allows you to explicitly enable or disable notifications for the current user, via UI in your own app - such as a notification settings screen.

If you call registerStreamContainers(forPushNotifications:) without supplying the notificationsEnabled parameter, the user's notificationsEnabled preference in the Atomic Platform is not affected.

try await AACSession.registerStreamContainers(forPushNotifications: ["1"], notificationsEnabled: false)

2. Send the push token to the Atomic Platform

Send the device's push token to the Atomic Platform when it changes. In SwiftUI, first register a UIApplicationDelegate using @UIApplicationDelegateAdaptor. Then follow standard push notification registration practice and call UIApplication.shared.registerForRemoteNotifications() to trigger token delivery. Forward the token from application(_:didRegisterForRemoteNotificationsWithDeviceToken:) by calling static AACSession.registerDevice(forNotifications:environment:).

The environment parameter in that method tells the SDK which APNs environment(s) to register the device token against, based on your Workbench notification configuration. Choose the value that matches how your app is set up in Workbench:

  • .sandbox: Registers the token against the APNs sandbox environment. If sandbox notifications are not configured in Workbench, registration fails with an error.
  • .production: Registers the token against the APNs production environment. If production notifications are not configured in Workbench, registration fails with an error.
  • .both: Attempts to register against both sandbox and production. If only one environment exists in Workbench, that one is still registered (the same behavior applies to deregistration). If one of them is missing, the SDK does not return an error for the missing environment.

The example below shows a SwiftUI-friendly setup using @UIApplicationDelegateAdaptor and async/await for registration.

import AtomicSwiftUISDK
import SwiftUI
import UserNotifications

@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(NotificationDelegate.self) var appDelegate

var body: some Scene {
WindowGroup {
ContentView()
}
}
}

final class NotificationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
UNUserNotificationCenter.current().delegate = self
return true
}

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Task {
do {
try await AACSession.registerDevice(forNotifications: deviceToken, environment: .production)
print("✅ Successfully registered the device for push notifications.")
} catch {
// Handle error here.
}
}
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("❌ Failed to register for notifications: \(error)")
}
}

You can also call this SDK method anytime you want to update the push token stored for the user in the Atomic Platform; pass the method the Data instance representing the push token. The token is automatically converted to a hex string by the SDK.

You will also need to update this token every time the logged-in user changes in your app, so the Atomic Platform knows who to send notifications to.

Device token registration and stream container ID registration can occur in either order.

The returned error has an explicit type. If the operation throws, the error domain will be AACSessionPushRegistrationErrorDomain - look for a specific error code in the AACSessionPushRegistrationErrorCode enumeration to determine the cause of the error. NSUnderlyingErrorKey will also be populated in the error's userInfo dictionary.

do {
try await AACSession.registerDevice(forNotifications: deviceToken, environment: .production)
} catch {
let error = error as NSError
if let errorCode = AACSessionPushRegistrationError.Code(rawValue: error.code) {
switch errorCode {
case .domainDataError:
// Deal with data error.
let dataError = error.userInfo[NSUnderlyingErrorKey] as? NSError
case .codeNetworkError:
// Deal with network error.
let networkError = error.userInfo[NSUnderlyingErrorKey] as? NSError
@unknown default:
// A new type of error is added in the future.
let unknownError = error.userInfo[NSUnderlyingErrorKey] as? NSError
}
}
}

To deregister the device for Atomic notifications for your app, such as when a user completely logs out of your app, call AACSession.deregisterDeviceForNotifications(with:). If the deregistration fails, the error domain will be AACSessionPushRegistrationErrorDomain - look for a specific error code in the AACSessionPushRegistrationErrorCode enumeration to determine the cause of the error. NSUnderlyingErrorKey will also be populated in the error's userInfo dictionary.

do {
try await AACSession.deregisterDeviceForNotifications(with: .both)
} catch {
let error = error as NSError
if let errorCode = AACSessionPushRegistrationError.Code(rawValue: error.code) {
switch errorCode {
case .domainDataError:
// Deal with data error.
let dataError = error.userInfo[NSUnderlyingErrorKey] as? NSError
case .codeNetworkError:
// Deal with network error.
let networkError = error.userInfo[NSUnderlyingErrorKey] as? NSError
@unknown default:
// A new type of error is added in the future.
let unknownError = error.userInfo[NSUnderlyingErrorKey] as? NSError
}
}
}

3. (Optional) Perform a custom action when tapping on a push notification

When a user taps on a push notification delivered to your app, you can ask the SDK whether the push notification payload originated from Atomic. If so, you will be provided with a structured object that you can inspect to perform custom actions based on the payload. The custom data for the notification that was sent with the original event to Atomic is exposed by the SDK through the detail property. This detail property shows the value sent in the notificationDetail object as part of the API request payload.

Read more about the shape of this payload in the Sending custom data in push notification payloads guide.

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
if response.actionIdentifier == UNNotificationDefaultActionIdentifier,
let notification = AACSession.notification(fromPushPayload: response.notification.request.content.userInfo) {
// The payload originated from Atomic - use the properties on the object to determine the action to take.
}
}

4. (Optional) Track when push notifications are received

To track when push notifications are delivered to your user's device, you can use a Notification Service Extension.

While the Atomic SDK does not supply this extension, it does supply a method you can call within your own extension to track delivery.

From within your notification service extension's didReceiveNotificationRequest:withContentHandler: method, call AACSession.trackPushNotificationReceived(_:) to track the delivery of a push notification. You must supply the push notification payload provided to your extension (stored in request.content.userInfo).

override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
// It is recommended to use the login method to initialize the SDK.
AACSession.login(withEnvironmentId: "<environmentId>", apiKey: "<apiKey>", sessionDelegate: <Your session delegate>, apiBaseUrl: <API base URL>)

Task {
do {
try await AACSession.trackPushNotificationReceived(request.content.userInfo)
} catch {
// Handle error if needed.
}
contentHandler(request.content)
}
}

This delivery event appears in your card's analytics as a notification-received event, with a timestamp indicating when the event was generated, as well as the card instance ID and stream container ID for the card.

Error handling

If the operation throws, the error domain will be AACSessionPushTrackingErrorDomain.

Error code AACSessionPushTrackingErrorCode.invalidPayload indicates the payload passed to the method is invalid or does not represent an Atomic push notification.

Error code AACSessionPushTrackingErrorCode.failedToSend indicates the event tracking push notification delivery could not be sent to the Atomic Platform. NSUnderlyingErrorKey will also be populated in the error's userInfo dictionary.

5. Background push notifications (Beta)

iOS background push notifications are also supported. See Background notifications for more information.

Troubleshooting push notifications

If you run into issues setting up push notifications, take a look at the Troubleshooting section in the iOS boilerplate app repo.

Retrieving card count

Use user metrics

It is recommended that you use user metrics to retrieve the card count instead. See Retrieving the count of active and unseen cards for more information.

The SDK supports observing the card count for a specific stream container or requesting a one-off count, even when that stream container does not exist in memory.

import AtomicSwiftUISDK

// Observe the card count.
let token = AACSession.observeCardCountForStreamContainer(withIdentifier: "1", interval: 15) { count in
if let count {
print("There are \(count) cards in the container.")
}
}
Task {
if let count = await AACSession.requestCardCountForStreamContainer(withIdentifier: "1") {
print("There are \(count) cards in the container.")
}
}

A completion-handler variant is also available: AACSession.requestCardCountForStreamContainer(withIdentifier:handler:).

If you choose to observe the card count, by default it is updated immediately after the published card number changes. If the WebSocket is not available, the count is updated periodically at the interval you specify. When the card count changes, the handler is called with the new card count, or nil if the card count could not be fetched. The time interval cannot be less than 1 second.

When you want to stop observing the card count, remove the observer using the token returned from the observation call:

AACSession.stopObservingCardCount(token)

When a stream container is present on screen or when the card count is requested, a notification is posted (AACSessionCardCountDidChange) every time the visible card count changes, such as when a card is dismissed or completed. You can observe this notification to get the latest card count:

NotificationCenter.default.addObserver(forName: AACSessionCardCountDidChange, object: nil, queue: .main) { notification in
guard let userInfo = notification.userInfo,
let visibleCards = userInfo[AACSessionCardCountUserInfoKey] as? NSNumber,
let streamContainerId = notification.object as? String else {
return
}

print("*** There are \(visibleCards.intValue) visible cards in stream container \(streamContainerId).")
}

The count of visible cards is available via the AACSessionCardCountUserInfoKey key in the userInfo dictionary, and the notification's object represents the stream container ID. You can listen for the card count of only a particular stream container by specifying that stream container ID in the object parameter when adding an observer.

If you want to retrieve the total number of cards in the container (rather than the number visible), use the AACSessionTotalCardCountUserInfoKey key in the userInfo dictionary.

When not in single card view, AACSessionCardCountUserInfoKey and AACSessionTotalCardCountUserInfoKey report the same value.

Retrieving the count of active and unseen cards

What is an active card? What is an unseen card?

All cards are unseen the moment they are sent. A card becomes "seen" when it has been shown on the customer's screen (even if only briefly or partly). A quick scroll-through might not make the card "seen"; this depends on the scrolling speed. User metrics only count "active" cards, which means snoozed and embargoed cards are not included in the count.

The Atomic SwiftUI SDK exposes a new object: user metrics. These metrics include:

  • The number of cards available to the user across all stream containers.
  • The number of cards that have not been seen across all stream containers.
  • The number of cards available to the user in a specific stream container (equivalent to the card count functionality in the previous section).
  • The number of cards not yet seen by the user in a specific stream container.

These metrics enable you to display badges in your UI that indicate how many cards are available to the user but not yet viewed, or the total number of cards available to the user.

The async method throws if user metrics cannot be retrieved. A completion-handler variant is also available: AACSession.userMetrics(completionHandler:). The completion handler returns nil if user metrics cannot be retrieved.

import AtomicSwiftUISDK

Task {
do {
let userMetrics = try await AACSession.userMetrics()
print("Total cards across all containers: \(userMetrics.totalCards())")
print("Total cards across a specific container: \(userMetrics.totalCardsForStreamContainer(withId: "containerId"))")

print("Unseen cards across all containers: \(userMetrics.unseenCards())")
print("Unseen cards across a specific container: \(userMetrics.unseenCardsForStreamContainer(withId: "containerId"))")
} catch {
// Handle error if needed.
}
}

Error handling

If the operation throws, the error domain will be AACSessionUserMetricsErrorDomain - look for a specific error code in the AACSessionUserMetricsErrorCode enumeration to determine the cause of the error. NSUnderlyingErrorKey will also be populated in the error's userInfo dictionary.

Runtime variables

Runtime variables are resolved in the SDK at runtime, rather than from an event payload when the card is assembled. Runtime variables are defined in the Atomic Workbench.

The SDK will ask the host app to resolve runtime variables when a list of cards is loaded (and at least one card has a runtime variable), or when new cards become available due to WebSockets pushing or HTTP polling (and at least one card has a runtime variable).

Runtime variables are resolved by your app via the onRuntimeVariablesRequested callback on ContainerConfiguration. If this callback is not set, runtime variables fall back to their default values, as defined in the Atomic Workbench. To resolve runtime variables, pass the closure when creating a StreamContainer, SingleCardContainer, or LazySingleCardContainer.

Only string values

Runtime variables can currently only be resolved to string values.

The onRuntimeVariablesRequested closure, when called by the SDK, provides you with:

  • An array of Card values representing the cards in the list that contain runtime variables.
  • Each Card includes:
    • instanceId for identifying the card instance.
    • runtimeVariables with each variable's name and defaultValue.
    • A mutating method you call to resolve each variable (resolveRuntimeVariable(name:value:)).

If a variable is not resolved, that variable uses its default value, as defined in the Atomic Workbench.

If you do not return the resolved cards before runtimeVariableResolutionTimeout elapses (defined on ContainerConfiguration), the default values for all runtime variables are used.

import AtomicSwiftUISDK

var config = ContainerConfiguration()
config.onRuntimeVariablesRequested = { cardsToResolve in
let numberOfItems = await fetchNumberOfItems()

var resolvedCards = cardsToResolve
for index in resolvedCards.indices {
// Resolve variables on all cards.
// You can also inspect instanceId and runtimeVariables to determine what type of card this is.
resolvedCards[index].resolveRuntimeVariable(name: "numberOfItems", value: "\(numberOfItems)")
}

return resolvedCards
}

Updating runtime variables manually

The SwiftUI SDK requests runtime variables automatically when cards load or refresh. There is no public API to manually trigger runtime variable resolution.

Accessibility and fonts

The Atomic SDKs support a variety of accessibility features on each platform. These features make it easier for vision-impaired customers to use Atomic's SDKs inside your app.

These features also allow your app, with Atomic integrated, to continue to fulfill your wider accessibility requirements.

Dynamic Type

The Atomic SwiftUI SDK supports dynamic font scaling. On iOS, this feature is called Dynamic Type.

To opt-in to Dynamic Type for a typography style:

  1. Open the Atomic Workbench;
  2. Navigate to Configuration > SDK > Container themes. Select the theme you want to edit.
  3. When editing a typography style in the theme (e.g. Headline > Text), turn Enable dynamic scaling... on.
  4. Optionally specify a minimum and maximum font size to use when scaling is applied.

The SDK automatically scales the typography style from the base font size that you specify, adding or subtracting a pixel value from this base font size, to create a size tailored to each Dynamic Type level. The minimum font size is 1px; this is enforced by the SDK.

The pixel value that is added or subtracted for each Dynamic Type level is determined inside the SDK and is not customizable.

info

Typography styles that do not have 'Dynamic scaling' enabled stay at a fixed size regardless of the user's text size (this is the default behavior for all typography styles).

Font sizing behavior

When the user changes their system-wide text size on iOS, the SDK will automatically re-render any components containing typography styles that opt-in to dynamic scaling (typography styles that have the 'Dynamic scaling' option enabled in the stream container theme).

  • On iOS, the font size in the typography style has a delta value added to, or subtracted from, it using the following values. User-selected sizes correspond to the values defined in UIContentSizeCategory:
User-selected sizeDelta applied
XS-3
S-2
M-1
L (default)0
XL2
XXL4
XXXL6
Accessibility M10
Accessibility L15
Accessibility XL20
Accessibility XXL25
Accessibility XXXL30

Once these delta values (iOS) are applied, the minimum and maximum scaling sizes are applied to the resultant font size. See the example below for a demonstration of how this works.

Example scaling scenario

A typography style is configured in the Workbench with the following properties:

  • Font size: 17px
  • Dynamic scaling: On
  • Dynamic scaling minimum size: 15px
  • Dynamic scaling maximum size: 30px

On iOS, the following font sizes would be used for each content size category:

User-selected sizeResultant size
XS15px (14px is below min)
S15px (at or above min)
M16px
L (default)17px
XL19px
XXL21px
XXXL23px
Accessibility M27px
Accessibility L30px (32px is above max)
Accessibility XL30px (37px is above max)
Accessibility XXL30px (42px is above max)
Accessibility XXXL30px (47px is above max)

Using embedded fonts in themes

When creating your stream container's theme in the Atomic Workbench, you optionally define custom fonts that can be used by the stream container for UI elements. When defined in the Workbench, these fonts must point to a remote URL, so the SDK can download the font, register it with the system, and use it.

It is likely that the custom fonts you wish to use are already part of your app, particularly if they are a critical component of your brand identity. If this is the case, you can have a stream container font, with a given font family name, weight, and style, reference a font embedded in your app instead. This is also useful if the license for your font only permits you to embed the font and not download it from a remote URL.

To map a font in a stream container theme to one embedded in your app, use AACSession.register(_:), passing an array of AACEmbeddedFont objects, each containing the following:

  • A familyName that matches the font family name declared in the Atomic Workbench.
  • A weight value of AACFontWeight, also matching the value declared in the Atomic Workbench.
  • A style of AACFontStyle, either italic or normal.
  • A postscriptName, which matches the Postscript name of a font available to your app. This can be a font bundled with your application or one provided by the operating system.

If the familyName, weight, and style of a font in the stream container theme matches an AACEmbeddedFont instance that you registered with the SDK, the SDK will use the postscriptName to create an instance of your embedded font and will not download the font from a remote URL.

In the example below, any use of a custom font named BrandFont in your theme, that is bold and italicized, would use the embedded font named HelveticaNeue instead:

Invalid Postscript names

If the Postscript name provided is invalid, or the family name, weight, and style do not match a custom font in the stream container theme exactly, the SDK will download the font at the remote URL specified in the theme instead.

AACSession.register([
AACEmbeddedFont(familyName: "BrandFont",
postscriptName: "HelveticaNeue",
weight: .weightBold,
style: .italic)
])

SDK Analytics

Default behavior

The default behavior is to not send analytics for resolved runtime variables. Therefore, you must explicitly enable this feature to use it.

If you use runtime variables on a card, you can optionally choose to send the resolved values of any runtime variables back to the Atomic Platform as an analytics event. This per-card analytics event - runtime-vars-updated - contains the values of runtime variables rendered in the card and seen by the end user. Therefore, you should not enable this feature if your runtime variables contain sensitive data that you do not wish to store on the Atomic Platform.

To enable this feature, set sendRuntimeVariableAnalytics on your ContainerConfiguration:

var config = ContainerConfiguration()
config.sendRuntimeVariableAnalytics = true

Network request security

The Atomic SwiftUI SDK provides functionality to further secure the SDK implementation in your app.

Allow or deny network requests

You can choose to implement a request delegate, which determines whether requests originating from the Atomic SDK are allowed to proceed. This enables you to permit network requests only to domains or subdomains that you approve.

The request delegate is called before every network request in the SDK, and during SSL certificate validation.

To enable this functionality, create a request delegate conforming to the AACRequestDelegate protocol, implement the disposition(forAtomicRequest:) method, and call AACSession.setRequestDelegate(_:) to assign it to the SDK. The request delegate is used for every network request from the SDK.

Within the disposition(forAtomicRequest:) method, you can inspect the request URL and return one of the following dispositions:

  • allow(): The request is allowed to proceed.
  • deny(): The request is not allowed to proceed and will be canceled.
  • allow(with:): The request can proceed if a hash of the subject public key info (SPKI) from part of the certificate chain matches one of the pin objects provided (see below for more information).

All of these dispositions are available as static methods on AACRequestDisposition.

If you do not implement this request delegate, all requests are permitted. Specifically, if you deny the request for connecting to the WebSockets server, the SDK will try to use HTTP polling instead.

WebSockets requests

The URL of WebSockets follows a pattern like wss://[url]/socket. If the allow(with:) disposition is used with this URL, the request will be denied.

Allow requests with certificate pinning

When you return allow(with:) in the AACRequestDelegate above, you will be required to supply a set of AACCertificatePin objects. Each object contains a SHA-256 hash of the subject public key info (SPKI) from part of the domain's certificate chain, which is then base64 encoded.

Pin requests to all available Amazon root certificate

When pinning requests for the Atomic API (<orgId>.client-api.atomic.io), we strongly recommend pinning to all available Amazon root certificates, which is the approach recommended by Amazon Web Services. SHA256 hashes for Amazon's root certificates are available on the Amazon Trust website.

You can convert these SHA256 hashes to a base64 representation, as required by the SDK, using the following Terminal command: openssl base64 -e <<< "<SHA256 hash>"

If the hashed, base64-encoded SPKI from part of the request's certificate chain matches any of the provided pin objects, the request is allowed to proceed. If it does not match any of the provided pins, the request is denied.

If the allow(with:) disposition is used with a non-HTTPS URL, the request will be denied.

final class RequestDelegate: AACRequestDelegate {
func disposition(forAtomicRequest requestUrl: URL) -> AACRequestDisposition {
guard let host = requestUrl.host else {
// Deny requests without a host.
return .deny()
}

switch host {
case "atomic.io":
// Allow requests to atomic.io with certificate pinning.
let pins = Set([
AACCertificatePin(sha256Hash: "AAAAAA=")
])
return .allow(with: pins)
case "placeholder.com":
// Always allow requests to placeholder.com.
return .allow()
default:
// Deny all other requests.
return .deny()
}
}
}

AACSession.setRequestDelegate(RequestDelegate())

Updating user data

The SDK allows you to update profile and preference data for the logged-in user via the AACSession.updateUser(_:) method. This is the user as identified by the auth token provided by the authentication callback.

Setting up profile fields

For simple setup, create an AACUserSettings object and set some profile fields, then call AACSession.updateUser(_:). The following optional profile fields can be supplied to update the data for the user. Any fields that you do not supply remain unmodified after the user update.

  • externalID: An optional string that represents an external identifier for the user.
  • name: An optional string that represents the name of the user.
  • email: An optional string that represents the email address of the user.
  • phone: An optional string that represents the phone number of the user.
  • city: An optional string that represents the city of the user.
  • country: An optional string that represents the country of the user.
  • region: An optional string that represents the region of the user.

The following code snippet shows how to set up some profile fields.

import AtomicSwiftUISDK

let settings = AACUserSettings()
// Set up some basic profile fields.
settings.externalID = "an external ID"
settings.name = "John Smith"

Task {
do {
try await AACSession.updateUser(settings)
} catch {
// Handle error here.
}
}

If you need a completion handler, use AACSession.updateUser(_:completionHandler:) instead.

Setting up custom profile fields

You can also set up your custom fields of the user profile. Custom fields must first be created in Atomic Workbench before updating them. For more details of custom fields, see Custom Fields.

There are two types of custom fields: date and text. Use setDate(_:forCustomField:) for date fields and setText(_:forCustomField:) for text fields.

Note: Use the name property in the Workbench to identify a custom field, not the label property.

The following code snippet shows how to set up a date and a text field.

let settings = AACUserSettings()
settings.setDate(Date(timeIntervalSinceNow: 0), forCustomField: "custom_date_field")
settings.setText("some custom text", forCustomField: "custom_text_field")

Setting up notification preferences

You can use the following optional property and method to update the notification preferences for the user. Again, any fields that you do not supply remain unmodified after the user update.

  • notificationsEnabled: A boolean that determines whether notifications are enabled. The default notification setting of a user is true. Only set this when you want to update the preference.
  • setNotificationTime(_:weekday:): An optional method that defines the notification time preferences of the user for different days of the week. If you specify .default for the weekday parameter, the notification time preferences apply to every day.

Each day accepts an array of notification time periods. These are periods during which notifications are allowed. If an empty array is provided notifications will be disabled for that day.

The following code snippet shows how to set up notification periods from 8 AM to 5:30 PM and 7 PM to 10 PM on Monday.

let settings = AACUserSettings()
let timeframes = [AACUserNotificationTimeframe(startHour: 8,
startMinute: 0,
endHour: 17,
endMinute: 30),
AACUserNotificationTimeframe(startHour: 19,
startMinute: 0,
endHour: 22,
endMinute: 0)]

settings.setNotificationTime(timeframes, weekday: .monday)

Hours are in the 24h format that must be between 0 and 23 inclusive, while minutes are values between 0 and 59 inclusive.

UpdateUser method: full example

The following code snippet shows an example of using the updateUser method to update profile fields, custom profile fields, and notification preferences.

import AtomicSwiftUISDK

let settings = AACUserSettings()
settings.externalID = "123"
settings.name = "User Name"
settings.email = "email@example.com"
settings.phone = "(+64)210001234"
settings.city = "Wellington"
settings.country = "New Zealand"
settings.region = "Wellington"
// Any further custom fields that have already been defined in the Workbench.
settings.setDate(Date(timeIntervalSinceNow: 0), forCustomField: "myCustomDateField")
settings.setText("My custom value", forCustomField: "myCustomTextField")

settings.notificationsEnabled = true

// Set up notification timeframes from 08:30 to 17:45, on all work days.
settings.setNotificationTime([AACUserNotificationTimeframe(startHour: 8,
startMinute: 0,
endHour: 17,
endMinute: 45)],
weekday: .default)
settings.setNotificationTime([], weekday: .saturday)
settings.setNotificationTime([], weekday: .sunday)

Task {
do {
try await AACSession.updateUser(settings)
} catch {
let error = error as NSError
if error.domain == AACSessionUpdateUserError.errorDomain,
let errorCode = AACSessionUpdateUserError.Code(rawValue: error.code) {
switch errorCode {
case .dataError:
// Deal with data error.
let dataError = error.userInfo[NSUnderlyingErrorKey] as? NSError
case .networkError:
// Deal with network error.
let networkError = error.userInfo[NSUnderlyingErrorKey] as? NSError
@unknown default:
// A new type of error is added in the future.
let unknownError = error.userInfo[NSUnderlyingErrorKey] as? NSError
}
}
}
}

If you need a completion handler, use AACSession.updateUser(_:completionHandler:) instead.

Optional values

Though all fields of AACUserSettings are optional, you must supply at least one field when calling AACSession.updateUser(_:).

Error handling

If the operation throws, the error domain will be AACSessionUpdateUserErrorDomain - look for a specific error code in the AACSessionUpdateUserErrorCode enumeration to determine the cause of the error. NSUnderlyingErrorKey will also be populated in the error's userInfo dictionary.

Observing SDK events

The Atomic SwiftUI SDK provides functionality to observe SDK events that symbolize identifiable SDK activities such as card feed changes or user interactions with cards. The following code snippet shows how to observe SDK events.

AACSession.observeSDKEvents { event in
// Do something with the event.
}
info

Only one SDK event observer can be active at a time. If you call this method again, it will replace the previous observer.

The SDK provides all observed events in the base class AACSDKEvent. To access more specific information about a particular event, you can cast it to its corresponding event class. The table below lists all currently supported events, including some that are part of Atomic analytics. For detailed information about these events, please refer to Analytics reference.

Event nameEvent classAnalyticsDescription
card-dismissedAACSDKEventCardDismissedYESThe user dismisses a card.
card-snoozedAACSDKEventCardSnoozedYESThe user snoozes a card.
card-completedAACSDKEventCardCompletedYESThe user submits a card.
card-feed-updatedAACSDKEventCardFeedUpdatedNOA card feed has been updated.
card-displayedAACSDKEventCardDisplayedYESA card is displayed in a container.
card-voted-upAACSDKEventCardVotedUpYESThe user taps on the “This is useful” option.
card-voted-downAACSDKEventCardVotedDownYESThe user taps the “Submit” button on the card feedback screen.
runtime-vars-updatedAACSDKEventRuntimeVarsUpdatedYESOne or more runtime variables are resolved.
stream-displayedAACSDKEventStreamDisplayedYESA stream container is first loaded or returned to.
user-redirectedAACSDKEventUserRedirectedYESThe user is redirected by a URL or a custom payload.
snooze-options-displayedAACSDKEventSnoozeOptionsDisplayedYESThe snooze date/time selection UI is displayed.
snooze-options-canceledAACSDKEventSnoozeOptionsCanceledYESThe user taps the “Cancel” button in the snooze UI.
card-subview-displayedAACSDKEventCardSubviewDisplayedYESA subview of card is opened.
card-subview-exitedAACSDKEventCardSubviewExitedYESThe user leaves the subview.
video-playedAACSDKEventVideoPlayedYESThe user hits the play button of a video.
video-completedAACSDKEventVideoCompletedYESA video finishes playing.
sdk-initializedAACSDKEventSDKInitializedYESAn instance of the SDK is initialized, or the JWT is refreshed.
request-failedAACSDKEventRequestFailedYESAny API request to the Atomic client API fails within the SDK, or a failure in WebSocket causes a fallback to HTTP polling.
Note: Network failure and request timeout does not trigger this event.
notification-receivedAACSDKEventNotificationReceivedYESA push notification is received by the SDK.
user-file-uploads-startedAACSDKEventUserFileUploadsStartedYESThe user has started the process of uploading files for a card, which is normally part of a card submission.
user-file-uploads-completedAACSDKEventUserFileUploadsCompletedYESThe user has successfully completed the process of uploading files for a card.
user-file-uploads-failedAACSDKEventUserFileUploadsFailedYESThe user has completed the process of uploading files, with one or more uploads failing.

Accessing a specific SDK event

To access the unique properties of each event, you need to cast the base class AACSDKEvent passed in the callback to the specific event classes. You can determine the type of the event using either the eventType or eventName property. It is recommended to use eventType as it benefits from the code-completion system.

The following code snippet shows how to retrieve the attributes of a card's subview when it's being displayed.

AACSession.observeSDKEvents { event in
switch event.eventType {
case .cardSubviewDisplayed:
if let event = event as? AACSDKEventCardSubviewDisplayed {
print("The subview \(event.subviewTitle) of card \(event.cardInstanceId) has been displayed.")
}
default:
break
}
}

Accessing the raw contents

You can access the raw value of an event by calling the method getRawContents on the SDK event object. It will return a NSDictionary object containing all the data available to this event. Raw contents are only for debugging purposes and are subject to change. Do not rely on it in production code.

AACSession.observeSDKEvents { event in
let rawData = event.getRawContents()
// Inspect the raw data for more details.
}

Stopping the observation

To stop the observation, call either AACSession.stopObservingSDKEvents() or AACSession.observeSDKEvents(completionHandler: nil).

Metadata pass-through

Any metadata sent from an API request can now be accessed from within the SDK using the SDK Event Observer, once enabled in the Workbench.

info

Currently the metadata is only returned in the card-displayed event.

An example body in an API request to an Action Flow trigger endpoint might look like:

{
"flows": [
{
"payload": {
"metadata": {
"account": "123445",
"fruit": "apple"
}
},
"target": {
"type": "user",
"targetUserIds": "user-123"
}
}
]
}

To retrieve the metadata, capture the card-displayed event in an SDK Event Observer, such as:

AACSession.observeSDKEvents { event in
switch event.eventType {
case .cardDisplayed:
if let event = event as? AACSDKEventCardDisplayed {
print(event.payloadMetadata ?? [:])
}
default:
break
}
}

Any metadata will be populated in the payloadMetadata dictionary property of AACSDKEvent.

More examples

Fetching unseen card number in real time

When your application displays the number of unseen cards on the app icon, it is crucial to ensure that this number stays current as the user navigates through cards. This way, when they return to the home screen, they see an up-to-date count of unseen cards. To make this possible, we must fetch the count of unseen cards in real time.

You can obtain the count of unseen cards from user metrics. However, since this is a singular call, we need to invoke this method repeatedly to keep the count current. By monitoring SDK events, we can update the unseen card count every time a card's viewed status changes. The code snippet below shows how to fetch the number of unseen cards for a container under these conditions.

AACSession.observeSDKEvents { event in
switch event.eventType {
case .cardDisplayed, .cardFeedUpdated:
Task {
do {
let metrics = try await AACSession.userMetrics()
let containerId = "1234"
let unseenCardsForContainer = metrics.unseenCardsForStreamContainer(withId: containerId)
print("The unseen cards for container \(containerId) is \(unseenCardsForContainer)")
} catch {
// Handle error.
}
}
default:
break
}
}

If you need a completion handler, use AACSession.userMetrics(completionHandler:) instead.

Capturing the voting-down event

The following code snippet shows how to capture an event when the user votes down for a card.

AACSession.observeSDKEvents { event in
switch event.eventType {
case .cardVotedDown:
if let event = event as? AACSDKEventCardVotedDown {
print("The user has voted down for the card \(event.cardInstanceId)")
switch event.reason {
case .notRelevant:
print("The reason is it's not relevant.")
case .tooOften:
print("The reason is it's displayed too often.")
case .other:
print("The user provided some other reasons.")
if let message = event.otherMessage {
print("Other reason: \(message)")
}
@unknown default:
print("The reason is unknown.")
}
}
default:
break
}
}

Image linking

You can use images for navigation purposes, such as directing to a web page, opening a subview, or sending a custom payload into the app, as if they were buttons. This functionality is accessible in Workbench, where you can assign custom actions to images on your cards.

Image custom payloads

For custom payload handling when an image is tapped, see Supporting custom actions on buttons and images.

The updated analytics event user-redirected

Redirection initiated by images also triggers the user-redirected analytics event. To accurately identify the origin of this event, access the detail property of this event, which has four distinct values:

  • image: The event was activated by an image.
  • submitButton: The redirection was initiated via a submit button.
  • linkButton: A link button was the source of the event.
  • textLink: The trigger was a link embedded within markdown text.

See Analytics or Analytics reference for more details of the event user-redirected.

In the Atomic SwiftUI SDK, you can capture the detail property via the SDK event observer. The following code snippet shows how to parse this property.

AACSession.observeSDKEvents { event in
if let userRedirectedEvent = event as? AACSDKEventUserRedirected {
switch userRedirectedEvent.detail {
case .image:
print("Event triggered by an image.")
case .linkButton:
print("Event triggered by a link button.")
case .submitButton:
print("Event triggered by a submit button.")
case .textLink:
print("Event triggered by a markdown link text.")
@unknown default:
print("Event triggered by an unknown component.")
}
}
}

See Observing SDK events for more details of SDK event observer.

Custom Icons

See the card element reference for supported SVG features in custom icons.

The SDK supports the use of custom icons in card elements. When you edit a card in the card template editor of the Workbench you will notice that for card elements supporting it, the properties panel will show an "Icon" switch. By turning it on you can select an icon to use, either from the Media Library or Font Awesome.

Choosing to use an icon from the Media Library you have the ability to provide an SVG format icon and an optional fallback icon to be used in case the SVG fails to load. The "Select a file" dropdown will present any SVG format assets in your media library which can be used as a custom icon. To add an icon for use you can click the "Open Media Library" button at the bottom of the dropdown.

Custom icon colors

The Workbench theme editor now provides the ability to set a color and opacity value for icons in each of the places where an icon may be used. The SDK will apply the following rules when determining what color the provided icon should be displayed in:

  • All icons will be displayed with the colors as dictated in the SVG file.
  • Black is used if no colors are specified in the SVG file.
  • Where a currentColor value is used in the SVG file, the following hierarchy is applied:
    1. Use the icon theme color if this has been supplied.
    2. Use the color of the text associated with the icon.
    3. Use a default black color.

Custom icon sizing

The custom icon will be rendered in a square icon container with a width & height in pixels equal to the font size of the associated text. Your supplied SVG icon will be rendered centered inside this icon container, at its true size until it is constrained by the size of container, at which point it will scale down to fit.

Fallback Rules

There are two scenarios where a fallback could occur for an SVG icon:

  1. If the provided SVG image is inaccessible due to a broken URL or network issues, such as those caused by certificate pinning.
  2. If the SVG icon is not supported on iOS. Currently, SVG features are not fully supported in the iOS SDK, so please check with our support team for details on supported SVG images.

In these scenarios, the following fallback rules apply:

  1. The fallback FontAwesome icon is used if it is set in Atomic Workbench for this custom icon.
  2. Otherwise, a default placeholder image is displayed.

Multiple display heights

You can specify different display heights in Atomic Workbench for banner and inline media components. There are four options, each of which defines how the thumbnail cover of that media is displayed.

  • Tall The thumbnail cover is 200 display points high, spanning the whole width and cropped as necessary. This is the default value and matches existing media components.
  • Medium The same as "Tall", but only 120 display points high.
  • Short The same as "Tall" but 50 display points high. Not supported for inline or banner videos.
  • Original The thumbnail cover will maintain its original aspect ratio, adjusting the height dynamically based on the width of the card to avoid any cropping.

File upload

This is currently a beta feature in Workbench.

The File Upload element allows you to add a component to a card through which users can upload a file. Currently, only images can be selected. Multiple updates in the SDK support its customization.

File type restrictions

Currently, only static images can be uploaded; GIFs and non-image file types are not supported. Images in formats other than JPEG and PNG are converted to JPEG before uploading. For Live Photos, only their key photos will be uploaded.

Preparing to use file upload

The File Upload element enables users to upload a picture taken from the camera or selected from the photo library. However, you must set up an NSCameraUsageDescription key in the host app's Info.plist, with a string value explaining to the user how the app uses this data. See Apple's documentation for more details about NSCameraUsageDescription.

Possibility of crash

The app will crash if that key isn't set and the user taps "Take Photo" in the photo pick menu of the File Upload element.

Configuring camera access request toast

If the user initially denies the camera access request, the iOS's built-in request dialog won't appear again due to iOS behavior. By default, when the user tries to take a photo under these circumstances, Atomic SDK will show a toast message at the bottom, prompting them to enable camera access. The user can tap a "Settings" button on the toast to go to the host app's settings page to turn on the camera. Both the prompting message and button title can be customized, see Custom strings for more details.

In the ContainerConfiguration object, a new bitmask value requestCameraUsage is supported by the enabledUIElements property. The toast will display if you either leave this property unset or explicitly include the requestCameraUsage bitmask in it. The toast will not display if you do not include this bitmask value in the property.

The following code snippet shows how to enable the camera usage request toast.

var config = ContainerConfiguration()
// Combine requestCameraUsage with other options.
config.enabledUIElements = [.requestCameraUsage, .cardListToast]

// Or do not assign values to 'enabledUIElements' at all.

The following code snippet shows how to disable the camera usage request toast.

var config = ContainerConfiguration()
// Do not include requestCameraUsage.
config.enabledUIElements = [.cardListHeader, .cardListToast]

Custom strings

The configuration object also allows you to specify custom strings for messages and button titles using the setCustomValue(_:for:) method on the configuration object:

  • processingStateMessage: The message displayed on the upload processing overlay during file upload. Defaults to "Sending, please wait...".
  • processingStateCancelButtonTitle: The text displayed on the cancel button in the upload overlay during file upload. Defaults to "Cancel process".
  • toastFileUploadFailedMessage: Customized toast message shown when file(s) fail to upload during card submission. Defaults to "Couldn't upload file(s)".
  • requestCameraAccessMessage: Customized toast message shown when requesting camera access from the user. Defaults to "Access to your camera is required to take photos. Please enable camera access in your device settings".
  • requestCameraAccessSettingsTitle: The title for the button in the toast message prompting for camera access, which navigates to the Settings app. Defaults to "Settings".

Container events

The SDK allows you to perform custom actions in response to denied camera usage events. To be notified when these occur, assign a closure to the onEvent property of the ContainerConfiguration struct and handle the events within it.

When the user tries to take photo but the camera isn't available, the following two events could occur:

  • cameraDenied: The user has explicitly denied an app permission to capture media. Equivalent to iOS's AVAuthorizationStatusDenied status.
  • cameraRestricted: The app isn’t permitted to use media capture devices. Equivalent to iOS's AVAuthorizationStatusRestricted status.

The following code snippet shows how to capture these events in the host app.

var config = ContainerConfiguration()
config.onEvent = { event in
switch event {
case .cameraDenied(let cardInstanceId), .cameraRestricted(let cardInstanceId):
// Show your own camera access request UI.
default:
break
}
}

SDK events

There are three new event classes related to file upload: AACSDKEventUserFileUploadsStarted, AACSDKEventUserFileUploadsFailed, and AACSDKEventUserFileUploadsCompleted, each containing an array of uploaded file info.

See Observing SDK events for more details of SDK event observer.

The following code snippet captures the started event and prints out the number of files.

import AtomicSwiftUISDK

...

AACSession.observeSDKEvents { event in
switch event.eventType {
case .userFileUploadsStarted:
if let event = event as? AACSDKEventUserFileUploadsStarted {
print("Captured file upload event with \(event.fileInfo.count) files.")
}
default:
break
}
}
Upload cancellation events

Currently, no events are generated when a user cancels an upload. We will add a corresponding SDK event in upcoming versions.

Utility methods

Debug logging

Debug logging lets you view verbose SDK logs during integration. It is turned off by default and should stay off in release builds.

AACSession.enableDebugMode(1)

The level parameter controls log verbosity:

  • Level 0: Default, no logs exposed.
  • Level 1: Operations and transactions are exposed.
  • Level 2: Operations, transactions, and their details are exposed, plus level 1.
  • Level 3: Expose all logs.

Purging cached data

To purge cached data and disable SDK activity, log out the current user. See Logging out the current user for details.

do {
try await AACSession.logout()
} catch {
// Handle the error.
}

If you need a completion handler, use AACSession.logout(_:) or AACSession.logout(withNotificationsDeregistered:completionHandler:) instead.

Setting the version of the client app

The SDK provides AACSession.setClientAppVersion(_:) for setting the current version of your app. An app version is used with analytics to track issues across releases and compare usage across versions.

Version strings longer than 128 characters are trimmed to that length. If you do not call this method, the client app version defaults to unknown.

The following code snippet sets the client app's version to Version 26.2 (17C52).

AACSession.setClientAppVersion("Version 26.2 (17C52)")

Configuring iPhone mute button functionality

Below is an explanation for making your app play sound even if the iPhone's mute switch on the device is enabled. This involves overriding the system's mute behavior by configuring the audio session appropriately. You can do this inside your App initializer, an UIApplicationDelegate adaptor, or a relevant view/controller.

First, import the AVFoundation framework used to configure the audio session:

import AVFoundation

Create a new method for audio session configuration:

private func configureAudioSession() {
do {
let audioSession = AVAudioSession.sharedInstance()

// Set the audio session category to allow playback even when muted.
try audioSession.setCategory(.playback, options: [.mixWithOthers])

// Activate the audio session.
try audioSession.setActive(true)

print("Audio session configured successfully.")
} catch {
print("Failed to configure audio session: \(error.localizedDescription)")
}
}

Then, call this method at app launch, such as from an app initializer:

import SwiftUI

@main
struct MyApp: App {
init() {
// For overriding the mute switch behavior of the whole app.
configureAudioSession()
}

var body: some Scene {
WindowGroup {
ContentView()
}
}
}

Third-party dependencies

Atomic iOS SwiftUI SDK contains four third-party dependencies:

  • Font Awesome Free 5.15.4, an icon font that is used for card icons. This is distributed as an OTF font.
  • TrustKit 1.6.5, used to implement public key certificate pinning.
  • SocketRocket 0.6.0, used to support WebSocket.
  • SwiftDraw 0.18.0, used to parse SVG images.

Atomic iOS SwiftUI SDK does not use any dependency managers. All dependencies are integrated manually.