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
bannerand a full-screeninterstitial. - Brand-neutral by default, themeable to match your app via
AdSlotStyle.
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.
![]() Inline banners, top and bottom of a screen |
![]() Launch interstitial, capped once a day |
Add the package to your Package.swift or Xcode project:
.package(url: "https://github.com/mcavus/AdSlotKit.git", from: "0.1.0")import AdSlotKit1. 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 default3. 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)engine.creative(for:) runs each request through AdSelector, a pure,
deterministic function of its inputs. A creative is eligible when all of
these hold:
- its
formatmatches the placement, weight > 0,- it is inside its
scheduleflight window, - its
targetingmatches the currentAdContext, - 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.
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
)
)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.
- iOS 17 / macOS 14 (uses the Observation framework).
- The
interstitialpresentation modifier is iOS/tvOS/watchOS (it usesfullScreenCover); the banner works on macOS too.
MIT. See LICENSE.

