#
SDK Gravity Field (iOS)
Gravity Field SDK (iOS) — клиентская библиотека для интеграции iOS-приложений с платформой персонализации и A/B-тестирования Gravity Field.
SDK позволяет:
- передавать просмотры экранов и действия пользователя в Gravity Field;
- получать контент кампаний по
selector; - автоматически показывать in-app кампании поддерживаемых форматов;
- вручную отправлять content/product engagement для manual rendering сценариев;
- использовать собственный
ProductViewBuilderдля карточек товаров в product-based in-app кампаниях.
Документация предназначена для iOS-разработчиков, которые интегрируют SDK в приложение и хотят пройти базовый сценарий интеграции без обращения в поддержку.
Репозиторий Gravity Field SDK для iOS
#
Установка
SDK подключается через Swift Package Manager.
Минимальная версия платформы: iOS 14.
- В Xcode откройте
File -> Add Package Dependencies.... - Укажите URL репозитория с iOS SDK.
- Выберите продукт
GravitySDKи добавьте его в target приложения. - Импортируйте SDK в коде:
import GravitySDK
#
Быстрый старт
#
Шаг 1. Инициализация SDK
Инициализируйте SDK при старте приложения, обычно в App или AppDelegate.
import GravitySDK
import SwiftUI
@main
struct MyApp: App {
init() {
GravitySDK.initialize(
apiKey: "YOUR_API_KEY",
section: "YOUR_SECTION_ID",
gravityEventCallback: { event in
// SDK передает в callback действия пользователя внутри кампаний
// и tracking-события поддерживаемых сценариев.
print(event)
},
productViewBuilder: nil,
productFilter: nil
)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
#
Шаг 2. Идентификация пользователя
После успешной авторизации рекомендуется отправить LoginEvent, чтобы связать анонимный и авторизованный профили.
let loginEvent = LoginEvent(
cuid: sha256Hex(normalizePhone(rawPhoneNumber)),
cuidType: "phone_hash"
)
GravitySDK.instance.triggerEvent(
events: [loginEvent],
pageContext: PageContext(
type: .other,
data: [],
location: "app://login"
)
)
Если приложение само управляет userId и sessionId, используйте setUser(userId:sessionId:). Подробности — в разделе
#
Шаг 3. Передача просмотров экранов
Используйте trackView(...), чтобы передать SDK факт просмотра экрана. Этот вызов нужен и для трекинга поведения пользователя, и для сценариев, в которых показ кампании зависит от текущего контекста.
GravitySDK.instance.trackView(
pageContext: PageContext(
type: .homepage,
data: [],
location: "app://homepage"
)
)
Важно: если
PageContextсодержит значения из товарного фида, передавайте их без изменений относительно фида. Это относится к SKU, иерархии категорий,lngи другим feed-derived значениям. Регистр является частью значения: не приводите его к upper/lower case.
#
Шаг 4. Передача пользовательских событий
Используйте triggerEvent(...), чтобы передать в SDK действие пользователя. Этот вызов нужен и для аналитики, и для сегментации, и для сценариев, где кампания реагирует на событие.
GravitySDK.instance.triggerEvent(
events: [
AddToCartEvent(
value: 1990,
productId: "sku-123",
quantity: 1,
currency: "RUB"
)
],
pageContext: PageContext(
type: .product,
data: ["sku-123"],
location: "app://product/sku-123"
)
)
Важно: если в событии или
PageContextпередаются значения, которые должны матчиться с товарным фидом, используйте их в точности как в фиде. Это относится кproductId,cart[*].productId, SKU вdata, категориям,lngи другим feed-derived значениям. Регистр должен совпадать с фидом.
#
Шаг 5. Ручная загрузка контента по selector
Для собственного UI или headless-сценариев загрузите контент через getContentBySelector(...).
Task {
do {
let response = try await GravitySDK.instance.getContentBySelector(
selector: "homepage-recommendations",
pageContext: PageContext(
type: .homepage,
data: [],
location: "app://homepage"
)
)
if let campaign = response.data.first,
let variation = campaign.payload.first,
let content = variation.contents.first {
print(content.contentType)
}
} catch {
print("Failed to load selector content: \(error)")
}
}
#
Инициализация и конфигурация
#
initialize(...)
public static func initialize(
apiKey: String,
section: String,
gravityEventCallback: @escaping GravityEventCallback,
productViewBuilder: ProductViewBuilder? = nil,
productFilter: ProductFilter? = nil,
logLevel: LogLevel = .none
)
Параметры:
apiKey— API key проекта.section— идентификатор секции проекта.gravityEventCallback— обязательный callback в публичной сигнатуре SDK.productViewBuilder— кастомный билдер карточек товара для product-based in-app кампаний.productFilter— присутствует в сигнатуре, но в текущей версии SDK не участвует в публичном rendering flow. Не используйте его как рабочий интеграционный инструмент.logLevel— уровень логирования внутреннего SDK-логгера. По умолчанию.none.
Что важно учесть:
- вызов
initialize(...)нужно выполнить до первого обращения кGravitySDK.instance; gravityEventCallbackиспользуется для обработки действий пользователя внутри кампаний и tracking-событий поддерживаемых сценариев;- если вы кастомизируете карточки товаров, передайте
productViewBuilderуже на этапе инициализации.
#
Логирование SDK
iOS SDK поддерживает параметр logLevel в initialize(...). Он управляет только внутренними логами SDK и не влияет на gravityEventCallback, отправку событий или логи приложения.
GravitySDK.initialize(
apiKey: "YOUR_API_KEY",
section: "YOUR_SECTION_ID",
gravityEventCallback: { event in
print(event)
},
logLevel: .debug
)
Доступные уровни:
.debug— включает HTTP request/response debug-логи и ошибки SDK;.info— включаетinfoиerrorсообщения SDK;.error— оставляет только ошибки SDK;.none— полностью отключает внутренний вывод SDK.
Логи пишутся через os_log с префиксом GravitySDK.
#
setOptions(options:contentSettings:proxyUrl:)
public func setOptions(
options: Options?,
contentSettings: ContentSettings?,
proxyUrl: String?
)
Метод задает runtime-настройки для последующих запросов trackView(...), triggerEvent(...) и getContentBySelector(...).
public struct Options: Codable {
public let isReturnCounter: Bool
public let isReturnUserInfo: Bool
public let isReturnAnalyticsMetadata: Bool
public let isImplicitPageview: Bool
public let isImplicitImpression: Bool
}
public struct ContentSettings: Codable {
public let skusOnly: Bool
public let fields: [String]?
}
Пример:
GravitySDK.instance.setOptions(
options: Options(
isReturnCounter: false,
isReturnUserInfo: true,
isReturnAnalyticsMetadata: false,
isImplicitPageview: false,
isImplicitImpression: true
),
contentSettings: ContentSettings(
skusOnly: false,
fields: ["id", "name", "price"]
),
proxyUrl: nil
)
Что настраивается через Options:
isReturnCounter— добавляет счетчики в response, если они нужны приложению;isReturnUserInfo— включает возвратuserв response;isReturnAnalyticsMetadata— включает возврат аналитических метаданных;isImplicitPageview— управляет неявной отправкой pageview на backend;isImplicitImpression— управляет неявной отправкой impression для поддерживаемых сценариев.
Что настраивается через ContentSettings:
skusOnly— возвращает только SKU вместо полного товарного объекта там, где это поддерживает backend-сценарий;fields— ограничивает набор полей, возвращаемых по товарам.
proxyUrl используется, если проект работает через собственный прокси к backend Gravity Field.
#
setUser(userId:sessionId:)
public func setUser(userId: String, sessionId: String)
Метод включает ручное управление идентификаторами пользователя и сессии. После вызова SDK использует переданные значения в последующих запросах.
#
setNotificationPermissionStatus(status:)
public func setNotificationPermissionStatus(
status: NotificationPermissionStatus
)
Передает в SDK текущий статус push-разрешения для таргетинга и аналитики.
Возможные значения:
.granted.denied.unknown
#
Идентификация пользователя
SDK поддерживает два режима идентификации: SDK-managed и ручной.
#
Стандарт CUID на основе телефона (phone_hash)
Во всех интеграциях рекомендуется использовать единый идентификатор пользователя — SHA-256 хеш нормализованного телефона.
Правила:
- номер очищается от всех символов, кроме цифр;
- для РФ и КЗ номер приводится к формату
7XXXXXXXXXX; - хеш считается по UTF-8 строке;
- результат передается в lowercase hex;
- в
LoginEvent.cuidTypeиспользуется строка"phone_hash".
#
SDK-managed режим
Если setUser(...) не вызывать:
uidсохраняется вUserDefaults;sesобновляется из ответов backend и хранится в runtime;- последующие запросы используют эти значения автоматически.
После логина рекомендуется отправить LoginEvent, чтобы связать анонимный и авторизованный профили.
#
Ручной режим
Если приложение само управляет идентификаторами, задайте их явно:
GravitySDK.instance.setUser(
userId: "external-user-id",
sessionId: "session-id"
)
#
PageContext
PageContext описывает, где находится пользователь в приложении и в каком бизнес-контексте выполняется запрос.
Этот объект используется в ключевых вызовах iOS SDK:
для передачи просмотров экранов;trackView(...) для передачи действий пользователя;triggerEvent(...) для ручной загрузки контента.getContentBySelector(...)
Корректное заполнение PageContext влияет и на аналитику, и на таргетинг, и на подбор контента.
#
Структура PageContext
public struct PageContext: Codable {
public let type: ContextType
public let data: [String]
public let location: String
public let lng: String?
public let pageNumber: Int?
public let referrer: String?
public let utm: [String: String]?
public let attributes: [String: String]
}
Бизнес-логика заполнения PageContext соответствует Page context.
Рекомендуемая схема заполнения:
HOMEPAGE:data = [].PRODUCT: вdataпередается SKU товара ровно как в товарном фиде, без нормализации и с тем же регистром.CART: вdataпередаются SKU всех товаров, которые сейчас находятся в корзине, ровно как в товарном фиде, без нормализации и с тем же регистром.CATEGORY: вdataпередается полная иерархия категорий от самой широкой до самой узкой ровно как в товарном фиде, без нормализации и с тем же регистром.SEARCH: вdataпередается поисковый запрос одной строкой. Для пустого поиска передавайте пустой список.OTHER: используйте только для экранов, которые не подходят под остальные типы.
Для любых значений в PageContext, которые должны матчиться с товарным фидом, действует единое правило: передавайте их идентично фиду. Нельзя менять написание, переименовывать значения или приводить их к upper/lower case.
#
Поле lng
lng используется только для мультирегиональности. Это региональный код, который позволяет отдавать пользователю корректные региональные данные по товарам: цены, доступность и остатки.
Например, если пользователь находится в Новосибирске, значение lng можно использовать для того, чтобы в рекомендациях участвовали только товары, реально доступные в Новосибирске.
Важно:
- значения
lngвPageContextи в товарном фиде должны совпадать полностью, включая регистр; lng, как и любые другие feed-derived значения, нужно передавать без преобразования к upper/lower case;- передавайте
lngтолько если проект действительно использует региональные варианты данных.
#
Поле attributes
SDK автоматически дополняет attributes служебными значениями:
app_versionsdk_versionapp_platform
#
Примечания по текущей версии iOS SDK
sdk_versionсейчас захардкожен как"0.0.1";referrerв публичной моделиPageContextкорректно передается через инициализатор;- перед использованием
pageNumberиreferrerкак обязательной основы для production-таргетинга всё равно стоит дополнительно проверить фактическое поведение backend на вашем проекте.
См. также:
trackView(...)triggerEvent(...)getContentBySelector(...)
#
Отслеживание просмотров экранов
#
trackView(pageContext:viewController:)
public func trackView(
pageContext: PageContext,
viewController: UIViewController? = nil
)
trackView(...) передает в Gravity Field факт просмотра экрана. Этот вызов используется и для трекинга пользовательского поведения, и для сценариев, в которых показ кампании зависит от контекста страницы.
Пример:
GravitySDK.instance.trackView(
pageContext: PageContext(
type: .product,
data: ["sku-123"],
location: "app://product/sku-123"
)
)
Что важно учесть:
- для корректной бизнес-логики требуется корректно заполненный
;PageContext - если
viewControllerне передан, SDK пытается найти top-mostUIViewControllerавтоматически; - для автопоказа in-app нужен доступный
UIViewController, уже находящийся вwindow; - при успешной загрузке контента для найденных кампаний SDK автоматически отправляет content load tracking;
- метод запускает асинхронную работу внутри
Taskи не возвращает результат вызывающему коду напрямую.
#
Трекинг событий
События описывают действия пользователя: покупку, добавление в корзину, авторизацию и другие сценарии, которые должны попадать в аналитику или запускать кампании.
Бизнес-логика заполнения событий соответствует Настройке передачи событий, а iOS SDK предоставляет типы и метод для их отправки.
Для e-commerce сценариев события PurchaseEvent и AddToCartEvent обязательны.
Для всех полей событий, которые должны матчиться с товарным фидом, действует то же правило, что и для PageContext: передавайте значения идентично фиду. Это относится к productId, cart[*].productId и другим feed-derived значениям. Регистр является частью значения.
#
triggerEvent(events:pageContext:viewController:)
public func triggerEvent(
events: [TriggerEvent],
pageContext: PageContext,
viewController: UIViewController? = nil
)
triggerEvent(...) передает в Gravity Field пользовательские действия. Этот вызов нужен и для аналитики, и для сегментации, и для сценариев, в которых событие может активировать кампанию.
Пример:
GravitySDK.instance.triggerEvent(
events: [
AddToCartEvent(
value: 1990,
productId: "sku-123",
quantity: 1,
currency: "RUB"
)
],
pageContext: PageContext(
type: .product,
data: ["sku-123"],
location: "app://product/sku-123"
)
)
Что важно учесть:
- как и для
trackView(...), для корректной бизнес-логики нужен корректно заполненный ;PageContext - для автопоказа in-app нужен доступный
UIViewController, уже находящийся вwindow; - при успешной загрузке контента для найденных кампаний SDK автоматически отправляет content load tracking;
- метод запускает асинхронную работу внутри
Taskи не возвращает результат вызывающему коду напрямую.
#
Основные типы TriggerEvent
#
Правила заполнения e-commerce событий
#
PurchaseEvent
- отправляйте после успешного завершения заказа;
uniqueTransactionIdдолжен быть уникальным для каждой покупки;value— полная сумма заказа;cart— фактический состав заказа;cart[*].productIdдолжен совпадать со SKU в товарном фиде полностью, включая регистр;currencyобязательна для мультивалютных проектов.
let purchase = PurchaseEvent(
uniqueTransactionId: "ORDER-1001",
value: 3580,
cart: [
CartItem(productId: "sku-123", quantity: 1, itemPrice: 1990),
CartItem(productId: "sku-555", quantity: 1, itemPrice: 1590)
],
currency: "RUB"
)
#
AddToCartEvent
- отправляйте в момент фактического добавления товара в корзину;
value— сумма, добавляемая этим действием;quantity— количество единиц, добавленных именно этим действием;productIdдолжен совпадать со SKU в товарном фиде полностью, включая регистр.
let addToCart = AddToCartEvent(
value: 1990,
productId: "sku-123",
quantity: 1,
currency: "RUB"
)
#
RemoveFromCartEvent и SyncCartEvent
RemoveFromCartEventотправляйте в момент удаления товара из корзины или уменьшения количества;valueвRemoveFromCartEvent— сумма удаляемых единиц товара;productIdвRemoveFromCartEventдолжен совпадать со SKU в товарном фиде полностью, включая регистр;SyncCartEventиспользуйте, когда нужно передать полное актуальное состояние корзины;valueвSyncCartEvent— общая стоимость актуального состава корзины.cart[*].productIdвRemoveFromCartEventиSyncCartEvent, если передаетсяcart, должен совпадать со SKU в товарном фиде полностью, включая регистр.
#
LoginEvent
let login = LoginEvent(
cuid: sha256PhoneHash,
cuidType: "phone_hash"
)
#
AddToWishlistEvent
let addToWishlist = AddToWishlistEvent(
value: 1990,
productId: "sku-123"
)
productId в AddToWishlistEvent должен совпадать со SKU в товарном фиде полностью, включая регистр.
#
CustomEvent
let custom = CustomEvent(
type: "survey-completed-v1",
name: "Survey Completed",
customProps: ["score": "9", "variant": "A"]
)
#
Передача статуса Push-уведомлений
Если таргетинг кампаний зависит от статуса push-разрешения, передавайте его в SDK через setNotificationPermissionStatus(status:).
GravitySDK.instance.setNotificationPermissionStatus(status: .granted)
Отдельно учитывайте поведение requestPush внутри in-app кампаний:
- iOS SDK сам вызывает
UNUserNotificationCenter.requestAuthorization(...); - при отказе SDK может открыть системные настройки приложения;
- SDK дополнительно передает
RequestPushEventвgravityEventCallback.
#
Работа с контентом
iOS SDK поддерживает два основных режима работы с контентом:
- автоматический показ in-app кампаний;
- ручную загрузку контента по
selectorдля собственного UI.
#
In-App кампании
SDK автоматически показывает кампании с deliveryMethod:
fullScreenmodalbottomSheetsnackBar
Что требуется для автопоказа:
- подходящая активная кампания на backend;
- корректный
;PageContext - доступный
UIViewControllerв момент вызоваtrackView(...)илиtriggerEvent(...).
Для inline отдельного публичного iOS widget сейчас нет.
#
getContentBySelector(selector:pageContext:)
public func getContentBySelector(
selector: String,
pageContext: PageContext
) async throws -> ContentResponse
Используйте этот метод для manual rendering и кастомного UI.
let response = try await GravitySDK.instance.getContentBySelector(
selector: "homepage-recommendations",
pageContext: PageContext(
type: .homepage,
data: [],
location: "app://homepage"
)
)
Для вызова требуется корректно заполненный PageContext
Что важно учесть:
- успешный
getContentBySelector(...)автоматически запускает content load tracking для всехcontents, пришедших в response; - это означает, что
ContentLoadEventможет прийти вgravityEventCallbackсразу после загрузки контента, даже если UI еще не был показан пользователю; - impression / visible impression / click для manual widget SDK автоматически не отправляет: приложение по-прежнему должно вызвать
sendContentEngagement(...)иsendProductEngagement(...)самостоятельно.
#
Пример: server-driven recommendation widget
Используйте этот сценарий, если приложение хочет само рендерить inline recommendation widget, а backend должен продолжать управлять содержимым и структурой виджета через getContentBySelector(...).
Разделение ответственности в этом flow:
- клиент вызывает
getContentBySelector(...)с нужнымиselectorиPageContext; - backend возвращает готовую конфигурацию виджета;
- клиент интерпретирует ответ SDK и строит native UI;
- клиент сам обрабатывает
onClickи вручную отправляет engagement.
#
Что именно управляется с сервера
- заголовок и дополнительные текстовые блоки приходят как
Element(type = text)внутриcontent.variables.elements; - изображения, кнопки, spacer и другие UI-элементы тоже приходят в
elements; - подборка товаров, порядок карточек, стратегия и fallback приходят в
content.products; - тип товарного блока задается через
products-containerиstyle.productContainerType; - внешний контейнер виджета приходит в
content.variables.frameUI?.container; - сервер также управляет визуальной кастомизацией через
frameUI.container.styleиelements[*].style.
#
Как читать ответ SDK
products.strategyId, products.name и products.fallback показывают, какая рекомендательная стратегия была выбрана backend и был ли использован fallback.
#
Что клиент не должен делать
- не хардкодить заголовок, если он уже приходит в
elements; - не перестраивать порядок товаров, если backend уже вернул готовое ранжирование;
- не игнорировать
productContainerType, если backend переключил layout сrowнаgridили обратно; - не передавать вручную
decisionIdили engagement URL: используйтеcampaign,contentиslotиз ответа SDK.
#
Полный пример на SwiftUI
Ниже пример server-driven виджета. Он:
- загружает
CampaignContentчерезgetContentBySelector(...); - рендерит
elementsв том порядке, который вернул backend; - берет товары из
content.products?.slots; - выбирает
rowилиgridпоproducts-container.style.productContainerType; - вручную отправляет
ContentImpressionEngagement,ContentVisibleImpressionEngagement,ProductVisibleImpressionEngagementиProductClickEngagement.
Для краткости RemoteImageView использует AsyncImage. Если в проекте уже есть свой image loader, замените только эту часть.
import SwiftUI
import UIKit
import GravitySDK
struct ServerDrivenRecommendationWidget: View {
let selector: String
let pageContext: PageContext
let onOpenUrl: (URL) -> Void
let onOpenDeeplink: (String) -> Void
let onOpenProduct: ([String: Any?]) -> Void
@State private var campaign: Campaign?
@State private var content: CampaignContent?
@State private var isLoading = false
@State private var hasSentContentImpression = false
@State private var hasSentContentVisibleImpression = false
@State private var visibleProductKeys = Set<String>()
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let campaign, let content {
VisibilityObserver {
sendContentVisibleImpressionIfNeeded(content: content, campaign: campaign)
} content: {
widgetBody(content: content, campaign: campaign)
}
.task(id: content.contentId) {
sendContentImpressionIfNeeded(content: content, campaign: campaign)
}
} else {
EmptyView()
}
}
.task {
await loadContent()
}
}
@ViewBuilder
private func widgetBody(content: CampaignContent, campaign: Campaign) -> some View {
let container = content.variables.frameUI?.container
let containerStyle = container?.style ?? .empty
let alignment = horizontalAlignment(from: containerStyle.contentAlignment)
VStack(alignment: alignment, spacing: 0) {
ForEach(Array(content.variables.elements.enumerated()), id: \.offset) { _, element in
renderElement(
element,
content: content,
campaign: campaign
)
}
}
.padding(edgeInsets(from: containerStyle.padding))
.frame(
width: containerStyle.size?.width,
height: containerStyle.size?.height,
alignment: .topLeading
)
.frame(maxWidth: .infinity, alignment: .leading)
.background(containerStyle.backgroundColor ?? Color.clear)
.clipShape(
RoundedRectangle(cornerRadius: containerStyle.cornerRadius ?? 0)
)
.padding(edgeInsets(from: containerStyle.margin))
}
@ViewBuilder
private func renderElement(
_ element: Element,
content: CampaignContent,
campaign: Campaign
) -> some View {
switch element.type {
case .text:
Text(element.text ?? "")
.font(.system(
size: element.style.fontSize ?? 16,
weight: fontWeight(from: element.style.fontWeight)
))
.foregroundColor(element.style.textColor)
.multilineTextAlignment(
element.style.contentAlignment?.toTextAlignment() ?? .leading
)
.frame(maxWidth: .infinity, alignment: alignment(from: element.style.contentAlignment))
.padding(edgeInsets(from: element.style.margin))
.onTapGesture {
handleOnClick(element.onClick)
}
case .image:
if let src = element.src {
RemoteImageView(
urlString: src,
height: element.style.size?.height ?? 120
)
.frame(maxWidth: .infinity)
.padding(edgeInsets(from: element.style.margin))
.onTapGesture {
handleOnClick(element.onClick)
}
}
case .button:
Button {
handleOnClick(element.onClick)
} label: {
Text(element.text ?? "")
.font(.system(
size: element.style.fontSize ?? 16,
weight: fontWeight(from: element.style.fontWeight)
))
.foregroundColor(element.style.textColor ?? .white)
.frame(maxWidth: .infinity)
.padding(edgeInsets(from: element.style.padding))
.background(element.style.backgroundColor ?? Color.blue)
.clipShape(
RoundedRectangle(cornerRadius: element.style.cornerRadius ?? 12)
)
}
.padding(edgeInsets(from: element.style.margin))
case .spacer:
Color.clear
.frame(height: element.style.size?.height ?? 12)
case .productsContainer:
if let slots = content.products?.slots {
switch element.style.productContainerType ?? .row {
case .row:
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 12) {
ForEach(Array(slots.enumerated()), id: \.offset) { itemIndex, slot in
productCard(
slot: slot,
key: productKey(slot: slot, index: itemIndex),
content: content,
campaign: campaign
)
}
}
.padding(edgeInsets(from: element.style.padding))
}
.padding(edgeInsets(from: element.style.margin))
case .grid:
let columns = Array(
repeating: GridItem(.flexible(), spacing: 12),
count: max(element.style.gridColumns ?? 3, 1)
)
LazyVGrid(columns: columns, spacing: 12) {
ForEach(Array(slots.enumerated()), id: \.offset) { itemIndex, slot in
productCard(
slot: slot,
key: productKey(slot: slot, index: itemIndex),
content: content,
campaign: campaign
)
}
}
.padding(edgeInsets(from: element.style.padding))
.padding(edgeInsets(from: element.style.margin))
}
}
default:
EmptyView()
}
}
@ViewBuilder
private func productCard(
slot: Slot,
key: String,
content: CampaignContent,
campaign: Campaign
) -> some View {
VisibilityObserver {
sendProductVisibleImpressionIfNeeded(
for: key,
slot: slot,
content: content,
campaign: campaign
)
} content: {
VStack(alignment: .leading, spacing: 8) {
if let imageUrl = stringValue("imageUrl", in: slot.item) {
RemoteImageView(urlString: imageUrl, height: 160)
}
Text(stringValue("name", in: slot.item) ?? "Unknown product")
.font(.headline)
.foregroundColor(.primary)
if let price = stringValue("price", in: slot.item) {
Text(price)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
.onTapGesture {
GravitySDK.instance.sendProductEngagement(
engagement: ProductClickEngagement(
slot: slot,
content: content,
campaign: campaign
)
)
onOpenProduct(slot.item)
}
}
}
@MainActor
private func loadContent() async {
isLoading = true
defer { isLoading = false }
do {
let response = try await GravitySDK.instance.getContentBySelector(
selector: selector,
pageContext: pageContext
)
campaign = response.data.first
content = campaign?.payload.first?.contents.first
hasSentContentImpression = false
hasSentContentVisibleImpression = false
visibleProductKeys.removeAll()
} catch {
campaign = nil
content = nil
}
}
private func sendContentImpressionIfNeeded(
content: CampaignContent,
campaign: Campaign
) {
guard !hasSentContentImpression else { return }
hasSentContentImpression = true
GravitySDK.instance.sendContentEngagement(
engagement: ContentImpressionEngagement(
content: content,
campaign: campaign
)
)
}
private func sendContentVisibleImpressionIfNeeded(
content: CampaignContent,
campaign: Campaign
) {
guard !hasSentContentVisibleImpression else { return }
hasSentContentVisibleImpression = true
GravitySDK.instance.sendContentEngagement(
engagement: ContentVisibleImpressionEngagement(
content: content,
campaign: campaign
)
)
}
private func sendProductVisibleImpressionIfNeeded(
for key: String,
slot: Slot,
content: CampaignContent,
campaign: Campaign
) {
guard !visibleProductKeys.contains(key) else { return }
visibleProductKeys.insert(key)
GravitySDK.instance.sendProductEngagement(
engagement: ProductVisibleImpressionEngagement(
slot: slot,
content: content,
campaign: campaign
)
)
}
private func handleOnClick(_ onClick: OnClickModel?) {
guard let onClick else { return }
switch onClick.action {
case .followUrl:
guard
let rawUrl = onClick.url,
let url = URL(string: rawUrl)
else { return }
onOpenUrl(url)
case .followDeeplink:
guard let deeplink = onClick.deeplink else { return }
onOpenDeeplink(deeplink)
case .copy:
UIPasteboard.general.string = onClick.copyData
default:
break
}
}
private func productKey(slot: Slot, index: Int) -> String {
slot.slotId
?? stringValue("sku", in: slot.item)
?? stringValue("id", in: slot.item)
?? "slot-\(index)"
}
private func stringValue(_ key: String, in item: [String: Any?]) -> String? {
if let value = item[key] as? String {
return value
}
if let value = item[key] as? NSNumber {
return value.stringValue
}
return nil
}
private func edgeInsets(from padding: GravityPadding?) -> EdgeInsets {
EdgeInsets(
top: padding?.top ?? 0,
leading: padding?.left ?? 0,
bottom: padding?.bottom ?? 0,
trailing: padding?.right ?? 0
)
}
private func edgeInsets(from margin: GravityMargin?) -> EdgeInsets {
EdgeInsets(
top: margin?.top ?? 0,
leading: margin?.left ?? 0,
bottom: margin?.bottom ?? 0,
trailing: margin?.right ?? 0
)
}
private func alignment(from value: GravityContentAlignment?) -> Alignment {
switch value {
case .center:
return .center
case .end:
return .trailing
default:
return .leading
}
}
private func horizontalAlignment(from value: GravityContentAlignment?) -> HorizontalAlignment {
switch value {
case .center:
return .center
case .end:
return .trailing
default:
return .leading
}
}
private func fontWeight(from value: Double?) -> Font.Weight {
switch value {
case 700:
return .bold
case 600:
return .semibold
case 500:
return .medium
default:
return .regular
}
}
}
private struct RemoteImageView: View {
let urlString: String
let height: Double
var body: some View {
if let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
ZStack {
Color.gray.opacity(0.1)
ProgressView()
}
}
}
.frame(height: height)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
private struct VisibilityObserver<Content: View>: View {
let onVisible: () -> Void
let content: () -> Content
@State private var hasFired = false
@State private var workItem: DispatchWorkItem?
var body: some View {
content()
.background(
GeometryReader { geometry in
Color.clear
.onAppear {
evaluate(frame: geometry.frame(in: .global))
}
.onChange(of: geometry.frame(in: .global)) { newFrame in
evaluate(frame: newFrame)
}
.onDisappear {
workItem?.cancel()
workItem = nil
}
}
)
}
private func evaluate(frame: CGRect) {
guard !hasFired else { return }
let screen = UIScreen.main.bounds
let intersection = screen.intersection(frame)
let visibleArea = intersection.width * intersection.height
let totalArea = frame.width * frame.height
guard totalArea > 0 else { return }
if visibleArea < totalArea * 0.5 {
workItem?.cancel()
workItem = nil
return
}
workItem?.cancel()
let task = DispatchWorkItem {
hasFired = true
onVisible()
}
workItem = task
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: task)
}
}
Если backend передает frameUI.container.onClick, его можно повесить на wrapper виджета тем же способом, что и element.onClick в примере выше.
#
Engagement для manual widget
При использовании публичного getContentBySelector(...) SDK только получает контент. Engagement для такого manual widget приложение отправляет само:
ContentImpressionEngagement— один раз, когда виджет впервые отрисован;ContentVisibleImpressionEngagement— один раз, когда минимум 50% виджета находится в видимой области не менее 1 секунды;ProductVisibleImpressionEngagement— один раз на карточку товара по тому же правилу;ProductClickEngagement— на тап по карточке товара до навигации;- публичного
ContentClickEngagementв текущем iOS SDK нет.
Для manual rendering это означает, что campaign, content и slot нужно сохранить из ответа SDK и использовать повторно при отправке engagement.
#
Дополнительная кастомизация из backend
Нативный widget можно стилизовать по полям, которые уже приходят в моделях SDK:
- container:
backgroundColor,cornerRadius,padding,margin,size; - text:
fontSize,fontWeight,textColor,contentAlignment; - image:
src,fit,size; - layout:
gridColumns,productContainerType; - элементы:
button,image,text,spacer,products-container.
В iOS сейчас нет отдельного публичного inline widget из коробки, поэтому для inline recommendation use case этот manual flow через getContentBySelector(...) является рекомендуемым подходом.
#
showBackendContent(_:_:_:)
public func showBackendContent(
_ viewController: UIViewController,
_ content: CampaignContent,
_ campaign: Campaign
)
Это публичный advanced API для принудительного показа уже полученного контента.
Метод поддерживает:
fullScreenmodalbottomSheetsnackBar
Для inline этот метод ничего не показывает.
#
ProductViewBuilder
Если нужно кастомизировать отображение карточки товара, передайте productViewBuilder в initialize(...).
Фактический протокол:
public protocol ProductViewBuilder {
@ViewBuilder
func build(slot: Slot) -> AnyView
}
Пример:
import SwiftUI
import GravitySDK
struct MyProductBuilder: ProductViewBuilder {
func build(slot: Slot) -> AnyView {
let name = (slot.item["name"] as? String) ?? "Unknown"
return AnyView(
Text(name)
.padding()
.background(Color.white)
.cornerRadius(8)
)
}
}
Что важно учесть:
- в iOS
ProductViewBuilderполучает толькоslot; contentиcampaignв билдер не передаются;productFilterприсутствует вinitialize(...), но в текущей версии SDK не участвует в публичном rendering flow.
#
Трекинг взаимодействий
Для manual rendering и кастомного UI iOS SDK предоставляет публичные методы:
public func sendContentEngagement(engagement: ContentEngagement)
public func sendProductEngagement(engagement: ProductEngagement)
Поддерживаемые content engagement-типы:
ContentImpressionEngagementContentVisibleImpressionEngagementContentCloseEngagement
Поддерживаемые product engagement-типы:
ProductClickEngagementProductVisibleImpressionEngagement
#
Откуда брать slot, content и campaign
В manual rendering сценарии эти объекты обычно берутся из ответа getContentBySelector(...):
let response = try await GravitySDK.instance.getContentBySelector(
selector: "homepage-recommendations",
pageContext: pageContext
)
if let campaign = response.data.first,
let payload = campaign.payload.first,
let content = payload.contents.first,
let slot = content.products?.slots?.first {
GravitySDK.instance.sendContentEngagement(
engagement: ContentImpressionEngagement(
content: content,
campaign: campaign
)
)
GravitySDK.instance.sendProductEngagement(
engagement: ProductClickEngagement(
slot: slot,
content: content,
campaign: campaign
)
)
}
Если вы используете ProductViewBuilder, сам билдер получает только slot. Объекты content и campaign в build(slot:) не передаются, поэтому для manual engagement сценариев удобнее работать через response getContentBySelector(...).
#
Обработка обратных вызовов (Callbacks)
#
gravityEventCallback
gravityEventCallback обязателен в сигнатуре initialize(...).
В SDK определены модели TrackingEvent, включая:
ContentLoadEventContentImpressionEventContentVisibleImpressionEventContentCloseEventCopyEventCancelEventFollowUrlEventFollowDeeplinkEventRequestPushEventProductImpressionEvent
SDK передает эти события в gravityEventCallback и выполняет вызов на главном потоке.
Практический вывод:
ContentLoadEventможет приходить послеgetContentBySelector(...), а также после внутренней загрузки контента для auto-rendered in-app кампаний;- используйте
gravityEventCallbackдля обработки URL, deeplink и других действий пользователя внутри кампаний; - SDK сам не выполняет переходы по
FollowUrlEventиFollowDeeplinkEvent, это ответственность приложения; - manual engagement через
sendContentEngagement(...)/sendProductEngagement(...)не генерирует callback-событие автоматически; - не документируйте и не ожидайте
ProductClickEventкак гарантированный callback: модель существует в SDK, но текущий callback flow ее не эмитит.
#
FAQ / Troubleshooting
#
fatalError("GravitySDK has not been initialized")
Причина: обращение к GravitySDK.instance до вызова GravitySDK.initialize(...).
Что делать:
- Инициализируйте SDK на раннем этапе старта приложения.
- Убедитесь, что инициализация выполняется один раз.
#
Кампании не показываются после trackView(...) или triggerEvent(...)
Проверьте:
- корректность
apiKeyиsection; - корректность
;PageContext - наличие доступного
UIViewControllerдля показа; - активность кампаний в Gravity Field для этого контекста.
#
getContentBySelector(...) возвращает пустой data
Проверьте:
- верный
selector; - активность кампании;
- соответствие
условиям таргетинга;PageContext - сетевые ограничения и
proxyUrl, если он используется.
#
Почему кнопка с URL или deeplink внутри in-app ничего не делает?
Скорее всего, в приложении не обработаны FollowUrlEvent или FollowDeeplinkEvent в gravityEventCallback. iOS SDK передает эти события в callback, но сам переход не выполняет.
#
Почему inline-кампания не отображается автоматически?
Потому что в текущем публичном iOS SDK нет отдельного inline widget из коробки. Для inline-сценариев используйте getContentBySelector(...) и стройте UI самостоятельно.
#
Почему не получается вручную отправить content/product engagement?
Проверьте, что вы используете публичные типы ContentEngagement / ProductEngagement и передаете в них корректные content, campaign и slot из ответа getContentBySelector(...). Для content click отдельного публичного типа в iOS SDK сейчас нет.
#
Можно ли использовать pageNumber и referrer для таргетинга?
referrer в PageContext заполняется корректно. Тем не менее, если вы хотите строить на pageNumber или referrer критичный production-таргетинг, сначала проверьте end-to-end поведение на своем проекте через реальные запросы и кампании.