Skip to content

Banner ads

How do I show a banner ad in Compose Multiplatform?

Section titled “How do I show a banner ad in Compose Multiplatform?”

Size and refresh behaviour come from the placement, not from composable parameters, which is why the same banner behaves identically whether it is driven from Compose or headlessly. The full AdMob Compose Multiplatform banner integration:

val placement = remember {
AdPlacement(
id = "banner_home",
format = AdFormat.Banner,
adUnitIds = AdUnitIds(
android = TestAdIds.ANDROID_BANNER,
ios = TestAdIds.IOS_BANNER,
),
)
}
BannerAdView(
placement = placement,
modifier = Modifier.fillMaxWidth(),
onEvent = { event ->
when (event) {
is AdEvent.Loaded -> println("banner loaded")
is AdEvent.LoadFailed -> println("banner failed: ${event.error}")
is AdEvent.Paid -> println("revenue: ${event.paidEvent.value.valueMicros}")
else -> Unit
}
},
)

BannerAdView resolves the width from its constraints (overridable with the widthDp parameter), sizes its height from the returned ad size, and clears the controller on dispose. The remember around the placement matters because controllers are cached per placement id for the manager’s lifetime and are never auto-evicted, so ids must be static and finite. The architecture reference explains why the cache exists.

A subtle but important property: the same BannerAdView composable is reused across recompositions of the same screen, so its onEvent callback fires for every state change of the placement. The recommendation is to hoist ad events to a single collector rather than react in place, both for analytics accuracy and to keep recomposition work bounded. The revenue and paid events guide has the full event-stream pattern.

AdPlacement(
id = "banner_home",
format = AdFormat.Banner,
adUnitIds = AdUnitIds(android = "…", ios = "…"),
bannerSizePolicy = AdSizePolicy.LargeAnchoredAdaptive(), // default
)
PolicyWhat it maps toUse it when
AdSizePolicy.LargeAnchoredAdaptive(collapsible)Large anchored adaptiveThe banner is pinned to the top or bottom of the screen. This is the default.
AdSizePolicy.InlineAdaptive(maxHeightDp)Inline adaptiveThe banner sits inside scrolling content and may be taller.
AdSizePolicy.Fixed(widthDp, heightDp)A fixed custom sizeYou need an exact size and accept lower fill.
AdSizePolicy.FluidFluidThe container dictates the size.

Anchored adaptive is the right default because it lets AdMob pick the best height for the device width. Inline adaptive is for feeds, where maxHeightDp bounds how much of the viewport an ad may take. Fixed sizes reduce eligible demand and should be a deliberate choice — they make sense for a custom layout slot that other UI elements measure against, and almost nowhere else.

A practical note on heights: anchored adaptive returns a height that matches the device’s current orientation. A banner at the bottom of the screen in portrait may grow when the device rotates to landscape, which is correct behaviour for an anchored ad and is the reason the size comes from the platform SDK rather than from a fixed number.

bannerSizePolicy = AdSizePolicy.LargeAnchoredAdaptive(
collapsible = CollapsiblePlacement.Bottom,
)

CollapsiblePlacement is Top or Bottom. Collapsible is a property of the anchored adaptive policy rather than a separate format. The part that wastes people’s afternoons: regular banner test ids never serve collapsible fill. Testing requires TestAdIds.ANDROID_COLLAPSIBLE_BANNER and TestAdIds.IOS_COLLAPSIBLE_BANNER. With regular test ids, the banner loads and renders normally, but never collapses, which looks like a bug in the size policy rather than a test-id mismatch.

Collapsible banners expand on a user tap and stay expanded until the user dismisses them, then return to a small anchor at the configured edge of the screen. They are subject to AdMob’s collapsible-specific policies, and the publisher’s AdMob account must have collapsible enabled — a unit from an account without collapsible enabled behaves the same as a regular banner.

AdPlacement(
id = "banner_home",
format = AdFormat.Banner,
adUnitIds = AdUnitIds(android = "…", ios = "…"),
bannerRefreshPolicy = BannerRefreshPolicy.SdkManaged(60.seconds),
)
PolicyBehaviour
BannerRefreshPolicy.AdServerManagedThe default. No client timer; configure refresh in the AdMob UI.
BannerRefreshPolicy.SdkManaged(interval)Client-side reload every interval, enforced to the 30s–120s range, only while the app is foregrounded, and it waits for an in-flight load to settle.
BannerRefreshPolicy.ManualNo automatic load at all. Call adManager.banner(placement).refresh() yourself.

The SdkManaged 30s–120s range is enforced because AdMob’s policy treats banners refreshing faster than 30 seconds as invalid traffic. Outside that range, the constructor clamps silently rather than throwing, which is the design choice that lets the same placement work across reasonable timer settings without forcing the caller to validate.

A banner’s width is an input, not something the SDK can always discover. BannerAdView measures its own container and supplies it. A headless caller must supply it:

adManager.banner(placement).load(geometry = BannerGeometry(widthDp = 320))
BannerAdViewmeasures its own containerHeadless callerload(geometry = null)BannerCore.load()geometry · sizePolicy · requestOptionsresolve the widthgeometry?.widthDp ?: fallbackWidthDp()widthDp resolved?load fails with an explicit errorBannerCore owns the failure policynoplatform.resolveSize()sizePolicy + widthDp → platform ad sizeyesplatform.loadBanner()size · sizePolicy · options · generationThe fallback is nullable on both platformsAndroid: the current Activity; null whenthere is none.iOS: the key window's bounds — neverUIScreen.mainScreen, which sized bannerswrong in Split View, Slide Over, popovers.refresh() and registerGeometry()refresh() replays the WHOLE resolvedrequest: geometry + size policy + options.registerGeometry() records a measured widthwithout loading, for the Manual policy.A controller never reaches for an Activityor a UIScreen.CLAUDE.md invariant #6 — geometry is a host-supplied input, never something the controller reaches for.
Banner geometry: host-supplied width to resolved adaptive sizeCLAUDE.md invariants #6Read this diagram in words

load() previously took (sizePolicy, requestOptions) and resolved its own width — from an Activity on Android and from UIScreen.mainScreen on iOS. The iOS path silently produced full-screen width in iPad split view, Slide Over and popovers, sizing every banner wrong with no error. Width is now a host-supplied input: load(geometry, sizePolicy, requestOptions). Existing no-argument load() calls still compile because geometry defaults to null, but a headless call with no geometry now fails rather than guessing when the platform cannot resolve a width.

refresh() replays the whole resolved request — geometry, size policy and request options — from the most recent load(). It previously kept only the resolved size and rebuilt options from placement.requestOptions, silently dropping any custom AdRequestOptions the original load() was given. It fails if nothing has been loaded yet.

The fix is part of the 1.1.0 release and is the kind of behaviour change that is invisible until the day you depend on it. The changelog entry for 1.1.0 calls it out explicitly.

Yes. adManager.banner(placement) returns a BannerAdController with load(geometry, sizePolicy, requestOptions), refresh(), clear(), loadState: StateFlow<AdLoadState>, events: SharedFlow<AdEvent>, and placement. The controller does not host a view — the caller is responsible for that. BannerAdView is the recommended path for anything Compose-rendered, because it measures its own container correctly; the headless path is for custom Android Views, for UIKit code, and for unit tests that need to drive a banner directly.

A typical headless use case is a banner embedded in an AndroidView inside Compose, where the underlying view is a real platform widget the app manages. The controller API gives that widget a loadState it can observe and an events flow it can pipe into the rest of the app’s analytics. The geometry parameter is mandatory in this case, because there is no Compose Modifier to measure.

Each line is one common cause:

  • The manager is not AdManagerStatus.Ready yet. Gate on status before placing any BannerAdView.
  • Consent has not been granted, so loads fail with AdErrorCode.CONSENT_REQUIRED. Check adManager.consent.canRequestAds.
  • The policy is Manual and nothing has called refresh(). Switch to SdkManaged or compose the view and call refresh() explicitly.
  • The ad unit has no fill right now, which surfaces as GMA code 3 on Android and 1 on iOS. That is normal; it is not a configuration error and is not retried.
  • A headless load() was issued with no geometry on a platform that cannot resolve a width. The library now fails rather than guessing.

The full symptom table is on Troubleshooting.

One more thing worth knowing: a banner that loaded once and is now returning “no fill” is not a configuration error. AdMob’s fill rate fluctuates by region, time of day, and ad unit age, and the right behaviour is to keep retrying on the configured refresh schedule. Retrying more aggressively — for instance, on every recomposition — risks invalid-traffic flags.