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
- Open your Xcode project, and choose File > Add Package Dependencies.
- Enter
https://github.com/atomic-app/action-cards-swiftui-sdk-releasesin the upper-right text field labeled "Search or Enter Package URL". - Set the dependency rule and click 'Add Package'.
- Add both
AtomicSDKandAtomicSwiftUISDKto your target.
CocoaPods
- 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'
- Add the SDK as a dependency.
pod 'AtomicCards', '25.4.0'
- 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
- You can download releases of the SDK from the Releases page on GitHub.
- Once you've downloaded the version you need, navigate to your project in Xcode and select the "General" settings tab.
- Drag both
AtomicSDK.xcframeworkandAtomicSwiftUISDK.xcframeworkfrom the directory where you unzipped the release, to theEmbedded Binariessection. - 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 AtomicSDKandimport AtomicSwiftUISDKin the same file, removeimport AtomicSDKand 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:
- In the Workbench, click the configuration icon in the left-hand sidebar;
- 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.
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:
- By adding the following to your app's
Info.plistfile under the keyAACRequestBaseURL, replacingSDK_API_BASE_URLwith your URL:
<key>AACRequestBaseURL</key>
<string>SDK_API_BASE_URL</string>
- By declaring your API base URL in code, replacing
SDK_API_BASE_URLwith 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.
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 totrueto 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.withoutButtonand.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 variantStreamContainer.
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:.snapon iOS 17 and later;.freeon 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,0disables 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 theruntime-vars-updatedanalytics 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, ornilto 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:
- Stream container ID: Identifier for the stream container you wish to display.
- 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:
- Stream container ID: The identifier for the stream you wish to display.
- Card width: The width of each card displayed (must be greater than 0).
- 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:
- 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.
- 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:
- Stream container ID: Identifier for the stream container you wish to display.
- 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.
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
200display points is not recommended, as it may trigger layout constraint warnings when content cannot fit. -
Negative values behave the same as
0display 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).
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
0to disable polling, or1or more to set the interval in seconds. - filters: Optional filters applied when fetching cards, or
nilfor no filters. Filters come from static methods onAACCardListFilter. - 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-updatedanalytics event should be sent after resolving runtime variables. Defaults tofalse.
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:
-
Create a card action object: Use the corresponding initialization methods of the
AACSessionCardActionstruct. 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. -
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.
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 attribute | Description | Value type |
|---|---|---|
| Priority | Card priority defined in Workbench, Card -> Delivery | Int |
| Card instance created date | The date and time when a card instance is created | Date |
| Card template ID | The template ID of a card, see below for how to get it | String |
| Card template name | The template name of a card | String |
| Custom variable | The variables defined for a card in Workbench, Card -> Variables | Multiple |
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.
On the card editing page, click on the ID part of the overflow menu at the upper-right corner.

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.
| Operator | Description | Supported types |
|---|---|---|
| equalTo | Equal to the filter value | Int, Date, String, Bool |
| notEqualTo | Not equal to the filter value | Int, Date, String, Bool |
| greaterThan | Greater than the filter value | Int, Date |
| greaterThanOrEqualTo | Greater than or equal to the filter value | Int, Date |
| lessThan | Less than the filter value | Int, Date |
| lessThanOrEqualTo | Less than or equal to the filter value | Int, Date |
| in | In one of the filter values | Int, Date, String |
| notIn | Not in one of the filter values | Int, Date, String |
| between | In the range of start and end, inclusive | Int, 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)
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:
-
Create one or more
AACCardFilterValueobjects. -
Combine filter values with filter operators to form a
AACCardFilter. -
Apply filter(s).
3.1. For stream containers and single card views, set
ContainerConfiguration.filtersto an array of filters. To delete all existing filters, pass eithernilor an empty list[]toContainerConfiguration.filters.3.2. For card count observers, pass an array of filters to the
filtersparameter when creating an observer usingAACSession.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.filterstonilor an empty list[]. - For card count observers, create a new card count observer with
nilpassed to thefiltersparameter.
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 viaContainerConfiguration.onEvent. - When a submit button is tapped and the card is successfully submitted, the container triggers the
.submitButtonTappedWithName(cardInstanceId:buttonName:payload:)event viaContainerConfiguration.onEvent. - When an image is tapped, the container triggers the
.imageTapped(cardInstanceId:payload:)event viaContainerConfiguration.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:
- Overflow menu: Select the overflow menu item (default "Remind me"), then choose a date and time in the built-in selector.
- Snooze button: Tap a snooze button on the card and select a date and time via the built-in selector.
- 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
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
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.
Runtime variables can currently only be resolved to string values.
The onRuntimeVariablesRequested closure, when called by the SDK, provides you with:
- An array of
Cardvalues representing the cards in the list that contain runtime variables. - Each
Cardincludes:instanceIdfor identifying the card instance.runtimeVariableswith each variable'snameanddefaultValue.- 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:
- Open the Atomic Workbench;
- Navigate to Configuration > SDK > Container themes. Select the theme you want to edit.
- When editing a typography style in the theme (e.g. Headline > Text), turn
Enable dynamic scaling...on. - 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.
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 size | Delta applied |
|---|---|
| XS | -3 |
| S | -2 |
| M | -1 |
| L (default) | 0 |
| XL | 2 |
| XXL | 4 |
| XXXL | 6 |
| Accessibility M | 10 |
| Accessibility L | 15 |
| Accessibility XL | 20 |
| Accessibility XXL | 25 |
| Accessibility XXXL | 30 |
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 size | Resultant size |
|---|---|
| XS | 15px (14px is below min) |
| S | 15px (at or above min) |
| M | 16px |
| L (default) | 17px |
| XL | 19px |
| XXL | 21px |
| XXXL | 23px |
| Accessibility M | 27px |
| Accessibility L | 30px (32px is above max) |
| Accessibility XL | 30px (37px is above max) |
| Accessibility XXL | 30px (42px is above max) |
| Accessibility XXXL | 30px (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
familyNamethat matches the font family name declared in the Atomic Workbench. - A
weightvalue ofAACFontWeight, also matching the value declared in the Atomic Workbench. - A
styleofAACFontStyle, eitheritalicornormal. - 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:
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
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.
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.
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 istrue. 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.defaultfor theweekdayparameter, 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.
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.
}
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 name | Event class | Analytics | Description |
|---|---|---|---|
| card-dismissed | AACSDKEventCardDismissed | YES | The user dismisses a card. |
| card-snoozed | AACSDKEventCardSnoozed | YES | The user snoozes a card. |
| card-completed | AACSDKEventCardCompleted | YES | The user submits a card. |
| card-feed-updated | AACSDKEventCardFeedUpdated | NO | A card feed has been updated. |
| card-displayed | AACSDKEventCardDisplayed | YES | A card is displayed in a container. |
| card-voted-up | AACSDKEventCardVotedUp | YES | The user taps on the “This is useful” option. |
| card-voted-down | AACSDKEventCardVotedDown | YES | The user taps the “Submit” button on the card feedback screen. |
| runtime-vars-updated | AACSDKEventRuntimeVarsUpdated | YES | One or more runtime variables are resolved. |
| stream-displayed | AACSDKEventStreamDisplayed | YES | A stream container is first loaded or returned to. |
| user-redirected | AACSDKEventUserRedirected | YES | The user is redirected by a URL or a custom payload. |
| snooze-options-displayed | AACSDKEventSnoozeOptionsDisplayed | YES | The snooze date/time selection UI is displayed. |
| snooze-options-canceled | AACSDKEventSnoozeOptionsCanceled | YES | The user taps the “Cancel” button in the snooze UI. |
| card-subview-displayed | AACSDKEventCardSubviewDisplayed | YES | A subview of card is opened. |
| card-subview-exited | AACSDKEventCardSubviewExited | YES | The user leaves the subview. |
| video-played | AACSDKEventVideoPlayed | YES | The user hits the play button of a video. |
| video-completed | AACSDKEventVideoCompleted | YES | A video finishes playing. |
| sdk-initialized | AACSDKEventSDKInitialized | YES | An instance of the SDK is initialized, or the JWT is refreshed. |
| request-failed | AACSDKEventRequestFailed | YES | Any 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-received | AACSDKEventNotificationReceived | YES | A push notification is received by the SDK. |
| user-file-uploads-started | AACSDKEventUserFileUploadsStarted | YES | The user has started the process of uploading files for a card, which is normally part of a card submission. |
| user-file-uploads-completed | AACSDKEventUserFileUploadsCompleted | YES | The user has successfully completed the process of uploading files for a card. |
| user-file-uploads-failed | AACSDKEventUserFileUploadsFailed | YES | The 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.
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.
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
currentColorvalue is used in the SVG file, the following hierarchy is applied:- Use the icon theme color if this has been supplied.
- Use the color of the text associated with the icon.
- 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:
- If the provided SVG image is inaccessible due to a broken URL or network issues, such as those caused by certificate pinning.
- 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:
- The fallback FontAwesome icon is used if it is set in Atomic Workbench for this custom icon.
- 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.
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.
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
}
}
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.