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

  1. В Xcode откройте File -> Add Package Dependencies....
  2. Укажите URL репозитория с iOS SDK.
  3. Выберите продукт GravitySDK и добавьте его в target приложения.
  4. Импортируйте 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]
}
Поле Обязательность Описание
type Обязательно Тип экрана.
data Обязательно Контекстные данные для выбранного типа экрана.
location Обязательно Уникальный идентификатор экрана, маршрута или deeplink.
lng Опционально Региональный код для мультирегиональности.
pageNumber Опционально Номер страницы в пагинации.
referrer Опционально Источник перехода.
utm Опционально UTM-метки.
attributes Опционально Дополнительные атрибуты таргетинга.

Бизнес-логика заполнения 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_version
  • sdk_version
  • app_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-most UIViewController автоматически;
  • для автопоказа 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

Событие Основные параметры
AddToCartEvent value, productId, quantity, currency?, cart?
PurchaseEvent uniqueTransactionId, value, cart, currency?
RemoveFromCartEvent value, productId, quantity, currency?, cart?
SyncCartEvent value, currency?, cart?
AddToWishlistEvent value, productId
SignUpEvent hashedEmail?, cuid?, cuidType?
LoginEvent hashedEmail?, cuid?, cuidType?
CustomEvent type, name, customProps?

# Правила заполнения 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:

  • fullScreen
  • modal
  • bottomSheet
  • snackBar

Что требуется для автопоказа:

  • подходящая активная кампания на 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

Поле Что означает Как использовать
response.data[*] campaign Верхний уровень ответа SDK. Используется для engagement и доступа к variation.
campaign.payload[*] variation / decision Содержит набор contents для выбранной кампании.
payload.contents[*] CampaignContent Конкретный блок, который нужно рендерить.
content.variables.frameUI?.container Внешний контейнер виджета Фон, скругление, padding, margin, size и опциональный onClick для всего блока.
content.variables.elements Ordered server-driven UI schema Рендерите элементы строго в том порядке, в котором они пришли с backend.
Element(type = text) Текстовый блок Если это первый text до products-container, его можно трактовать как заголовок виджета.
Element(type = products-container) Место вставки товарного блока В этой точке UI нужно отрисовать content.products?.slots.
element.style.productContainerType Тип recommendation widget Значение row или grid приходит с backend и не должно хардкодиться в приложении.
element.style.gridColumns Число колонок для grid Если поле отсутствует, используйте дефолт клиента, например 3.
content.products?.slots[*] Отранжированные товары Используйте порядок слотов как есть, без локальной перестановки.
slot.item[...] Данные товара Источник name, price, imageUrl, url и любых кастомных полей из фида.

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 для принудительного показа уже полученного контента.

Метод поддерживает:

  • fullScreen
  • modal
  • bottomSheet
  • snackBar

Для 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-типы:

  • ContentImpressionEngagement
  • ContentVisibleImpressionEngagement
  • ContentCloseEngagement

Поддерживаемые product engagement-типы:

  • ProductClickEngagement
  • ProductVisibleImpressionEngagement

# Откуда брать 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, включая:

  • ContentLoadEvent
  • ContentImpressionEvent
  • ContentVisibleImpressionEvent
  • ContentCloseEvent
  • CopyEvent
  • CancelEvent
  • FollowUrlEvent
  • FollowDeeplinkEvent
  • RequestPushEvent
  • ProductImpressionEvent

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

Что делать:

  1. Инициализируйте SDK на раннем этапе старта приложения.
  2. Убедитесь, что инициализация выполняется один раз.

# Кампании не показываются после trackView(...) или triggerEvent(...)

Проверьте:

  1. корректность apiKey и section;
  2. корректность PageContext;
  3. наличие доступного UIViewController для показа;
  4. активность кампаний в Gravity Field для этого контекста.

# getContentBySelector(...) возвращает пустой data

Проверьте:

  1. верный selector;
  2. активность кампании;
  3. соответствие PageContext условиям таргетинга;
  4. сетевые ограничения и 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 поведение на своем проекте через реальные запросы и кампании.