The monorepo from Build a 2D Game with SpriteKit in a Swift Monorepo holds three iOS apps built from shared packages. None of them earns anything. AdMob is Google’s service for showing ads in mobile apps: an app asks Google’s SDK for an ad, the SDK fetches one and draws it, and the app’s owner is paid for the ads people see and tap. Adding it means a third-party SDK, a consent question that must be settled before the first ad request, Apple’s tracking prompt, Info.plist entries Apple and Google check for, and ad code that is easy to copy into every app slightly differently.
You put all of that in one place: an Ads package that every app in the
monorepo can link, holding Google’s Mobile Ads SDK and the code around it. Shopping gets a banner, a strip of ad along the bottom of
the screen. Catcher gets an interstitial, a full-screen ad shown after
every second game, and a rewarded ad, one the player chooses to watch in
return for one more life when a game is lost. You finish by running both apps
on the simulator and watching Google’s test ads appear: a message from Google
explaining tracking, Apple’s tracking prompt, a banner under the shopping list, a rewarded video
that gives Catcher a life back, and an interstitial between games. TodoList
does not link Ads, carries none of Google’s code, and make changes shows
that none of this work touched it. Every ad comes from Google’s published
test IDs, and a Debug build stops at launch on any other.
What you end up with
Packages/Ads, a local package that wraps Google’s Mobile Ads SDK and its User Messaging Platform, the SDK that asks for consent: the consent flow, a SwiftUI banner, interstitial and rewarded ads, and the AdMob settings every app that shows ads includes.- Tests that run on the Mac for the parts of
Adsthat do not need Google’s SDK, and for a new Catcher rule: one extra life per game. - Shopping with a banner, and Catcher with an interstitial every second game and a rewarded ad that buys one more life.
- Shopping 1.2.0 and Catcher 1.1.0 released and tagged, with TodoList left alone.
Prerequisites
- The finished
my-appsfolder from Build a 2D Game with SpriteKit in a Swift Monorepo, with its Git history, theCatcher/v1.0.0tag, and theTodoList-iPhonesimulator. - Xcode 27 or later on a Mac with Apple silicon. Validated on Xcode 27.1 (build 27A9275) with Swift 6.4, on macOS 27.0.1, with Google Mobile Ads SDK 13.11.0 and User Messaging Platform 3.1.0.
- An internet connection. Swift Package Manager downloads Google’s SDKs from GitHub and Google’s servers, and the simulator fetches each test ad.
- No AdMob account and no Apple Developer account. Google’s test IDs serve ads to anyone.
- Steps 1, 7 and 9 are partly done in Xcode; a command after each part checks the result.
Run every command from inside my-apps.
Step 1: Create the Ads package and add it to the workspace
Ad code belongs in a package for the same reason CoreKit does: every app
that links it gets the same consent flow and the same ad behavior, and a fix
lands in all of them with one commit. The package is also where
Google’s SDK enters the monorepo, so an app that does not link Ads, such as
TodoList, never builds or ships Google’s code. Create it and delete the two
template files:
mkdir Packages/Ads
cd Packages/Ads
swift package init --type library
cd ../..
rm Packages/Ads/Sources/Ads/Ads.swift Packages/Ads/Tests/AdsTests/AdsTests.swift
Create the file
touch Packages/Ads/Package.swift
The file exists already. Replace its contents with the listing below.
Add the code: Packages/Ads/Package.swift
// swift-tools-version: 6.4
import PackageDescription
// Google's SDKs are iOS-only, so they are linked only when building for iOS.
// On the Mac, `swift test` builds and tests the rest of the package.
let iOSOnly = TargetDependencyCondition.when(platforms: [.iOS])
let package = Package(
name: "Ads",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(name: "Ads", targets: ["Ads"]),
],
dependencies: [
// Exact versions: an SDK update is a one-line change, made on purpose.
.package(url: "https://github.com/googleads/swift-package-manager-google-mobile-ads.git",
exact: "13.11.0"),
.package(url: "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git",
exact: "3.1.0"),
],
targets: [
.target(
name: "Ads",
dependencies: [
.product(name: "GoogleMobileAds",
package: "swift-package-manager-google-mobile-ads",
condition: iOSOnly),
.product(name: "GoogleUserMessagingPlatform",
package: "swift-package-manager-google-user-messaging-platform",
condition: iOSOnly),
],
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
.testTarget(
name: "AdsTests",
dependencies: ["Ads"],
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
]
)
Detailed breakdown
- Two remote packages, both Google’s. The Google Mobile Ads SDK requests
and draws ads. The User Messaging Platform (UMP) is Google’s SDK for
asking permission before ads use personal data, which the next steps call
consent. The ads SDK already depends on UMP, but
Adsimports UMP itself, so the manifest lists it rather than relying on the ads SDK to bring it in. The series builds with Xcode andmakealone, adding no other build tools; these are libraries the apps link, not build tools, so they fit that rule. condition: iOSOnlylinks them only into iOS builds. Google ships the SDKs as prebuilt iOS binaries with no macOS version. Without the condition,swift teston the Mac fails withWhile building for macOS, no library for this platform was found. With it, the Mac build ofAdscontains only the code that needs no SDK, which Step 2 writes and tests.exact:pins each version. Neitherxcodebuildnor Xcode 27.1 wrote aPackage.resolvedfile (the file that records the versions chosen) for this workspace, whose only remote packages come in through a local package. So the manifest is where the versions are fixed. Google renamed the SDK’s Swift types in version 12 (GADBannerViewbecameBannerView), so an update should be a commit you make, not one Swift Package Manager makes for you.
swift test writes a Package.resolved into the package folder when it
resolves the dependencies on the Mac. With the versions fixed in the
manifest it records nothing new, so keep it out of Git:
echo "Package.resolved" >> Packages/Ads/.gitignore
Add the package to the workspace in Xcode:
open MyApps.xcworkspace
- Click an empty area of the Project navigator so nothing is selected, and choose File → Add Files to “MyApps”….
- Select the
Packages/Adsfolder, click Add, and in the options sheet click Finish. Xcode downloads both SDKs, which can take a minute the first time.
grep '"group:Packages/Ads"' MyApps.xcworkspace/contents.xcworkspacedata
location = "group:Packages/Ads">
The Makefile needs to know about the package in two ways: make test
tests every package in PACKAGES, and make changes treats a commit to any
package in an app’s DEPS_ line as a change to that app. Shopping and
Catcher will show ads; TodoList will not. Ads depends on no other package in
the monorepo, so it can go anywhere in PACKAGES:
sed -i '' \
-e 's/^PACKAGES := CoreKit DesignSystem CatcherKit TodoKit$/& Ads/' \
-e 's/^DEPS_Shopping := CoreKit DesignSystem$/& Ads/' \
-e 's/^DEPS_Catcher := CatcherKit CoreKit$/& Ads/' Makefile
grep -E '^(PACKAGES|DEPS_)' Makefile
PACKAGES := CoreKit DesignSystem CatcherKit TodoKit Ads
DEPS_TodoList := TodoKit CoreKit DesignSystem
DEPS_Shopping := CoreKit DesignSystem Ads
DEPS_Catcher := CatcherKit CoreKit Ads
Step 2: Read ad unit IDs and pace interstitials
Most of Ads talks to Google’s SDK and can only run on iOS. Two pieces do
not, so swift test can cover them on the Mac: reading an app’s ad unit IDs, and
deciding which breaks between games show an interstitial. An ad unit is
one place an app shows ads, created in the AdMob console, and its ad unit
ID (such as ca-app-pub-3940256099942544/2435281174) is what the app sends
with each ad request. Each app also has one AdMob app ID, with a ~
instead of the /.
The IDs in this article are Google’s test IDs. Requests for them always
return Google’s sample ads, marked “Test mode”, and nobody is paid for them.
Real IDs during development put the AdMob account at risk: in Google’s words, “if you click on too many ads without being in
test mode, you risk your account being flagged for invalid activity”, and a
flagged account can be disabled. Every test unit ID starts with Google’s own
publisher ID (the number that identifies an AdMob account),
ca-app-pub-3940256099942544/, which is how the code below
recognizes one.
Create the file
touch Packages/Ads/Sources/Ads/AdUnits.swift
Add the code: Packages/Ads/Sources/Ads/AdUnits.swift
import Foundation
/// The AdMob ad unit IDs an app shows, read from its Info.plist. Each app
/// sets the ones it uses in its `.xcconfig` file; a format it does not use
/// is nil.
public struct AdUnits: Equatable, Sendable {
public var banner: String?
public var interstitial: String?
public var rewarded: String?
public init(banner: String? = nil, interstitial: String? = nil, rewarded: String? = nil) {
self.banner = banner
self.interstitial = interstitial
self.rewarded = rewarded
}
/// Reads `AdBannerUnitID`, `AdInterstitialUnitID` and `AdRewardedUnitID`.
/// An empty value, from a build setting the app does not set, is nil.
public init(info: [String: Any]) {
func unit(_ key: String) -> String? {
guard let id = info[key] as? String, !id.isEmpty else { return nil }
return id
}
self.init(banner: unit("AdBannerUnitID"),
interstitial: unit("AdInterstitialUnitID"),
rewarded: unit("AdRewardedUnitID"))
}
/// The IDs in the running app's Info.plist.
public static var main: AdUnits {
AdUnits(info: Bundle.main.infoDictionary ?? [:])
}
/// The start of every one of Google's test ad unit IDs.
public static let testPrefix = "ca-app-pub-3940256099942544/"
/// True when every ID that is set is one of Google's test ad units.
public var areAllTestUnits: Bool {
[banner, interstitial, rewarded]
.compactMap { $0 }
.allSatisfy { $0.hasPrefix(AdUnits.testPrefix) }
}
}
Create the file
touch Packages/Ads/Sources/Ads/InterstitialPacer.swift
Add the code: Packages/Ads/Sources/Ads/InterstitialPacer.swift
/// Decides which breaks between games show an interstitial: one after
/// every `every` finished games, so a player is not shown a full-screen ad
/// after each one.
public struct InterstitialPacer: Equatable, Sendable {
public let every: Int
private var finished = 0
public init(every: Int) {
precondition(every > 0, "every must be at least 1")
self.every = every
}
/// Call once for each finished game. Returns whether this break is the
/// one that shows an interstitial.
public mutating func gameFinished() -> Bool {
finished += 1
return finished.isMultiple(of: every)
}
}
Create the file
touch Packages/Ads/Tests/AdsTests/AdUnitsTests.swift
Add the code: Packages/Ads/Tests/AdsTests/AdUnitsTests.swift
import Testing
import Ads
struct AdUnitsTests {
@Test func readsTheUnitsAnAppSets() {
let units = AdUnits(info: [
"AdBannerUnitID": "ca-app-pub-3940256099942544/2435281174",
"AdInterstitialUnitID": "",
])
#expect(units.banner == "ca-app-pub-3940256099942544/2435281174")
#expect(units.interstitial == nil)
#expect(units.rewarded == nil)
}
@Test func recognizesGoogleTestUnits() {
#expect(AdUnits(banner: "ca-app-pub-3940256099942544/2435281174").areAllTestUnits)
#expect(AdUnits().areAllTestUnits)
#expect(!AdUnits(banner: "ca-app-pub-3940256099942544/2435281174",
rewarded: "ca-app-pub-0000000000000000/0000000000").areAllTestUnits)
}
}
Create the file
touch Packages/Ads/Tests/AdsTests/InterstitialPacerTests.swift
Add the code: Packages/Ads/Tests/AdsTests/InterstitialPacerTests.swift
import Testing
import Ads
struct InterstitialPacerTests {
@Test(arguments: [
(1, [true, true, true, true]),
(2, [false, true, false, true]),
(3, [false, false, true, false]),
])
func showsOneAfterEveryFewGames(every: Int, expected: [Bool]) {
var pacer = InterstitialPacer(every: every)
var shown: [Bool] = []
for _ in expected { shown.append(pacer.gameFinished()) }
#expect(shown == expected)
}
}
Detailed breakdown
AdUnitsreads the IDs from the app’s Info.plist, under three keys this article makes up:AdBannerUnitID,AdInterstitialUnitIDandAdRewardedUnitID. Step 6 fills them from each app’s.xcconfigfile, so the IDs sit next to the app’s version and bundle identifier instead of in Swift code. A key whose build setting the app never sets arrives as an empty string, whichinit(info:)turns into nil: Shopping has no rewarded ad, and itsrewardedis nil.areAllTestUnitschecks the publisher number at the start of each ID that is set. Step 4 uses it to stop a Debug build that has a real ad unit ID in it. An app with no units at all passes, since it requests nothing.InterstitialPacercounts finished games and says yes on everyeveryth one. A full-screen ad after every game can drive players away, and Google’s interstitial guidance asks for them only at natural breaks. Withevery: 2, Catcher shows one after the second, fourth and sixth games.- The tests call
gameFinished()before checking its result.#expect(pacer.gameFinished())does not compile: inside the#expectmacro’s expansion the value is immutable, and the error iscannot use mutating member on immutable value. The parameterized test collects the answers in an array first.
Run the tests. The first run resolves the dependencies, which downloads both SDKs on the Mac although the Mac build does not link them:
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/Ads 2>&1 | tail -1
✔ Test run with 3 tests in 2 suites passed after 0.001 seconds.
Step 3: Wrap interstitial and rewarded ads
The SDK’s full-screen ads work in three stages: load one ahead of time, show
it at the right moment, and hear back through a delegate when the player
closes it. Catcher needs the last part as a plain await: “show the ad, then
restart the game” reads in that order only if show() returns when the ad has
closed. This file turns the delegate callback into that. Everything in it is
wrapped in #if canImport(GoogleMobileAds), so the Mac build of the package
skips it.
Create the file
touch Packages/Ads/Sources/Ads/FullScreenAds.swift
Add the code: Packages/Ads/Sources/Ads/FullScreenAds.swift
#if canImport(GoogleMobileAds)
import GoogleMobileAds
import Observation
/// An interstitial, loaded ahead of time so it is ready at the next break.
@MainActor
@Observable
public final class Interstitial {
private let unitID: String
private var ad: InterstitialAd?
/// The ad on screen, kept until it closes; the SDK does not promise to.
@ObservationIgnored private var showing: InterstitialAd?
private var isLoading = false
private let closing = Closing()
init(unitID: String) {
self.unitID = unitID
}
public var isReady: Bool { ad != nil }
func load() async {
guard ad == nil, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let loaded = try await InterstitialAd.load(with: unitID, request: Request())
loaded.fullScreenContentDelegate = closing
ad = loaded
} catch {
print("Ads: interstitial: \(error.localizedDescription)")
retryLater(load)
}
}
/// Shows the loaded ad and returns when it closes, while the next one
/// loads in the background. Returns at once if no ad is loaded.
public func show() async {
guard let ad else { return }
self.ad = nil
showing = ad
await closing.wait { ad.present(from: nil) }
showing = nil
Task { await load() }
}
}
/// A rewarded ad, loaded ahead of time like `Interstitial`.
@MainActor
@Observable
public final class Rewarded {
private let unitID: String
private var ad: RewardedAd?
/// The ad on screen, kept until it closes; the SDK does not promise to.
@ObservationIgnored private var showing: RewardedAd?
private var isLoading = false
private let closing = Closing()
init(unitID: String) {
self.unitID = unitID
}
public var isReady: Bool { ad != nil }
func load() async {
guard ad == nil, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let loaded = try await RewardedAd.load(with: unitID, request: Request())
loaded.fullScreenContentDelegate = closing
ad = loaded
} catch {
print("Ads: rewarded: \(error.localizedDescription)")
retryLater(load)
}
}
/// Shows the loaded ad and returns, once it closes, whether the player
/// earned the reward. Returns false at once if no ad is loaded.
public func show() async -> Bool {
guard let ad else { return false }
self.ad = nil
showing = ad
var earned = false
await closing.wait {
ad.present(from: nil) { earned = true }
}
showing = nil
Task { await load() }
return earned
}
}
/// Calls `load` again in 30 seconds, after a load failed, for example
/// because the network was down.
@MainActor
private func retryLater(_ load: @escaping @MainActor () async -> Void) {
Task {
try? await Task.sleep(for: .seconds(30))
await load()
}
}
/// The delegate of a full-screen ad: lets `wait` return when the ad closes,
/// or when it could not be shown at all.
@MainActor
private final class Closing: NSObject, FullScreenContentDelegate {
private var continuation: CheckedContinuation<Void, Never>?
func wait(present: () -> Void) async {
await withCheckedContinuation { continuation in
self.continuation = continuation
present()
}
}
func adDidDismissFullScreenContent(_ ad: any FullScreenPresentingAd) {
finish()
}
func ad(_ ad: any FullScreenPresentingAd,
didFailToPresentFullScreenContentWithError error: any Error) {
print("Ads: could not show: \(error.localizedDescription)")
finish()
}
private func finish() {
continuation?.resume()
continuation = nil
}
}
#endif
Detailed breakdown
- The SDK’s names have no
GADprefix. Since version 12, the Swift names areInterstitialAd,RewardedAd,RequestandFullScreenContentDelegate; many examples online still useGADInterstitialAdand the otherGADnames, which no longer compile. - Each class holds at most one loaded ad.
load()asks Google for an ad and keeps it;isReadysays whether one is waiting. Ads take a second or more to load over the network, so loading at a break would keep the player waiting.load()is internal: Step 4’sAdsControllercalls it once the SDK has started, andshow()calls it after each ad.isLoadingstops two loads from running together. - A failed load is tried again in 30 seconds. Without the retry, an app
launched with no network would show no full-screen ads until it was
relaunched. A load also fails for an ad unit Google does not know: an
invalid unit ID logged
Ads: rewarded: Publisher data not found.every 30 seconds in the validation run. - An ad is shown once.
show()clearsadbefore presenting it, then starts loading the next in aTaskand returns without waiting for it. Awaiting that load would hold Catcher on its game-over screen for a network round trip after every rewarded ad.showingkeeps a reference to the ad while it is on screen, since nothing else in the app does onceadis cleared. Google says a loaded ad expires after an hour; showing an expired one fails,Closinghears of it, and the next load replaces it. present(from: nil)lets the SDK find the view controller to present from, the top one in the app’s window. A SwiftUI app has no view controller of its own to pass.Closingturns the delegate into anawait.wait(present:)stores a continuation (a handle that resumes a suspendedasyncfunction) and presents the ad;adDidDismissFullScreenContentor a failure to present resumes it. The ad holds its delegate weakly, so each class keeps itsClosingin a property; without that the delegate would be freed at once andshow()would never return.- The reward is a closure.
present(from:userDidEarnRewardHandler:)calls it when the player has watched enough to earn the reward, before the ad closes.Rewarded.show()records that inearnedand returns it once the ad is gone, so a player who closes the ad early gets nothing. @MainActorand@Observable. The SDK must be called on the main thread, and these package types do not get the app target’s main-actor default, so they declare it.@Observablelets SwiftUI redraw a button whenisReadychanges;@ObservationIgnoredkeepsshowingout of that, since no view reads it.
Step 4: Ask for consent, then for tracking, then start the SDK
Two questions come before the first ad request. The first is Google’s, and
it must be settled before any request: in the European Economic Area, the UK and Switzerland, privacy law
(GDPR) requires the user’s consent before ads use their personal data, and
UMP asks for it with a form Google hosts. The second is Apple’s: App
Tracking Transparency (ATT) is the system prompt that asks whether an app
may track the user across other companies’ apps, and without a yes the app
cannot read the IDFA, the device’s advertising identifier. ATT does not
block ad requests, but asking before the SDK starts lets the first requests
use the IDFA if the user allows it. Only then does the SDK start. AdsController runs that sequence once per launch and
owns the app’s interstitial and rewarded ads.
Create the file
touch Packages/Ads/Sources/Ads/AdsController.swift
Add the code: Packages/Ads/Sources/Ads/AdsController.swift
#if canImport(GoogleMobileAds)
import AppTrackingTransparency
import Foundation
import GoogleMobileAds
import Observation
import UserMessagingPlatform
/// Asks for consent, then for permission to track, then starts the Google
/// Mobile Ads SDK. Each app creates one and calls `start()` when its first
/// screen appears; no ad is requested before `canShowAds` is true.
@MainActor
@Observable
public final class AdsController {
/// True once the consent and tracking prompts are over, whether or not
/// ads can be shown.
public private(set) var hasFinishedPrompts = false
/// True once consent allows ad requests and the SDK has started.
public private(set) var canShowAds = false
/// True where privacy law requires a way to change the consent choice;
/// the app then shows a Privacy button that calls `showPrivacyOptions()`.
public private(set) var needsPrivacyOptions = false
public let units: AdUnits
public let interstitial: Interstitial?
public let rewarded: Rewarded?
public init(units: AdUnits = .main) {
#if DEBUG
precondition(units.areAllTestUnits, "Debug builds must use Google's test ad units")
#endif
self.units = units
interstitial = units.interstitial.map(Interstitial.init)
rewarded = units.rewarded.map(Rewarded.init)
}
public func start() async {
let parameters = RequestParameters()
#if DEBUG
// Launch arguments for trying the consent form on a simulator.
let defaults = UserDefaults.standard
if defaults.bool(forKey: "adsResetConsent") {
ConsentInformation.shared.reset()
}
if defaults.bool(forKey: "adsDebugEEA") {
let debug = DebugSettings()
debug.geography = .EEA
parameters.debugSettings = debug
}
#endif
do {
try await ConsentInformation.shared.requestConsentInfoUpdate(with: parameters)
try await ConsentForm.loadAndPresentIfRequired(from: nil)
} catch {
print("Ads: consent: \(error.localizedDescription)")
}
needsPrivacyOptions =
ConsentInformation.shared.privacyOptionsRequirementStatus == .required
// Consent given on an earlier launch still counts if this one failed.
guard ConsentInformation.shared.canRequestAds else {
hasFinishedPrompts = true
return
}
_ = await ATTrackingManager.requestTrackingAuthorization()
hasFinishedPrompts = true
_ = await MobileAds.shared.start()
canShowAds = true
await interstitial?.load()
await rewarded?.load()
}
/// Shows the consent form again, so the user can change their choice.
public func showPrivacyOptions() async {
do {
try await ConsentForm.presentPrivacyOptionsForm(from: nil)
} catch {
print("Ads: privacy options: \(error.localizedDescription)")
}
}
}
#endif
Detailed breakdown
Setup.
initstops a Debug build that has real ad units.preconditionstops the app with the message shown ifareAllTestUnitsis false. A simulator gets test ads even for real IDs, because Google treats every simulator as a test device, but a Debug build on your own iPhone would get live ads, and that is where accidental clicks happen. Release builds skip the check, since they are the ones that will carry real IDs.- One
Interstitialand oneRewardedper app, or nil.mapcreates each only when the app has that ad unit, so Shopping has neither and Catcher has both.
Consent.
requestConsentInfoUpdateasks Google whether this user needs to be asked, based on where the request comes from and on the consent form set up for the app ID in AdMob. Google’s documentation says to call it on every launch.loadAndPresentIfRequiredthen shows the form only if one is needed and no answer is stored, and returns when the user has answered. For a user outside the regions that need consent, it returns at once.canRequestAdsmeans the consent question is settled, not that the answer was yes. It is true when the user has answered, or when no answer is needed where they are. A user who declines consent still gets ads, limited ones that use no personal data. It is false only when the question could not be settled, for example on a first launch with no network, and then the app shows no ads.needsPrivacyOptionscomes fromprivacyOptionsRequirementStatus. Where consent is required, the user must also be able to change their answer later, and the form itself tells them to look for a link in the app. The apps show a Privacy button when this is true, which callsshowPrivacyOptions()to bring the form back.- The
#if DEBUGblock reads two launch arguments. As in the previous article, iOS puts launch arguments of the form-name valueintoUserDefaults.-adsDebugEEA YEStells UMP to treat this device as if it were in the EEA, which works only on test devices such as the simulator, and-adsResetConsent YESforgets the stored answer. Release builds ignore both. Step 10 uses them.
Tracking and start-up.
requestTrackingAuthorization()asks for Apple’s prompt. iOS shows it once per install and returns the stored answer every time after that. An AdMob account can also set up a tracking explainer, a message UMP shows before the ATT prompt; Google’s documentation says UMP then shows the ATT prompt itself, and this call only returns its answer. The call is here for accounts without one. The prompt’s text comes from the app’s Info.plist, which Step 6 sets; without that key, iOS stops the app when it asks. The answer is not used here: the SDK reads it itself, and serves ads either way.hasFinishedPromptsis set before the SDK starts. Catcher waits for it before starting a game (Step 9), so balls do not fall behind a prompt the player is reading. Starting the SDK took about half a second in the validation run, and the game does not need to wait for it. The consent check still goes to Google and back on every launch, so Catcher shows a black screen for that moment.MobileAds.shared.start()initializes the SDK.canShowAdsthen lets the banner appear, and the controller loads the first interstitial and rewarded ad so they are ready at the first break.
Step 5: Put a banner in a SwiftUI view
Google’s BannerView is a UIKit view, so SwiftUI needs a wrapper to show it.
The wrapper’s main job is size: a banner’s height depends on its width and
the device, and the SDK computes it. An anchored adaptive banner, the
kind meant to sit at the top or bottom of a screen, spans the full width
and takes the height Google picks for that width.
Create the file
touch Packages/Ads/Sources/Ads/BannerAd.swift
Add the code: Packages/Ads/Sources/Ads/BannerAd.swift
#if canImport(GoogleMobileAds)
import GoogleMobileAds
import SwiftUI
/// A banner as wide as its container, for the top or bottom of a screen.
/// It takes no space until the app's `AdsController` can show ads.
public struct BannerAd: View {
@Environment(AdsController.self) private var ads
@State private var width: CGFloat = 0
public init() {}
public var body: some View {
ZStack {
if let unitID = ads.units.banner, ads.canShowAds, width > 0 {
BannerRepresentable(unitID: unitID, width: width)
}
}
.frame(maxWidth: .infinity)
.frame(height: height)
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
}
private var height: CGFloat {
guard ads.units.banner != nil, ads.canShowAds, width > 0 else { return 0 }
return largeAnchoredAdaptiveBanner(width: width).size.height
}
}
/// Puts the SDK's UIKit `BannerView` into SwiftUI.
private struct BannerRepresentable: UIViewRepresentable {
let unitID: String
let width: CGFloat
func makeUIView(context: Context) -> BannerView {
let banner = BannerView(adSize: largeAnchoredAdaptiveBanner(width: width))
banner.adUnitID = unitID
banner.load(Request())
return banner
}
// The size SwiftUI lays out. Without it, SwiftUI uses the banner's own
// idea of its size, which can be wider than the screen.
func sizeThatFits(_ proposal: ProposedViewSize, uiView: BannerView, context: Context) -> CGSize? {
largeAnchoredAdaptiveBanner(width: width).size
}
func updateUIView(_ banner: BannerView, context: Context) {
// A new size makes the banner request a new ad, so only set a changed one.
let size = largeAnchoredAdaptiveBanner(width: width)
if !isAdSizeEqualToSize(size1: banner.adSize, size2: size) {
banner.adSize = size
}
}
}
#endif
Detailed breakdown
BannerAdreads theAdsControllerfrom the environment, where each app puts it in Step 7. UntilcanShowAdsis true, or in an app with no banner unit, its height is 0, so the screen does not save room for an ad that will not come.onGeometryChangemeasures the width the view is offered, andlargeAnchoredAdaptiveBanner(width:)turns it into Google’s size for that width. Thelargevariant replacedcurrentOrientationAnchoredAdaptiveBannerin SDK 13, and the older function now gives a deprecation warning.BannerRepresentableis aUIViewRepresentable, SwiftUI’s way to host a UIKit view.makeUIViewcreates the banner, gives it the ad unit ID and loads an ad. No root view controller is set: the SDK uses the one that contains the banner when someone taps the ad.sizeThatFitsis what keeps the banner on screen. Without it, SwiftUI sizes the banner from the UIKit view’s own preferred size. In the validation run that came out wider than the iPhone, and the shopping list was pushed off both edges of the screen.
Build the package for the simulator to check all four files compile against
the real SDK. -quiet prints only warnings and errors, so no output means
success:
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Ads -destination 'generic/platform=iOS Simulator' -quiet build
Commit the package on its own, as the series does for each shared package:
git add Packages/Ads Makefile MyApps.xcworkspace
git commit -q -m "Add the Ads package"
Step 6: Give each app its AdMob IDs
The SDK reads the app ID from the Info.plist key GADApplicationIdentifier
and stops the app at launch if it is missing. Apple’s SKAdNetwork, which
reports app installs that came from an ad without identifying the user, reads
a list of ad networks from SKAdNetworkItems. And ATT needs its prompt text.
Every app that shows ads needs all three, so they live in two files in the
Ads package, and each app’s .xcconfig file includes one of them and
supplies its own IDs.
The files sit in Packages/Ads/AppConfig, not in Apps/Config. Every app’s
make changes watches Apps/Config, so a file there would make TodoList look
due a release when it shows no ads; under Packages/Ads, only the apps
whose DEPS_ lines name Ads see the change. Swift Package Manager ignores
folders other than Sources and Tests, so the package does not try to
compile them.
Create the file
mkdir -p Packages/Ads/AppConfig
touch Packages/Ads/AppConfig/Ads.xcconfig Packages/Ads/AppConfig/Ads-Info.plist
Add the code: Packages/Ads/AppConfig/Ads.xcconfig
// Settings for every app that shows ads. An app's own .xcconfig file
// includes this one and sets the AdMob IDs it uses.
// Adds the AdMob keys in Ads-Info.plist to the Info.plist Xcode generates.
INFOPLIST_FILE = $(SRCROOT)/../../Packages/Ads/AppConfig/Ads-Info.plist
// The text of the App Tracking Transparency prompt.
INFOPLIST_KEY_NSUserTrackingUsageDescription = Allowing tracking lets the app show you ads that are more relevant to you.
Add the code: Packages/Ads/AppConfig/Ads-Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>GADApplicationIdentifier</key>
<string>$(ADMOB_APP_ID)</string>
<key>AdBannerUnitID</key>
<string>$(ADMOB_BANNER_UNIT)</string>
<key>AdInterstitialUnitID</key>
<string>$(ADMOB_INTERSTITIAL_UNIT)</string>
<key>AdRewardedUnitID</key>
<string>$(ADMOB_REWARDED_UNIT)</string>
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cstr6suwn9.skadnetwork</string>
</dict>
</array>
</dict>
</plist>
Create the file
touch Apps/Shopping/Shopping.xcconfig Apps/Catcher/Catcher.xcconfig
Both files exist already. Replace their contents with the two listings below.
Add the code: Apps/Shopping/Shopping.xcconfig
// Shopping's identity and version, for Debug and Release.
#include "../Config/Shared.xcconfig"
#include "../../Packages/Ads/AppConfig/Ads.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).Shopping
INFOPLIST_KEY_CFBundleDisplayName = Shopping
INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = Shopping Dev
// The version people see. Raise it for each release.
MARKETING_VERSION = 1.1.1
// The build number. `make archive` replaces it with the commit count.
CURRENT_PROJECT_VERSION = 1
// Google's sample app ID and test ad units. A Debug build stops with an
// error if an ad unit is not one of Google's test units.
ADMOB_APP_ID = ca-app-pub-3940256099942544~1458002511
ADMOB_BANNER_UNIT = ca-app-pub-3940256099942544/2435281174
Add the code: Apps/Catcher/Catcher.xcconfig
// Catcher's identity and version, for Debug and Release.
#include "../Config/Shared.xcconfig"
#include "../../Packages/Ads/AppConfig/Ads.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).Catcher
INFOPLIST_KEY_CFBundleDisplayName = Catcher
INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = Catcher Dev
// The version people see. Raise it for each release.
MARKETING_VERSION = 1.0.0
// The build number. `make archive` replaces it with the commit count.
CURRENT_PROJECT_VERSION = 1
// Google's sample app ID and test ad units. A Debug build stops with an
// error if an ad unit is not one of Google's test units.
ADMOB_APP_ID = ca-app-pub-3940256099942544~1458002511
ADMOB_INTERSTITIAL_UNIT = ca-app-pub-3940256099942544/4411468910
ADMOB_REWARDED_UNIT = ca-app-pub-3940256099942544/1712485313
Detailed breakdown
INFOPLIST_FILEadds keys to the generated Info.plist. The apps were made from a template that setsGENERATE_INFOPLIST_FILE = YES, so Xcode builds each Info.plist fromINFOPLIST_KEY_settings. A file named byINFOPLIST_FILEis merged into it, which is the only way to add keys that have noINFOPLIST_KEY_setting, such asGADApplicationIdentifierand theSKAdNetworkItemsarray. The path starts at$(SRCROOT), the folder of the app’s project, two levels belowmy-apps.$(ADMOB_APP_ID)and the other values are build settings. Xcode replaces them in the Info.plist with each app’s values when it builds, so one shared file serves both apps. A setting an app does not define becomes an empty string: Catcher’sAdBannerUnitIDis empty, andAdUnitsreads it as nil.ADMOB_APP_IDis Google’s sample app ID for iOS. It belongs to an AdMob account Google set up for testing, which also has a consent form and a tracking explainer set up, and Steps 7 and 10 show both.SKAdNetworkItemslists only Google’s own network. Google’s documentation asks forcstr6suwn9.skadnetworkplus a longer list of third-party buyers, published on its “3P SKAdNetwork” page. Add that list before releasing to the App Store; the test ads do not need it.- The ATT text goes in through
INFOPLIST_KEY_NSUserTrackingUsageDescription. It is the second line of Apple’s prompt, so it should say what the user gets. - The app files gain two things: an
#includeofAds.xcconfigafter the shared one, and the IDs at the end. The rest is unchanged. A real app would add its own IDs for Release only, with lines such asADMOB_BANNER_UNIT[config=Release] = ..., the same per-configuration form the display names use.
Check what Xcode now sees for Catcher:
xcodebuild -workspace MyApps.xcworkspace -scheme Catcher -showBuildSettings 2>/dev/null | grep -E '^ *(ADMOB_|INFOPLIST_KEY_NSUser)' | sed 's/^ *//'
ADMOB_APP_ID = ca-app-pub-3940256099942544~1458002511
ADMOB_INTERSTITIAL_UNIT = ca-app-pub-3940256099942544/4411468910
ADMOB_REWARDED_UNIT = ca-app-pub-3940256099942544/1712485313
INFOPLIST_KEY_NSUserTrackingUsageDescription = Allowing tracking lets the app show you ads that are more relevant to you.
Step 7: Show a banner in Shopping
Shopping is the first app to link Ads. Link it in Xcode through Build
Phases, never the General tab, which can write the display name into
the target, as
Version, Archive and Tag Each App in a Swift Monorepo
found:
- Click the Shopping project in the Project navigator and select the Shopping target. If the editor opens on General, switch to Build Phases straight away.
- Expand Link Binary With Libraries, click +, expand Ads under Workspace, select its library, and click Add.
grep -oE 'productName = [A-Za-z]+;' Apps/Shopping/Shopping.xcodeproj/project.pbxproj
productName = Shopping;
productName = CoreKit;
productName = DesignSystem;
productName = Ads;
The app creates one AdsController, starts it when the first screen appears,
and puts it in the environment, where BannerAd finds it.
Create the file
touch Apps/Shopping/Shopping/ShoppingApp.swift
The file exists already. Replace its contents with the listing below.
Add the code: Apps/Shopping/Shopping/ShoppingApp.swift
import Ads
import SwiftUI
@main
struct ShoppingApp: App {
@State private var ads = AdsController()
var body: some Scene {
WindowGroup {
ContentView()
.environment(ads)
.task { await ads.start() }
}
}
}
Create the file
touch Apps/Shopping/Shopping/ContentView.swift
The file exists already. Replace its contents with the listing below.
Add the code: Apps/Shopping/Shopping/ContentView.swift
import Ads
import SwiftUI
import CoreKit
import DesignSystem
struct ShoppingItem: Identifiable {
let id = UUID()
var name: String
var isBought = false
}
struct ContentView: View {
@Environment(AdsController.self) private var ads
// Kept in memory, so every launch starts from these two items.
@State private var items = [
ShoppingItem(name: "Apples"),
ShoppingItem(name: "Bread", isBought: true),
]
@State private var newName = ""
var body: some View {
NavigationStack {
List {
Section {
HStack {
TextField("Add an item", text: $newName)
.onSubmit(add)
Button("Add", action: add)
.disabled(TextInput.cleaned(newName) == nil)
}
}
Section {
ForEach($items) { $item in
Button {
item.isBought.toggle()
} label: {
CheckRow(title: item.name, isDone: item.isBought)
}
.buttonStyle(.plain)
}
.onDelete { items.remove(atOffsets: $0) }
}
}
.navigationTitle("Shopping")
.toolbar {
if ads.needsPrivacyOptions {
Button("Privacy") {
Task { await ads.showPrivacyOptions() }
}
}
}
.safeAreaInset(edge: .bottom) {
BannerAd()
}
}
}
private func add() {
guard let name = TextInput.cleaned(newName) else { return }
items.append(ShoppingItem(name: name))
newName = ""
}
}
#Preview {
ContentView()
.environment(AdsController())
}
Detailed breakdown
@State private var ads = AdsController()creates the controller once for the life of the app..taskrunsstart()when the window’s content appears. ATT shows its prompt only while the app is active, and by the time the consent check has gone to Google and back, it is..environment(ads)hands the controller to every view below it.BannerAdandContentViewboth read it with@Environment..safeAreaInset(edge: .bottom)puts the banner below the list and shrinks the list’s scrolling area by the banner’s height, so the last item can always be scrolled above the ad.- The Privacy button appears only when
needsPrivacyOptionsis true, which outside the EEA, the UK and Switzerland it is not. Step 10 makes it appear. - The preview needs an
AdsControllertoo, or it fails to find one in the environment. The preview builds a Debug version of the app, so the test-ID check runs there as well. - The template’s header comment is gone from
ShoppingApp.swift; the listing replaces the whole file.
Run Shopping:
make run APP=Shopping
xcrun simctl launch "TodoList-iPhone" com.example.Shopping
com.example.Shopping: 13336
These are the last two lines of the output; the process ID differs on your Mac. On the first launch outside the EEA, the UK and Switzerland, two prompts come before any ad (inside those regions, the consent form from Step 10 comes first):
- “Our app wants to stay free for you”, with a Continue button. This is the tracking explainer set up for the sample app ID, which UMP shows before Apple’s prompt. Click Continue.
- Allow “Shopping Dev” to track your activity across other companies’ apps
and websites?, Apple’s ATT prompt, with the text from
Ads.xcconfigunder it. Click Allow.
A second or two later the banner appears under the list: AdMob Adaptive Banner, “You’ve loaded a test ad from AdMob”, with a Test mode label in the middle. Take a screenshot:
make screenshot APP=Shopping
In the validation run, screenshots/Shopping.png showed the list with
Apples and Bread, and the test banner across the full width at the bottom.
Quit and relaunch Shopping (make run APP=Shopping again) and neither prompt
returns: UMP and iOS each remember the answer.
Step 8: Give a lost Catcher game one extra life
A rewarded ad needs a reward, and the reward is a game rule: when a game is
lost, the player may watch an ad for one more life, once per game, and keep
the score. The code calls this continuing a game. The rule goes into CatcherKit with the others, where swift test checks it without a simulator.
Create the file
touch Packages/CatcherKit/Sources/CatcherKit/CatcherGame.swift
The file exists already. Replace its contents with the listing below.
Add the code: Packages/CatcherKit/Sources/CatcherKit/CatcherGame.swift
/// The rules of Catcher, kept apart from SpriteKit so they can be tested
/// without a scene: the score, the lives left, and how often balls appear.
public struct CatcherGame: Equatable, Sendable {
public static let startingLives = 3
public private(set) var score = 0
public private(set) var lives = CatcherGame.startingLives
public private(set) var hasContinued = false
public init() {}
public var isOver: Bool { lives == 0 }
/// A ball reached the paddle.
public mutating func caught() {
guard !isOver else { return }
score += 1
}
/// A ball reached the bottom of the screen.
public mutating func missed() {
guard !isOver else { return }
lives -= 1
}
/// True when the game is over and has not been continued yet: a game
/// can be continued once.
public var canContinue: Bool { isOver && !hasContinued }
/// Gives a finished game one more life and keeps its score.
public mutating func continueWithExtraLife() {
guard canContinue else { return }
lives = 1
hasContinued = true
}
/// Seconds between new balls: 1.2 at the start, 0.05 less for each
/// point scored, and never less than 0.4.
public var spawnInterval: Double {
max(0.4, 1.2 - 0.05 * Double(score))
}
}
Create the file
touch Packages/CatcherKit/Tests/CatcherKitTests/CatcherGameTests.swift
The file exists already. Replace its contents with the listing below.
Add the code: Packages/CatcherKit/Tests/CatcherKitTests/CatcherGameTests.swift
import Testing
import CatcherKit
struct CatcherGameTests {
@Test func catchingScores() {
var game = CatcherGame()
game.caught()
game.caught()
#expect(game.score == 2)
#expect(game.lives == CatcherGame.startingLives)
}
@Test func missingCostsALifeAndEndsTheGameAtZero() {
var game = CatcherGame()
for _ in 1...CatcherGame.startingLives { game.missed() }
#expect(game.lives == 0)
#expect(game.isOver)
}
@Test func nothingCountsAfterTheGameIsOver() {
var game = CatcherGame()
for _ in 1...CatcherGame.startingLives { game.missed() }
game.caught()
game.missed()
#expect(game.score == 0)
#expect(game.lives == 0)
}
@Test func aFinishedGameCanContinueOnce() {
var game = CatcherGame()
game.caught()
for _ in 1...CatcherGame.startingLives { game.missed() }
#expect(game.canContinue)
game.continueWithExtraLife()
#expect(game.lives == 1)
#expect(game.score == 1)
#expect(!game.isOver)
game.missed()
#expect(game.isOver)
#expect(!game.canContinue)
game.continueWithExtraLife()
#expect(game.lives == 0)
}
@Test func aGameInPlayCannotContinue() {
var game = CatcherGame()
#expect(!game.canContinue)
game.continueWithExtraLife()
#expect(game.lives == CatcherGame.startingLives)
}
// Floating-point arithmetic is not exact, so compare within a tolerance.
@Test(arguments: [(0, 1.2), (10, 0.7), (16, 0.4), (30, 0.4)])
func ballsComeFasterAsTheScoreRises(score: Int, expected: Double) {
var game = CatcherGame()
for _ in 0..<score { game.caught() }
#expect(abs(game.spawnInterval - expected) < 0.000_001)
}
}
Detailed breakdown
- Three additions to
CatcherGame:hasContinued,canContinueandcontinueWithExtraLife(). The rest of the file is unchanged from the previous article. canContinueis computed, likeisOver, so it cannot disagree with the values it comes from. The game screen shows the rewarded-ad button only when it is true.continueWithExtraLife()setslivesto 1 and keepsscore, and does nothing in a game still in play or one already continued. Likecaught()andmissed(), it checks the game’s state first, so a stray call cannot hand out a second extra life.- Two new tests cover the rule: one extra life per game with the score kept, and none while a game is in play.
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/CatcherKit 2>&1 | tail -1
git add Packages/CatcherKit
git commit -q -m "CatcherKit: allow one extra life per game"
✔ Test run with 6 tests in 1 suite passed after 0.001 seconds.
Step 9: Show interstitial and rewarded ads in Catcher
Catcher shows full-screen ads only when a game is over, when a missed ball no
longer costs a life, so an ad can never cost one. Until now a tap on the
game-over screen restarted the game. Now that screen offers a choice:
Watch an ad for one more life when a rewarded ad is loaded and the game
has not been continued, and Play again, which shows an interstitial after
every second game first. Link Ads to the Catcher target the same way as
Shopping:
- Click the Catcher project and select the Catcher target, then Build Phases (switch away from General at once if it opens there).
- Expand Link Binary With Libraries, click +, select the Ads library under Workspace, and click Add.
grep -oE 'productName = [A-Za-z]+;' Apps/Catcher/Catcher.xcodeproj/project.pbxproj
productName = Catcher;
productName = CatcherKit;
productName = CoreKit;
productName = Ads;
The game session forwards the new rule, as it forwards the others:
Create the file
touch Apps/Catcher/Catcher/GameSession.swift
The file exists already. Replace its contents with the listing below.
Add the code: Apps/Catcher/Catcher/GameSession.swift
import CatcherKit
import CoreKit
import Observation
/// The state the scene changes and the SwiftUI overlay shows.
@Observable
final class GameSession {
private(set) var game = CatcherGame()
private(set) var best: Int
private let bestScore = BestScore(key: "bestScore")
init() {
best = bestScore.value
}
func caught() {
game.caught()
if bestScore.record(game.score) {
best = game.score
}
}
func missed() {
game.missed()
}
func restart() {
game = CatcherGame()
}
func continueWithExtraLife() {
game.continueWithExtraLife()
}
}
The scene stops restarting the game on a tap and gains a method that removes
the balls still falling. In GameScene.swift, replace the
touchesBegan(_:with:) method with the two methods below; the rest of the
file stays as it is.
Create the file
touch Apps/Catcher/Catcher/GameScene.swift
Add the code: Apps/Catcher/Catcher/GameScene.swift (replaces touchesBegan)
/// Removes the balls still falling, before a new or continued game.
func clearBalls() {
enumerateChildNodes(withName: "ball") { node, _ in node.removeFromParent() }
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Once the game is over, the SwiftUI buttons decide what happens next.
guard !session.game.isOver else { return }
targetX = touches.first?.location(in: self).x
}
Create the file
touch Apps/Catcher/Catcher/CatcherApp.swift
The file exists already. Replace its contents with the listing below.
Add the code: Apps/Catcher/Catcher/CatcherApp.swift
import Ads
import SwiftUI
@main
struct CatcherApp: App {
@State private var ads = AdsController()
var body: some Scene {
WindowGroup {
ContentView()
.environment(ads)
.task { await ads.start() }
}
}
}
Create the file
touch Apps/Catcher/Catcher/ContentView.swift
The file exists already. Replace its contents with the listing below.
Add the code: Apps/Catcher/Catcher/ContentView.swift
import Ads
import CatcherKit
import SpriteKit
import SwiftUI
struct ContentView: View {
@Environment(AdsController.self) private var ads
@State private var session: GameSession
@State private var scene: GameScene
@State private var pacer = InterstitialPacer(every: 2)
/// True while a button's action runs, so a second tap does nothing.
@State private var isBusy = false
init() {
let session = GameSession()
_session = State(initialValue: session)
_scene = State(initialValue: GameScene(session: session))
}
var body: some View {
ZStack {
// The scene starts when it is shown, so it waits until the
// consent and tracking prompts have been answered.
if ads.hasFinishedPrompts {
SpriteView(scene: scene)
.ignoresSafeArea()
} else {
Color.black
.ignoresSafeArea()
}
VStack {
HStack {
Text("Score \(session.game.score)")
Spacer()
Text("Best \(session.best)")
Spacer()
Text("Lives \(session.game.lives)")
}
.font(.headline.monospacedDigit())
// Touches pass through the score line to the scene.
.allowsHitTesting(false)
Spacer()
if session.game.isOver {
gameOver
Spacer()
}
}
.foregroundStyle(.white)
.padding()
}
}
private var gameOver: some View {
VStack(spacing: 16) {
Text("Game over")
.font(.title2.bold())
if session.game.canContinue, ads.rewarded?.isReady == true {
Button("Watch an ad for one more life") {
Task { await watchAdToContinue() }
}
.buttonStyle(.borderedProminent)
}
Button("Play again") {
Task { await playAgain() }
}
.buttonStyle(.bordered)
if ads.needsPrivacyOptions {
Button("Privacy") {
Task { await ads.showPrivacyOptions() }
}
}
}
}
private func playAgain() async {
guard session.game.isOver, !isBusy else { return }
isBusy = true
defer { isBusy = false }
if pacer.gameFinished() {
await ads.interstitial?.show()
}
scene.clearBalls()
session.restart()
}
private func watchAdToContinue() async {
guard session.game.canContinue, !isBusy else { return }
isBusy = true
defer { isBusy = false }
guard await ads.rewarded?.show() == true else { return }
scene.clearBalls()
session.continueWithExtraLife()
}
}
#Preview {
ContentView()
.environment(AdsController())
}
Detailed breakdown
Waiting for the prompts.
- The scene is not shown until
hasFinishedPromptsis true. SpriteKit calls the scene’sdidMove(to:)when a view first shows it, and that is where the previous article’s scene starts dropping balls. Holding back theSpriteViewholds back the game, so a player reading the prompts does not lose three lives behind them. Pausing does not work: SpriteKit unpaused the scene when presenting it, undoingscene.isPaused, and passingisPausedtoSpriteViewleft the game frozen once the prompts were over (both seen in the validation runs).
The game-over screen.
- The score line is the only part that lets touches through. In the
previous article the whole overlay had
.allowsHitTesting(false), so a tap anywhere reached the scene. Now the buttons must receive taps, so the modifier moves to theHStack, with the font, and the scene ignores touches once the game is over. - The rewarded button needs both conditions: the game can continue, and an ad is loaded. Without the second, a tap could lead to nothing on a slow network.
- The Privacy button is the same as Shopping’s, on the one screen where Catcher has room for it.
Showing the ads.
playAgain()asks the pacer first. On every second call it shows the interstitial and waits for it to close, then clears the balls and starts a new game.show()returns at once if no interstitial is loaded, so a missing ad never blocks the restart.watchAdToContinue()continues only ifshow()returns true, which means the player watched enough to earn the reward. Closing the ad early leaves the game over, and the button returns once the next ad has loaded, since the game has not been continued.isBusyignores a second tap. Each tap starts aTask, so without it a quick double tap on Play again would count two finished games and could show an interstitial during the new game. Both functions also check the game’s state first, for the same reason.
Build all three apps, then run Catcher:
make build
make run APP=Catcher
--- TodoList
--- Shopping
--- Catcher
xcrun simctl launch "TodoList-iPhone" com.example.Catcher
com.example.Catcher: 25470
The three --- lines are make build, and the last two are the end of
make run.
Each app stores its own answers, so the same two prompts appear: click Continue, then Allow. The game starts only then. Leave the paddle alone, and within twenty seconds or so three balls have missed it:
- The game-over screen offers Watch an ad for one more life and Play again. Click Watch an ad for one more life. A test video plays, marked Test mode. The creative varies from run to run, sometimes two short ads in a row, some showing real brands. Once enough has been watched, the top right reads Reward granted with a close button. Click the close button. The game resumes immediately with Lives 1 and the score it had.
- When that life is gone, only Play again is offered: this game has been continued. Click it. A new game starts with no ad, since this is the first finished game.
- When the next game ends, click Play again. This time the interstitial appears first, in the validation runs Google’s “This is an interstitial test ad.”
Take a screenshot while the interstitial is showing to keep a record of it:
make screenshot APP=Catcher
After a few seconds a close button appears in the top right. Close the ad, and a new game starts with Lives 3.
Step 10: See the consent form as a user in Europe
Outside the EEA, the UK and Switzerland, UMP decides no consent form is
needed and shows only the tracking explainer. The -adsDebugEEA YES launch
argument from Step 4 makes UMP treat the simulator as if it were in the EEA,
and -adsResetConsent YES forgets any answer stored by an earlier launch.
Both are read only in Debug builds, and the EEA setting works only on test
devices, which every simulator is. make run reinstalls the app, which
stops the copy already running:
make run APP=Shopping LAUNCH_ARGS="-adsDebugEEA YES -adsResetConsent YES"
The consent form appears: Publisher Test Ads asks for your consent to use
your personal data to:, with the purposes, a partner count (205 in the
validation run; it changes as Google’s list does), and three buttons,
Consent, Do not consent and Manage options. Click Do not
consent. The banner still appears, possibly with a different test ad:
canRequestAds is true because the question is settled, and the SDK serves
ads that use no personal data. No
tracking prompt appears, since Shopping’s ATT answer from Step 7 is still
stored. A Privacy button now sits at the top right: in the EEA,
privacyOptionsRequirementStatus is .required.
Launch it as a European user again, without the reset:
make run APP=Shopping LAUNCH_ARGS="-adsDebugEEA YES"
The form does not return, since the answer is stored, and the Privacy
button is there again. Click it and the same form opens, so the choice can
be changed. Click Consent. Launched once more with plain make run APP=Shopping, the app no longer treats the simulator as being in the EEA,
and the Privacy button is gone.
Step 11: Test and release the two apps
Both apps changed, and TodoList did not. make test now tests Ads with the
other packages and builds all three apps. On the Mac, the Ads tests cover
only the code outside #if canImport(GoogleMobileAds):
make test 2>&1 | grep -E '^---|Test run|TEST'
--- CoreKit
✔ Test run with 5 tests in 2 suites passed after 0.003 seconds.
--- DesignSystem
✔ Test run with 2 tests in 1 suite passed after 0.025 seconds.
--- CatcherKit
✔ Test run with 6 tests in 1 suite passed after 0.001 seconds.
--- TodoKit
✔ Test run with 5 tests in 1 suite passed after 0.010 seconds.
--- Ads
✔ Test run with 3 tests in 2 suites passed after 0.001 seconds.
--- TodoList
--- Shopping
--- Catcher
--- TodoList UI tests
** TEST SUCCEEDED **
Commit the apps, then ask which ones are due a release:
git add -A
git commit -q -m "Show ads in Shopping and Catcher"
make changes APP=TodoList
make changes APP=Shopping
make changes APP=Catcher
TodoList since TodoList/v1.0.1:
f48f9e4 CoreKit: add BestScore
Shopping since Shopping/v1.1.1:
bf6b20d Show ads in Shopping and Catcher
2e509cb Add the Ads package
f48f9e4 CoreKit: add BestScore
Catcher since Catcher/v1.0.0:
bf6b20d Show ads in Shopping and Catcher
6177a68 CatcherKit: allow one extra life per game
2e509cb Add the Ads package
TodoList lists none of this article’s three commits: none touched its
folder, the shared settings in Apps/Config, or a package it is built from.
Its one commit is the previous article’s CoreKit change, which that
article showed does not affect TodoList, so TodoList is not due a release.
Shopping lists that commit too,
with the Ads commit and its own; Catcher lists the Ads, CatcherKit
and app commits. Your hashes differ.
TodoList’s build contains none of Google’s code. Each app’s frameworks are
copied into a Frameworks folder inside it:
ls build/Build/Products/Debug-iphonesimulator/TodoList.app/Frameworks build/Build/Products/Debug-iphonesimulator/Shopping.app/Frameworks
ls: build/Build/Products/Debug-iphonesimulator/TodoList.app/Frameworks: No such file or directory
build/Build/Products/Debug-iphonesimulator/Shopping.app/Frameworks:
GoogleMobileAds.framework
UserMessagingPlatform.framework
Raise the two apps’ versions, commit, and release them:
make set-version APP=Shopping TO=1.2.0
make set-version APP=Catcher TO=1.1.0
git commit -q -am "Shopping 1.2.0, Catcher 1.1.0"
make release APP=Shopping
make release APP=Catcher
MARKETING_VERSION = 1.2.0
MARKETING_VERSION = 1.1.0
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Shopping -destination 'generic/platform=iOS' -archivePath build/Shopping.xcarchive CURRENT_PROJECT_VERSION=14 CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.2.0","CFBundleIdentifier":"com.example.Shopping","Team":"","ApplicationPath":"Applications\/Shopping.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"14"}
git tag -a Shopping/v1.2.0 -m "Shopping 1.2.0 (build 14)"
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Catcher -destination 'generic/platform=iOS' -archivePath build/Catcher.xcarchive CURRENT_PROJECT_VERSION=14 CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.1.0","CFBundleIdentifier":"com.example.Catcher","Team":"","ApplicationPath":"Applications\/Catcher.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"14"}
git tag -a Catcher/v1.1.0 -m "Catcher 1.1.0 (build 14)"
Build 14: the ten commits from the previous articles, plus the four made here. Both apps share it because both archives were made from the same commit.
Troubleshooting
- The app stops at launch with
Precondition failed: Debug builds must use Google's test ad units. An ad unit ID in the app’s.xcconfigfile does not start withca-app-pub-3940256099942544/. Use Google’s test IDs while developing. - The app stops at launch with
GADInvalidInitializationException, “The Google Mobile Ads SDK was initialized without an application ID”. The app’s Info.plist has no app ID. Check that the app’s.xcconfigfile includesAds.xcconfigand setsADMOB_APP_ID, then look at the built Info.plist withplutil -p build/Build/Products/Debug-iphonesimulator/Shopping.app/Info.plist. - No banner, no rewarded button. Ads load over the network; check the
Mac’s connection. The console line
Ads: rewarded:orAds: interstitial:gives the SDK’s reason, and a failed full-screen load is tried again every 30 seconds. Ads also wait for the consent answer: on a first launch with no network,canRequestAdsis false and nothing is requested until the app is relaunched. - The banner is wider than the screen and the list disappears.
BannerRepresentableis missingsizeThatFits. - The tracking prompt never appears. iOS asks once per install. To see it
again, delete the app:
xcrun simctl uninstall TodoList-iPhone com.example.Catcher. 'currentOrientationAnchoredAdaptiveBanner(width:)' was deprecatedorcannot find 'GADBannerView' in scope. Code written for an older SDK. UselargeAnchoredAdaptiveBanner(width:)and the names withoutGAD.swift testonAdsfails withWhile building for macOS, no library for this platform was found. Thecondition: iOSOnlyargument is missing from a dependency inPackage.swift.- The game ends behind the prompts.
ContentViewshows theSpriteViewbeforeads.hasFinishedPromptsis true.
Recap
Shopping and Catcher now show Google’s test ads, and the code that does it
lives in one package. Ads links Google’s Mobile Ads SDK and User Messaging
Platform only into iOS builds, so its ID handling and interstitial pacing are
tested on the Mac with swift test. AdsController asks for consent through
UMP, then for tracking through ATT, then starts the SDK, and no ad is
requested before that. The full-screen ads load ahead of time and turn the
SDK’s delegate into an await that returns when the ad closes, and the
banner is a SwiftUI view sized by Google’s adaptive banner rules. The AdMob
settings each app needs are one shared .xcconfig file and one Info.plist
fragment in the package, with each app’s IDs in its own .xcconfig file. A
Debug build stops on any ad unit that is not a test one. TodoList links none
of it: make changes showed none of this article’s commits for it, and only
Shopping 1.2.0 and Catcher 1.1.0 shipped.
Next improvements:
- Create the apps in AdMob, add their real app and ad unit IDs as
[config=Release]settings, and publish anapp-ads.txtfile on the developer website, which AdMob checks before paying. - Add Google’s full list of third-party
SKAdNetworkItemsbefore submitting to the App Store. - Give Catcher a UI test that loses a game, taps Watch an ad for one more life, closes the test ad and checks for Lives 1.