Skip to main content

UIKit Integration

A complete example showing SpendOwl integration in a UIKit app.

Project Setup

1

Add SpendOwl Package

In Xcode, go to File → Add Package Dependencies and add:
https://github.com/spendowl/spendowl-ios
2

Configure in AppDelegate

Call SpendOwl.configure() in didFinishLaunchingWithOptions.
3

Access Attribution

Use try await SpendOwl.attribution() in your view controllers.

Complete Example

AppDelegate.swift

Configure SpendOwl when the app launches:
import UIKit
import SpendOwl

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Enable logging in debug builds
        #if DEBUG
        SpendOwl.enableLogging = true
        #endif

        // Configure SpendOwl
        SpendOwl.configure(apiKey: "your-api-key")

        // Setup window
        window = UIWindow(frame: UIScreen.main.bounds)
        let navigationController = UINavigationController(
            rootViewController: AttributionViewController()
        )
        window?.rootViewController = navigationController
        window?.makeKeyAndVisible()

        return true
    }
}

AttributionViewController.swift

Display attribution data and manage user identity:
import UIKit
import SpendOwl

class AttributionViewController: UITableViewController {

    // MARK: - Properties

    private var attribution: AttributionResult?
    private var error: SpendOwlError?
    private var isLoading = false
    private var userId: String = ""

    private enum Section: Int, CaseIterable {
        case attribution
        case userIdentity
        case status
    }

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    // MARK: - Setup

    private func setupUI() {
        title = "SpendOwl Demo"
        navigationController?.navigationBar.prefersLargeTitles = true

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
        tableView.register(TextFieldCell.self, forCellReuseIdentifier: "TextFieldCell")
    }

    // MARK: - Actions

    private func fetchAttribution() {
        isLoading = true
        error = nil
        tableView.reloadSections(IndexSet(integer: Section.attribution.rawValue), with: .automatic)

        Task { @MainActor in
            do {
                self.attribution = try await SpendOwl.attribution()
            } catch let err as SpendOwlError {
                self.error = err
            } catch {
                self.error = .unknown(error)
            }
            self.isLoading = false
            self.tableView.reloadSections(
                IndexSet(integer: Section.attribution.rawValue),
                with: .automatic
            )
        }
    }

    private func setUserId() {
        guard !userId.isEmpty else { return }
        SpendOwl.setUserId(userId)
        showAlert(title: "User ID Set", message: "User ID: \(userId)")
    }

    private func clearUserId() {
        SpendOwl.clearUserId()
        userId = ""
        tableView.reloadSections(IndexSet(integer: Section.userIdentity.rawValue), with: .automatic)
        showAlert(title: "User ID Cleared", message: nil)
    }

    private func showAlert(title: String, message: String?) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }

    // MARK: - UITableViewDataSource

    override func numberOfSections(in tableView: UITableView) -> Int {
        Section.allCases.count
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        switch Section(rawValue: section)! {
        case .attribution:
            if isLoading { return 1 }
            if attribution != nil { return 6 } // 5 fields + button
            if error != nil { return 2 }
            return 1
        case .userIdentity:
            return 3
        case .status:
            return 2
        }
    }

    override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        switch Section(rawValue: section)! {
        case .attribution: return "Attribution"
        case .userIdentity: return "User Identity"
        case .status: return "Status"
        }
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        switch Section(rawValue: indexPath.section)! {
        case .attribution:
            return attributionCell(for: indexPath)
        case .userIdentity:
            return userIdentityCell(for: indexPath)
        case .status:
            return statusCell(for: indexPath)
        }
    }

    private func attributionCell(for indexPath: IndexPath) -> UITableViewCell {
        if isLoading {
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
            var config = cell.defaultContentConfiguration()
            config.text = "Fetching..."
            cell.contentConfiguration = config
            let spinner = UIActivityIndicatorView(style: .medium)
            spinner.startAnimating()
            cell.accessoryView = spinner
            return cell
        }

        if let attribution {
            let fields: [(String, String)] = [
                ("Status", attribution.status.displayName),
                ("Campaign", attribution.campaignName ?? "-"),
                ("Ad Group", attribution.adGroupName ?? "-"),
                ("Keyword", attribution.keyword ?? "-"),
                ("Country", attribution.countryOrRegion ?? "-")
            ]

            if indexPath.row < fields.count {
                let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
                var config = cell.defaultContentConfiguration()
                config.text = fields[indexPath.row].0
                config.secondaryText = fields[indexPath.row].1
                cell.contentConfiguration = config
                cell.selectionStyle = .none
                return cell
            }
        }

        if let error, indexPath.row == 0 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
            var config = cell.defaultContentConfiguration()
            config.text = "Error"
            config.secondaryText = error.localizedDescription
            config.secondaryTextProperties.color = .systemRed
            cell.contentConfiguration = config
            cell.selectionStyle = .none
            return cell
        }

        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        var config = cell.defaultContentConfiguration()
        config.text = "Fetch Attribution"
        config.textProperties.color = .systemBlue
        cell.contentConfiguration = config
        return cell
    }

    private func userIdentityCell(for indexPath: IndexPath) -> UITableViewCell {
        switch indexPath.row {
        case 0:
            let cell = tableView.dequeueReusableCell(
                withIdentifier: "TextFieldCell",
                for: indexPath
            ) as! TextFieldCell
            cell.configure(placeholder: "User ID", text: userId) { [weak self] text in
                self?.userId = text
            }
            return cell
        case 1:
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
            var config = cell.defaultContentConfiguration()
            config.text = "Set User ID"
            config.textProperties.color = .systemBlue
            cell.contentConfiguration = config
            return cell
        case 2:
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
            var config = cell.defaultContentConfiguration()
            config.text = "Clear User ID"
            config.textProperties.color = .systemRed
            cell.contentConfiguration = config
            return cell
        default:
            return UITableViewCell()
        }
    }

    private func statusCell(for indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        var config = cell.defaultContentConfiguration()

        switch indexPath.row {
        case 0:
            config.text = "Configured"
            config.secondaryText = SpendOwl.isConfigured ? "Yes" : "No"
            config.image = UIImage(systemName: SpendOwl.isConfigured ? "checkmark.circle.fill" : "xmark.circle.fill")
            config.imageProperties.tintColor = SpendOwl.isConfigured ? .systemGreen : .systemRed
        case 1:
            config.text = "SDK Version"
            config.secondaryText = SpendOwl.sdkVersion
        default:
            break
        }

        cell.contentConfiguration = config
        cell.selectionStyle = .none
        return cell
    }

    // MARK: - UITableViewDelegate

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)

        switch Section(rawValue: indexPath.section)! {
        case .attribution:
            let buttonRow = (attribution != nil) ? 5 : (error != nil) ? 1 : 0
            if indexPath.row == buttonRow {
                fetchAttribution()
            }
        case .userIdentity:
            if indexPath.row == 1 {
                setUserId()
            } else if indexPath.row == 2 {
                clearUserId()
            }
        case .status:
            break
        }
    }
}

// MARK: - Helper Extension

extension AttributionStatus {
    var displayName: String {
        switch self {
        case .attributed: return "Attributed"
        case .organic: return "Organic"
        case .unknown: return "Unknown"
        }
    }
}

// MARK: - TextFieldCell

class TextFieldCell: UITableViewCell {
    private let textField = UITextField()
    private var onTextChange: ((String) -> Void)?

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupTextField()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupTextField() {
        textField.translatesAutoresizingMaskIntoConstraints = false
        textField.autocapitalizationType = .none
        textField.autocorrectionType = .no
        textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
        contentView.addSubview(textField)

        NSLayoutConstraint.activate([
            textField.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
            textField.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
            textField.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
            textField.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12)
        ])

        selectionStyle = .none
    }

    func configure(placeholder: String, text: String, onTextChange: @escaping (String) -> Void) {
        textField.placeholder = placeholder
        textField.text = text
        self.onTextChange = onTextChange
    }

    @objc private func textChanged() {
        onTextChange?(textField.text ?? "")
    }
}

Key Patterns

MainActor for UI Updates

Always update UI on the main thread:
Task { @MainActor in
    self.attribution = try await SpendOwl.attribution()
    self.tableView.reloadData()
}

SceneDelegate Setup

For apps using SceneDelegate:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        // SpendOwl is already configured in AppDelegate

        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = UINavigationController(
            rootViewController: AttributionViewController()
        )
        window?.makeKeyAndVisible()
    }
}

Authentication Integration

Set user ID in your login flow:
class LoginViewController: UIViewController {
    func login(email: String, password: String) {
        Task { @MainActor in
            do {
                let user = try await AuthService.login(email: email, password: password)

                // Set SpendOwl user ID
                SpendOwl.setUserId(user.id)

                // Navigate to main app
                let homeVC = HomeViewController()
                navigationController?.setViewControllers([homeVC], animated: true)
            } catch {
                showError(error)
            }
        }
    }
}

Download Example

The complete example project is available on GitHub:

SpendOwlDemo-UIKit

Download the complete UIKit example