Developer Docs / iOS SDK / Ads

GrowthCat iOS Ads SDK

In-app advertising built into the same Swift package as referral redemption. Banners, interstitials, rewarded ads, and App Store install ads — all with server-side config, offline queuing, and reward validation.

Ad formats

Banner, interstitial image, interstitial video, rewarded image, rewarded video, and App Store install.

Built-in guarantees

Server-side format control, offline event queuing, server-validated rewards, and App Store-compliant disclosure labels.

Not included

Ad creation, campaign management, creative uploads, or advertiser billing. Those live in the GrowthCat dashboard.

1. Overview

The GrowthCat Ads system handles the full ad lifecycle automatically. The same GrowthCat.initialize call that sets up referral also bootstraps the ads engine — no extra configuration step is required.

Full lifecycle at a glance

  1. GrowthCat.initialize() → fetches bootstrap config including ads settings.
  2. loadAd(placementKey:) → fetches catalog, caches it, pre-downloads R2 creative assets to disk.
  3. Render ad via GrowthCatAdBannerView or GrowthCatAdView.
  4. SDK fires impression, click, and video events — batched and persisted offline automatically.
  5. For rewarded ads: POST /v1/ads/reward/validate returns server confirmation before any reward is granted.

Built-in guarantees

  • Ads are only shown when ads_enabled: true from the server.
  • Only formats listed in the server formats array are served.
  • Creative assets are served from R2 and pre-fetched so ads render without a network delay.
  • Every ad format shows a visible "Sponsored" label — required by App Store policy.
  • Rewards are only granted after server-side validation succeeds.
  • Stable sdk_event_id values prevent double-counting on retry.

Not handled by the SDK

  • Creating or managing ad campaigns or creatives.
  • Advertiser billing and payout reconciliation.
  • Analytics dashboards or reporting UI.
  • Granting rewards before reward_validated: true.

2. Requirements

  • iOS 16+
  • GrowthCat SDK installed and initialized (same Swift Package as referral)
  • GrowthCat app SDK key (gc_live_...)
  • Ads feature enabled in your GrowthCat dashboard
  • At least one placement key configured in your dashboard

RevenueCat is not required for ads. You only need RevenueCat if you are also using the referral redemption features in the same app.

3. Production readiness checklist

  • GrowthCat.initialize is called before any ad is shown.
  • ads_enabled: true is confirmed before showing ad entry points.
  • appUserID is passed to all rewarded ad views and validateAdReward calls.
  • No reward is granted unless response.rewardValidated == true.
  • prefetchAdCatalogs is called on app foreground.
  • flushAdEvents is called when the app enters the background.
  • No-fill (loadAd returns nil) is handled gracefully — no empty containers left in layout.
  • Every rendered ad shows a "Sponsored" disclosure label.
  • close_policy.skippable_after_seconds is enforced — close button hidden until timer elapses.
  • For rewarded formats, close button is hidden until the reward condition is met.
  • Both banner and interstitial formats tested, including rewarded and App Store install creatives.
  • Offline scenario tested: events queue locally and flush on reconnect.

4. Ad formats

The SDK exposes two public formats. The server decides the exact creative type within each format — you never need to branch on image vs. video vs. StoreKit in your app code.

AdFormatDescriptionRenders as
.bannerFixed-height strip, full widthInline SwiftUI view
.interstitialFull-screen modal — image, video, rewarded, or App Store install (server decides)Full-screen overlay

The server picks the creative type

Within .interstitial, the SDK reads creative_type from the server response and automatically renders the correct view (image, video, or StoreKit). Whether a reward is involved is determined by placement.rewardEnabled — not the format. You use the same onReward callback regardless.
swift
public enum AdFormat: String {
    case banner        // inline strip
    case interstitial  // full-screen (image / video / rewarded / app store — server decides)

    public var isFullScreen: Bool { self == .interstitial }
}

The SDK will not show any ad if adsEnabled is false in the bootstrap config.

5. Initialization

No additional setup is needed beyond the standard GrowthCat.initialize call. The ads engine starts automatically as part of the bootstrap.

swift
import SwiftUI
import GrowthCat

@main
struct MyApp: App {
    init() {
        GrowthCat.initialize(
            apiKey: "gc_live_your_key_here",
            logsEnabled: true   // set false in production
        )
    }

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

What happens on initialize

  1. SDK stores your API key and resolves the workspace (sandbox in DEBUG, live in release).
  2. A background task calls GET /v1/sdk/config to fetch the bootstrap.
  3. Bootstrap includes: ads_enabled, allowed formats, cache TTL, and offline queue cap.
  4. The AdService is created and configured with the returned ads settings.
  5. All subsequent calls to GrowthCat.shared are ready.

6. SDK config flags

The bootstrap from GET /v1/sdk/config exposes ads-specific feature flags your app can read to decide whether to show ad-related UI before attempting to load any ads.

swift
// Check if ads are enabled at all
if GrowthCat.shared.sdkBootstrap?.adsConfig.adsEnabled == true {
    prefetchAdCatalogs()
    showAdEntryPoints()
}

// Read the full ads config
if let adsConfig = GrowthCat.shared.sdkBootstrap?.adsConfig {
    print("Ads enabled: \(adsConfig.adsEnabled)")
    print("Cache TTL: \(adsConfig.adCacheTTLSeconds)s")
    print("Max offline events: \(adsConfig.maxOfflineAdEvents)")
}

To refresh manually, for example after the app returns to the foreground:

swift
Task {
    let config = try await GrowthCat.shared.refreshSDKConfig()
    print("Ads enabled: \(GrowthCat.shared.sdkBootstrap?.adsConfig.adsEnabled ?? false)")
}

GrowthCatSDKAdsConfig fields

  • adsEnabled — master switch. false means no ads are shown.
  • adCacheTTLSeconds — default catalog TTL, overridden per-response when the server sends a tighter TTL.
  • maxOfflineAdEvents — max events queued offline (default 500). Oldest events are dropped when the cap is exceeded.

8. Interstitial ads

Interstitials are presented modally. Bind a Bool state variable and set it to true when you want the ad to appear — for example, between game levels or after completing a task. The SDK reads creative_type from the server response and automatically renders the right view. You use the same code regardless of whether the server sends an image, a video, or an App Store install creative.

swift
import SwiftUI
import GrowthCat

struct LevelCompleteView: View {
    @State private var showInterstitial = false

    var body: some View {
        VStack {
            Text("Level Complete!")
            Button("Continue") {
                showInterstitial = true
            }
        }
        .overlay(
            GrowthCatAdView(
                placementKey: "level_complete_interstitial",
                appUserID: "user_123",
                isPresented: $showInterstitial,
                onDismiss: { showInterstitial = false }
            )
        )
    }
}

Image creative (creative_type: "image")

  • Full-screen modal with headline, body, and a CTA button.
  • Countdown in the top-right corner. After skippable_after_seconds, the X button appears.
  • If skippable_after_seconds is null, close appears after 3 seconds.
  • impression fires immediately on presentation.
  • click fires if the user taps the CTA.
  • ad_closed fires on dismiss.

Video creative (creative_type: "video")

  • Full-screen modal with auto-playing video (muted by default).
  • Countdown replaces the X button until skippable_after_seconds is reached.
  • If skippable_after_seconds is null, cannot close until video finishes.
  • video_start + impression fire when playback begins.
  • video_progress fires at 25%, 50%, and 75%.
  • video_complete fires when video reaches the end.

9. Rewarded ads

A rewarded ad is an interstitial placement where placement.rewardEnabled == true. The server sets this per placement in your dashboard — no special format enum is needed. The in-app reward is only granted after POST /v1/ads/reward/validate returns reward_validated: true.

appUserID is required for rewarded ads

The server needs it to attribute the reward to the correct user. If you omit it, the reward validation call will fail and no reward will be granted.
swift
import SwiftUI
import GrowthCat

struct CoinStoreView: View {
    @State private var showRewardedAd = false
    @State private var coins: Int = 0

    var body: some View {
        VStack(spacing: 24) {
            Text("You have \(coins) coins")
                .font(.title)

            Button("Watch ad to earn 50 coins") {
                showRewardedAd = true
            }
            .buttonStyle(.borderedProminent)
        }
        .overlay(
            GrowthCatAdView(
                placementKey: "rewarded_coins",
                appUserID: "user_123",            // REQUIRED for rewarded ads
                isPresented: $showRewardedAd,
                onReward: { response in
                    // Only called when reward_validated == true from the server.
                    // response.reward contains { name: "Coins", amount: 50 }
                    coins += Int(response.reward?.amount ?? 0)
                    showRewardedAd = false
                },
                onDismiss: {
                    showRewardedAd = false
                }
            )
        )
    }
}

Reward flow step by step

  1. User taps "Watch ad" → showRewardedAd = true.
  2. The SDK presents the rewarded ad full-screen.
  3. The user views the image or video.
  4. Once the user has watched for minimum_view_seconds (set in your dashboard), the SDK calls POST /v1/ads/reward/validate with the actual elapsed seconds.
  5. Server responds: { reward_validated: true, reward: { name: "Coins", amount: 50 } }.
  6. The SDK calls your onReward closure — this is the only safe place to grant the reward.
  7. If the network is unavailable, the SDK does not grant the reward and does not retry silently. The user must watch again when online.

User closes early?

For rewarded placements the close button is hidden until minimum_view_seconds elapses (or the video finishes). The user cannot skip early, regardless of whether the creative is an image or video.

Network unavailable?

The SDK throws. The reward is not granted. No pending reward is stored locally — the user must watch the ad again when they are online.

10. App Store install ads

App Store install ads promote another app using StoreKit. When the user taps the CTA, the SDK presents either an SKOverlay anchored to the bottom or an SKStoreProductViewController as a full modal, depending on the presentation value in the ad object.

swift
GrowthCatAdView(
    placementKey: "app_store_promo",
    appUserID: "user_123",
    isPresented: $showAppStoreAd,
    onDismiss: { showAppStoreAd = false }
)

What the SDK handles automatically

  • impression fires when the card appears.
  • click fires when the user taps the CTA button (before StoreKit appears).
  • StoreKit is presented — SKOverlay for overlay, SKStoreProductViewController for product_view.
  • ad_closed fires when the user dismisses your ad card (regardless of whether they installed).

11. Unified GrowthCatAdView

GrowthCatAdView is the single public entry point for all non-banner formats. It automatically detects the format of the loaded ad and renders the correct view — you never need to check the format yourself. There are two ways to initialize it.

Placement-key initializer (dashboard-configured slot)

Use this when you have a named placement key created in your GrowthCat dashboard. The SDK calls GET /v1/ads/catalog?placement_key=… to fetch the best ad for that slot.

swift
GrowthCatAdView(
    placementKey: "my_placement",
    appUserID: currentUser.id,
    sessionID: currentSessionID,
    isPresented: $isAdPresented,
    onReward: { response in
        // Reward granted (rewarded formats only).
        grantUserReward(response.reward)
    },
    onDismiss: {
        isAdPresented = false
    }
)

Format-based initializer (automatic format selection)

Use this when you want the SDK to find the best available ad for a given format without a fixed placement key. The SDK calls GET /v1/ads/serve?format=…, which accepts banner or interstitial.

swift
// Show a full-screen ad (image, video, rewarded, or app store install — server decides).
GrowthCatAdView(
    format: .interstitial,
    appUserID: currentUser.id,
    sessionID: currentSessionID,
    isPresented: $isAdPresented,
    onReward: { response in
        // Called only if the server returns a rewarded creative.
        grantUserReward(response.reward)
    },
    onDismiss: { isAdPresented = false }
)

// Show a banner using format-based selection.
GrowthCatAdView(
    format: .banner,
    appUserID: currentUser.id
)

The SDK sends the format's rawValue directly to the server — GET /v1/ads/serve?format=banner or …?format=interstitial. The server picks the best creative for the slot and the SDK renders it automatically.

ParameterTypeRequiredDescription
placementKeyStringYes (placement init)Matches the placement key in your GrowthCat dashboard.
formatAdFormatYes (format init)The ad format to request via /v1/ads/serve. Either .banner or .interstitial.
appUserIDString?Yes for rewardedYour app's user identifier. Required for reward attribution.
sessionIDString?RecommendedUse GrowthCat.makeSessionID() to correlate events in one session.
bannerHeightCGFloatNoMinimum height in points for banner format. Default: 60.
bannerStyleBannerStyleNo.standard (full-width) or .rounded(cornerRadius:horizontalPadding:). Default: .rounded().
bannerBackgroundColorColor?NoOverride the banner background. nil uses secondarySystemBackground.
isPresentedBinding<Bool>Yes for non-bannerDrives the .fullScreenCover. Set to true to show.
onReward((AdRewardValidationResponse) -> Void)?NoCalled when reward is validated by the server.
onDismiss(() -> Void)?NoCalled when the ad is closed. Set isPresented = false here.

12. Manual ad loading and event tracking

If you want full control — for example, to pre-check whether an ad is available before showing a "Watch Ad" button — you can load ads and fire events manually.

Loading an ad manually

loadAd returns nil when ads are disabled, there is no fill, the format is not allowed, or the cache has expired and the network is unavailable. It only throws for hard errors (bad API key, server 5xx, etc.).

swift
let ad = try? await GrowthCat.shared.loadAd(placementKey: "rewarded_coins")

if ad != nil {
    showWatchAdButton = true
} else {
    // No fill — hide the button or show an alternative.
}

Firing events manually

The SDK fires all events automatically from GrowthCatAdBannerView and GrowthCatAdView. Only use the manual API if you are building a fully custom renderer.

swift
// Fire an impression when your custom ad becomes visible.
GrowthCat.shared.trackAdEvent(
    .impression,
    for: ad,
    placementKey: "rewarded_coins",
    appUserID: "user_123",
    sessionID: sessionID
)

// Fire a click when the user taps your CTA.
GrowthCat.shared.trackAdEvent(
    .click,
    for: ad,
    placementKey: "rewarded_coins",
    appUserID: "user_123",
    sessionID: sessionID
)

// Fire video progress with metadata.
GrowthCat.shared.trackAdEvent(
    .videoProgress,
    for: ad,
    placementKey: "rewarded_coins",
    appUserID: "user_123",
    sessionID: sessionID,
    metadata: ["progress_pct": "50"]
)
EventWhen to fire
.impressionAd is visible and fully rendered (once per ad show).
.clickUser taps the CTA (once per tap).
.videoStartVideo begins playing.
.videoProgressAt 25%, 50%, and 75% of video duration. Pass "progress_pct" in metadata.
.videoCompleteVideo reaches the end.
.adClosedUser dismisses or closes the ad.
.adReportedUser taps "Report this ad" (if you expose that option).

Do not fire reward_validated manually

It is created internally by the server when you call POST /v1/ads/reward/validate. Never call trackAdEvent(.rewardValidated, ...) yourself.

Batching strategy

  • impression and click are flushed immediately (single HTTP call).
  • Video progress and other events are flushed every 10 events or every 30 seconds, whichever comes first.
  • All pending events are flushed when the app moves to the background.

13. Reward validation in detail

Call validateAdReward when you want to validate a reward from a fully custom ad renderer. When using GrowthCatAdView, this is handled automatically — your onReward closure is only called after the server confirms.

swift
do {
    let response = try await GrowthCat.shared.validateAdReward(
        for: ad,
        appUserID: "user_123",       // required
        sessionID: currentSessionID,
        viewedSeconds: 30,           // actual elapsed seconds the user viewed
        completed: true              // true if video played to end
    )

    if response.rewardValidated {
        let reward = response.reward
        print("Reward: \(reward?.amount ?? 0) \(reward?.name ?? "")")
        grantReward()
    }
} catch {
    // Network unavailable or server error — do NOT grant the reward.
    // The user must watch the ad again when online.
    print("Reward validation failed: \(error)")
}

Rules — these are non-negotiable

  • Never grant the reward before calling this endpoint.
  • Never grant the reward if the call throws or returns reward_validated: false.
  • If the call fails due to a network error, do not queue a "pending reward" locally — the user must watch again when online.
  • The server internally records the reward_validated event. Do not call trackAdEvent for it yourself.

14. Offline behavior

The SDK handles offline gracefully at every layer. Events are persisted to a JSON file in the app's Caches directory and replayed automatically when connectivity is restored via NWPathMonitor.

ScenarioWhat happens
Ad catalog fetch fails (offline)Returns cached entry if TTL has not expired; returns nil (no fill) otherwise.
Creative asset not pre-fetchedAd view falls back to AsyncImage with the remote URL.
Event tracking (offline)Events are persisted to a JSON file in the app's Caches directory.
App comes back onlineNWPathMonitor detects connectivity and replays the offline queue automatically.
Reward validation (offline)The SDK throws. The reward is not granted. No pending reward is stored locally.
Offline queue cap exceededOldest events are dropped to make room for new ones. Default cap: 500, controlled by max_offline_ad_events.

You can manually flush the event queue at any time:

swift
Task {
    await GrowthCat.shared.flushAdEvents()
}

15. Prefetching on foreground

To ensure ads are ready the moment your users need them (no loading delay), prefetch catalogs when the app returns to the foreground. Prefetching runs in the background and never blocks the UI. It respects the catalog cache TTL — if the cached entry is still valid, no network request is made. Creative media is cached separately using the server-providedasset_cache hints.

GET /v1/sdk/apps/:appId/ads/catalog?placement_key=home_banner

{
  "placement_key": "home_banner",
  "cache_ttl_seconds": 300,
  "media_cache_ttl_seconds": 604800,
  "ads": [
    {
      "creative": {
        "id": "uuid",
        "creative_type": "video",
        "public_asset_url": "https://media.example.com/ads/...",
        "asset_cache": {
          "cacheable": true,
          "ttl_seconds": 604800,
          "strategy": "prefetch"
        }
      }
    }
  ]
}

Media cache rules

  • cache_ttl_seconds controls the placement catalog cache.
  • media_cache_ttl_seconds is the default disk TTL for creative files.
  • creative.asset_cache.ttl_seconds can override the media TTL per creative.
  • asset_cache.strategy: prefetch means the SDK should download the asset before rendering when possible.
  • R2 is the v1 creative storage backend; the SDK should treat public_asset_url as the source of truth.
swift
import SwiftUI
import GrowthCat

@main
struct MyApp: App {
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup { ContentView() }
            .onChange(of: scenePhase) { _, newPhase in
                if newPhase == .active {
                    GrowthCat.shared.prefetchAdCatalogs(placementKeys: [
                        "home_banner",
                        "level_complete_interstitial",
                        "rewarded_coins"
                    ])
                }
            }
    }
}

16. Flushing events on background

Register a background task to flush any in-memory pending events before the system suspends your app. Losing events means losing attribution data for impressions and clicks.

Using the SwiftUI scene lifecycle (recommended):

swift
.onChange(of: scenePhase) { _, newPhase in
    if newPhase == .background {
        Task { await GrowthCat.shared.flushAdEvents() }
    }
}

Using UIApplicationDelegate:

swift
func applicationDidEnterBackground(_ application: UIApplication) {
    let task = application.beginBackgroundTask(withName: "GrowthCatFlush")
    Task {
        await GrowthCat.shared.flushAdEvents()
        application.endBackgroundTask(task)
    }
}

Background time is limited

Use UIApplication.beginBackgroundTask to extend the deadline if you need more than a few seconds. The SDK's flush is typically fast (one HTTP batch call), but the OS can suspend the app before it completes without the extension.

17. Error handling

All GrowthCat operations throw GrowthCatError. Ads operations follow the same error taxonomy as referral operations.

swift
do {
    let ad = try await GrowthCat.shared.loadAd(placementKey: "home_banner")
    // ad == nil → no fill. ad != nil → ready to show.
} catch GrowthCatError.unauthorized {
    // SDK key is wrong. Check your dashboard configuration.
} catch GrowthCatError.network {
    // No connectivity. The catalog cache will be used if still valid.
} catch GrowthCatError.rateLimited(let retryAfter) {
    // Respect the retry-after interval before attempting again.
    print("Retry after \(retryAfter ?? 60)s")
} catch {
    print("Unexpected error: \(error)")
}
HTTP statusSDK behavior
200 / 202Process normally.
400Throw .server. Do not retry.
401Throw .unauthorized. Check your API key.
403Throw .server. Discard the request.
404 (catalog)Return no-fill (nil). Not an error.
429Throw .rateLimited(retryAfter:). Back off.
5xxThrow .server. Retry with exponential backoff (max 3).
Network errorThrow .network. Queue ad events offline.

18. API reference

Full type signatures for ads-related APIs on GrowthCatClient.

GrowthCatClient — ads methods

swift
// Load an ad for a placement. Returns nil on no-fill or if ads are disabled.
func loadAd(placementKey: String) async throws -> AdObject?

// Fire a tracking event for an ad.
func trackAdEvent(
    _ eventName: AdEventName,
    for ad: AdObject,
    placementKey: String,
    appUserID: String? = nil,
    sessionID: String? = nil,
    metadata: [String: String]? = nil
)

// Validate a reward server-side. Only grant the reward if response.rewardValidated == true.
func validateAdReward(
    for ad: AdObject,
    appUserID: String,
    sessionID: String? = nil,
    viewedSeconds: Int,
    completed: Bool
) async throws -> AdRewardValidationResponse

// Start background downloads for the given placement keys.
func prefetchAdCatalogs(placementKeys: [String])

// Flush all pending in-memory events to the server (or offline queue).
func flushAdEvents() async

AdFormat

swift
public enum AdFormat: String {
    case banner        // inline strip
    case interstitial  // full-screen (image / video / rewarded / app store — server decides)

    public var isFullScreen: Bool { self == .interstitial }
}

AdObject

Returned by loadAd. You rarely need to access its fields directly when using GrowthCatAdView, but they are available for custom renderers.

swift
public struct AdObject {
    public let placement: AdPlacement?   // nil when using the format-based serve endpoint
    public let campaign: AdCampaign      // id, mode, objective, priorityWeight
    public let creative: AdCreative      // id, publicAssetURL, headline, body, ctaText, storeKit, layout
    public let tracking: AdTracking      // allocationID, token
    public let closePolicy: AdClosePolicy?
}

public struct AdPlacement {
    public let id: String
    public let format: AdFormat          // .banner or .interstitial
    public let rewardEnabled: Bool       // true → onReward callback will fire when validated
    public let rewardName: String?
    public let rewardAmount: Double?
    public let minimumViewSeconds: Int?
    public let isSkippable: Bool
    public let skippableAfterSeconds: Int?
}

public struct AdClosePolicy {
    public let isSkippable: Bool?
    public let skippableAfterSeconds: Int?
    public let rewardGrantAfterSeconds: Int?
}

AdRewardValidationResponse

swift
public struct AdRewardValidationResponse {
    public let rewardValidated: Bool
    public let accepted: Int
    public let reward: AdReward?
}

public struct AdReward {
    public let name: String    // e.g. "Coins"
    public let amount: Double  // e.g. 50
}

AdEventName

swift
public enum AdEventName: String {
    case impression
    case click
    case videoStart     = "video_start"
    case videoProgress  = "video_progress"
    case videoComplete  = "video_complete"
    case adClosed       = "ad_closed"
    case adReported     = "ad_reported"
}

GrowthCatSDKAdsConfig

Available via GrowthCat.shared.sdkBootstrap?.adsConfig.

swift
public struct GrowthCatSDKAdsConfig {
    public let adsEnabled: Bool          // Master switch. False → no ads shown.
    public let adCacheTTLSeconds: Int    // Default catalog TTL (overridden per response).
    public let maxOfflineAdEvents: Int   // Max events queued offline (default 500).
}

BannerStyle

swift
public enum BannerStyle {
    case standard
    case rounded(cornerRadius: CGFloat = 14, horizontalPadding: CGFloat = 16)
}

UI components

swift
// Inline banner ad
GrowthCatAdBannerView(
    placementKey: String,
    appUserID: String?,
    sessionID: String?,
    minHeight: CGFloat,           // default 60
    style: BannerStyle,           // default .rounded()
    backgroundColor: Color?,
    onTap: (() -> Void)?
)

// Placement-key initializer — all non-banner formats
GrowthCatAdView(
    placementKey: String,
    appUserID: String?,
    sessionID: String?,
    bannerHeight: CGFloat,
    bannerStyle: BannerStyle,
    bannerBackgroundColor: Color?,
    isPresented: Binding<Bool>,
    onReward: ((AdRewardValidationResponse) -> Void)?,
    onDismiss: (() -> Void)?
)

// Format-based initializer — uses /v1/ads/serve
GrowthCatAdView(
    format: AdFormat,
    appUserID: String?,
    sessionID: String?,
    bannerHeight: CGFloat,
    bannerStyle: BannerStyle,
    bannerBackgroundColor: Color?,
    isPresented: Binding<Bool>,
    onReward: ((AdRewardValidationResponse) -> Void)?,
    onDismiss: (() -> Void)?
)