Skip to content
mcavusPublic

About

Self-served ad slots for SwiftUI. Your own inventory, weighted rotation, scheduling, targeting, and frequency caps.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

AdSlotKit

A tiny, self-served ad-slot framework for SwiftUI apps. You own the inventory and serve your own creatives (house cross-promotion today, direct-sold sponsorships later) instead of handing the slot to an ad network.

  • Privacy-preserving. Built without any advertising or tracking SDK: it renders the creatives you supply and reports interaction events back to you, and never touches the advertising identifier.
  • You control everything. Inventory, targeting, scheduling, pacing, and rotation are plain data you author in code or JSON (and can move to your own server without changing any framework code).
  • Two slot formats out of the box: an inline banner and a full-screen interstitial.
  • Brand-neutral by default, themeable to match your app via AdSlotStyle.

In the wild

Screens from Cabine, a photo-booth app on the App Store that uses AdSlotKit for house cross-promotion. One engine serves both formats, themed to the app through AdSlotStyle: the inline banner collapses to nothing when no ad is eligible, and the interstitial uses the app's own full-screen layout over the same selection and frequency-capping.

Two inline banners on a home screen
Inline banners, top and bottom of a screen
A full-screen launch interstitial
Launch interstitial, capped once a day

Installing

Add the package to your Package.swift or Xcode project:

.package(url: "https://github.com/mcavus/AdSlotKit.git", from: "0.1.0")
import AdSlotKit

Quick start

1. Describe your inventory, the ads you control:

let inventory = StaticAdInventory([
    AdCreative(
        id: "promo.filmcamera",
        format: .banner,
        image: .asset(name: "PromoFilmCamera"),
        headline: "Try FilmCamera",
        body: "Our vintage camera app, from the same studio.",
        callToAction: "Get",
        destination: URL(string: "https://apps.apple.com/app/id000000000")
    ),
    AdCreative(
        id: "promo.launch",
        format: .interstitial,
        image: .symbol("sparkles"),
        headline: "More from us",
        body: "Discover the rest of our apps.",
        callToAction: "Browse",
        destination: URL(string: "https://apps.apple.com/developer/id000000000"),
        frequencyCap: .perDay(1)          // never more than once a day
    ),
])

2. Build an engine and inject it:

let engine = AdSlotKit.makeEngine(
    inventory: inventory,
    impressionKey: "myapp.ads.impressions",
    policy: AdPolicy(
        // Paying users see no ads, evaluated live on every request.
        isSuppressed: { context in context.flags.contains("is_premium") }
    ),
    eventHandler: AdEventHandlerClosure { event in
        // Forward to your own analytics however you like.
    },
    contextProvider: { date in
        AdContext(date: date, flags: MyApp.currentAdFlags())
    }
)

RootView()
    .environment(engine)
    .adSlotStyle(myBrandStyle)   // optional, omit for the neutral default

3. Place a banner. It renders nothing when there is no eligible ad, so it is safe to leave in place:

let feedBanner = AdPlacement(id: "feed_banner", format: .banner)

ScrollView {
    // ...your content...
    AdBannerView(placement: feedBanner)
        .padding()
}

4. Present an interstitial. Ask first, present only if there is something to show:

let launch = AdPlacement(id: "launch", format: .interstitial)

@State private var pendingAd: AdCreative?

SomeView()
    .onAppear { pendingAd = engine.creative(for: launch) }
    .adInterstitial(launch, item: $pendingAd)

How selection works

engine.creative(for:) runs each request through AdSelector, a pure, deterministic function of its inputs. A creative is eligible when all of these hold:

  1. its format matches the placement,
  2. weight > 0,
  3. it is inside its schedule flight window,
  4. its targeting matches the current AdContext,
  5. it is not frequency-capped (per-creative cap, or the engine's defaultFrequencyCap).

Among the eligible set, one is chosen by weighted rotation. Because the clock, impression store, and random generator are all injected, selection is fully unit-testable. See Tests/AdSlotKitTests.

Theming

The built-in views read an AdSlotStyle from the environment. Override it to match your app:

.adSlotStyle(
    AdSlotStyle(
        backgroundColor: .init(red: 0.94, green: 0.91, blue: 0.85),
        accentColor: .init(red: 0.65, green: 0.50, blue: 0.22),
        cornerRadius: 18,
        assetBundle: .main
    )
)

Server-driven inventory

AdInventorySource is a protocol. StaticAdInventory is the built-in in-memory implementation, and it can decode an AdInventoryManifest from JSON:

let inventory = try StaticAdInventory(manifestData: data)

A creative is fully JSON-expressible, including its artwork: AdImageSource has a .remote(URL) case, so the image itself can live on your CDN. To run or update campaigns without shipping an app release, fetch that JSON from your own endpoint (or a config service) and build the inventory from it. Nothing else changes.

Requirements

  • iOS 17 / macOS 14 (uses the Observation framework).
  • The interstitial presentation modifier is iOS/tvOS/watchOS (it uses fullScreenCover); the banner works on macOS too.

License

MIT. See LICENSE.

About

Self-served ad slots for SwiftUI. Your own inventory, weighted rotation, scheduling, targeting, and frequency caps.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages