mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* ci: test AdMob adapter deploy with hardcoded version 5.66.0 Temporarily hardcode version and simplify deploy-tag workflow to test the deploy-to-purchases-ios-admob job from the release-admob-adapter branch. * Revert "ci: test AdMob adapter deploy with hardcoded version 5.66.0" This reverts commit 104542f0ab0a0dbb590b19187f8e598ca8ece96c. * feat: add AdMobIntegrationSample example app xcodeproj and config Add the Xcode project, Info.plist, gitignore, and Package.resolved for the AdMob example app. Clean up duplicate RevenueCatAdMob package dependencies and fix project-level deployment target. * fix: allow example app Package.resolved through gitignore The root Package.resolved pattern matched at any depth, silently excluding the example app's Package.resolved from the deployed repo. Add a negation so the example app keeps reproducible builds. * fix: gitignore Package.resolved in example app instead of committing it * feat: add VanillaAdTrackingSample example app for manual ad tracking Adds a companion example to AdMobIntegrationSample that demonstrates manual ad event tracking via Purchases.shared.adTracker without the RevenueCatAdMob adapter library. Covers all ad formats (banner, interstitial, app open, rewarded, rewarded interstitial, native) with manual delegate callbacks and paidEventHandler wiring. * feat: add VanillaAdTrackingSample to Tuist workspace Adds Tuist project definition and workspace entry for the vanilla ad tracking example. Introduces vanillaAdTrackingPackages helper that provides RevenueCat (local) + GoogleMobileAds (remote) without the adapter dependency. * Fixup * docs: remove adapter comparison from VanillaAdTrackingSample README * fix: use dynamic placement in banner impression and click callbacks * fix: update Google Mobile Ads version constraint to allow v13 * fix: use configuration-based styling for CTA button in native ad view * fix: remove unused placement parameter from loadNativeAd * fix: inline Google Mobile Ads package in Project.swift Move the Google Mobile Ads package declaration directly into the VanillaAdTrackingSample Project.swift instead of using a shared helper, since it's only used by this one project. * fix: remove unused VanillaAdTrackingSample-Info.plist The info.plist values are already declared in Project.swift via infoPlist: .extendingDefault, making this file redundant. * fix: add didFailToPresentFullScreenContent delegate method Handle the case where a loaded full-screen ad fails to present by cleaning up metadata, resetting the ad reference and status. This prevents leaked metadata entries and stuck "Ready" states. * fix: mark AdMobManager as @MainActor for thread safety AdMob callbacks may fire off the main thread, but @Published properties are observed by SwiftUI and must be mutated on main. * docs: update build instructions to use tuist generate The project uses Tuist for workspace generation, not a standalone .xcodeproj file. * fix: move native ad delegate assignment before availability guard The delegate assignment is not iOS 15-dependent and should be set before the guard so impression/click callbacks work regardless of the consumer's deployment target. * refactor: extract shared full-screen ad cleanup into helper method Deduplicate the identical cleanup logic between adDidDismissFullScreenContent and ad(_:didFailToPresentFullScreenContentWithError:) into a single cleanUpFullScreenAd helper. * fix(ads): save adLoader before setting delegate * chore(ads): cleanup vanilla example and project file * fix(ads): remove hardcoded development team * fix(ads): move from local to remote package dep * fix(ads): revert tuist and fix project file * fix(ads): revert tuist and fix project file --------- Co-authored-by: Peter Porfy <peter.porfy@revenuecat.com>
353 lines
13 KiB
Swift
353 lines
13 KiB
Swift
import GoogleMobileAds
|
|
import SwiftUI
|
|
|
|
struct HomeView: View {
|
|
@StateObject private var adManager = AdMobManager()
|
|
|
|
var body: some View {
|
|
Group {
|
|
if #available(iOS 16.0, *) {
|
|
NavigationStack {
|
|
self.content
|
|
}
|
|
} else {
|
|
NavigationView {
|
|
self.content
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var content: some View {
|
|
List {
|
|
Section {
|
|
Text("Select an ad format to test. Check Xcode console logs for detailed event tracking.")
|
|
.font(.subheadline)
|
|
}
|
|
|
|
Section {
|
|
ForEach(AdFormat.allCases) { format in
|
|
NavigationLink(destination: AdFormatDetailView(format: format, adManager: adManager)) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(format.title)
|
|
.font(.headline)
|
|
Text(format.subtitle)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("AdMob + RC (Vanilla)")
|
|
}
|
|
}
|
|
|
|
private enum AdFormat: String, CaseIterable, Identifiable {
|
|
case banner
|
|
case interstitial
|
|
case appOpen
|
|
case rewarded
|
|
case rewardedInterstitial
|
|
case native
|
|
case nativeVideo
|
|
case errorTesting
|
|
|
|
var id: String { self.rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .banner: return "Banner Ad"
|
|
case .interstitial: return "Interstitial Ad"
|
|
case .appOpen: return "App Open Ad"
|
|
case .rewarded: return "Rewarded Ad"
|
|
case .rewardedInterstitial: return "Rewarded Interstitial Ad"
|
|
case .native: return "Native Ad"
|
|
case .nativeVideo: return "Native Video Ad"
|
|
case .errorTesting: return "Error Testing"
|
|
}
|
|
}
|
|
|
|
var subtitle: String {
|
|
switch self {
|
|
case .banner: return "Always visible, auto-loaded"
|
|
case .interstitial: return "Full-screen ad"
|
|
case .appOpen: return "App launch/resume ad"
|
|
case .rewarded: return "Rewards users after viewing"
|
|
case .rewardedInterstitial: return "Interstitial that rewards users"
|
|
case .native: return "Text + images integrated into UI"
|
|
case .nativeVideo: return "Video content integrated into UI"
|
|
case .errorTesting: return "Triggers ad load failure"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AdFormatDetailView: View {
|
|
let format: AdFormat
|
|
@ObservedObject var adManager: AdMobManager
|
|
@State private var showErrorFeedback = false
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(self.detailDescription)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
switch format {
|
|
case .banner:
|
|
BannerAdView(adManager: adManager)
|
|
.frame(height: 50)
|
|
|
|
case .interstitial:
|
|
self.statusAndButtons(
|
|
status: adManager.interstitialStatus,
|
|
onLoad: { adManager.loadInterstitialAd() },
|
|
onShow: {
|
|
if let rootVC = Self.rootViewController {
|
|
adManager.showInterstitialAd(from: rootVC)
|
|
}
|
|
},
|
|
canShow: adManager.interstitialStatus == "Ready"
|
|
)
|
|
|
|
case .appOpen:
|
|
self.statusAndButtons(
|
|
status: adManager.appOpenStatus,
|
|
onLoad: { adManager.loadAppOpenAd() },
|
|
onShow: {
|
|
if let rootVC = Self.rootViewController {
|
|
adManager.showAppOpenAd(from: rootVC)
|
|
}
|
|
},
|
|
canShow: adManager.appOpenStatus == "Ready"
|
|
)
|
|
|
|
case .rewarded:
|
|
self.statusAndButtons(
|
|
status: adManager.rewardedStatus,
|
|
onLoad: { adManager.loadRewardedAd() },
|
|
onShow: {
|
|
if let rootVC = Self.rootViewController {
|
|
adManager.showRewardedAd(from: rootVC) {}
|
|
}
|
|
},
|
|
canShow: adManager.rewardedStatus == "Ready"
|
|
)
|
|
|
|
case .rewardedInterstitial:
|
|
self.statusAndButtons(
|
|
status: adManager.rewardedInterstitialStatus,
|
|
onLoad: { adManager.loadRewardedInterstitialAd() },
|
|
onShow: {
|
|
if let rootVC = Self.rootViewController {
|
|
adManager.showRewardedInterstitialAd(from: rootVC) {}
|
|
}
|
|
},
|
|
canShow: adManager.rewardedInterstitialStatus == "Ready"
|
|
)
|
|
|
|
case .native:
|
|
self.nativeBlock(
|
|
status: adManager.nativeAdStatus,
|
|
onLoad: {
|
|
adManager.loadNativeAd(
|
|
adUnitID: Constants.AdMob.nativeAdUnitID
|
|
)
|
|
},
|
|
nativeAd: adManager.nativeAd
|
|
)
|
|
|
|
case .nativeVideo:
|
|
self.nativeBlock(
|
|
status: adManager.nativeVideoAdStatus,
|
|
onLoad: {
|
|
adManager.loadNativeAd(
|
|
adUnitID: Constants.AdMob.nativeVideoAdUnitID
|
|
)
|
|
},
|
|
nativeAd: adManager.nativeVideoAd
|
|
)
|
|
|
|
case .errorTesting:
|
|
Button("Trigger Ad Load Error") {
|
|
adManager.loadAdWithError()
|
|
showErrorFeedback = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
|
showErrorFeedback = false
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
if showErrorFeedback {
|
|
Text("Loading with invalid ID. Check console logs for failure tracking.")
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.navigationTitle(format.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
private var detailDescription: String {
|
|
switch format {
|
|
case .banner:
|
|
return "Always visible at the top. Tracks Loaded, Displayed, Opened, and Revenue."
|
|
case .interstitial:
|
|
return "Full-screen ad. Tracks Loaded, Displayed, Opened, and Revenue."
|
|
case .appOpen:
|
|
return "App launch/resume ad. Tracks Loaded, Displayed, Opened, and Revenue."
|
|
case .rewarded:
|
|
return "Reward ad with reward callback. Tracks Loaded, Displayed, Opened, and Revenue."
|
|
case .rewardedInterstitial:
|
|
return "Interstitial format with reward callback and full tracking."
|
|
case .native:
|
|
return "Integrated native ad. Native test IDs can be unreliable; custom IDs are best for validation."
|
|
case .nativeVideo:
|
|
return "Integrated native video ad. Native video test IDs can be unreliable in test environments."
|
|
case .errorTesting:
|
|
return "Uses an intentionally invalid ad unit ID to trigger and track load failures."
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func statusAndButtons(
|
|
status: String,
|
|
onLoad: @escaping () -> Void,
|
|
onShow: @escaping () -> Void,
|
|
canShow: Bool
|
|
) -> some View {
|
|
Text("Status: \(status)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
HStack {
|
|
Button("Load") { onLoad() }
|
|
.buttonStyle(.bordered)
|
|
.disabled(status == "Loading...")
|
|
|
|
Button("Show") { onShow() }
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!canShow)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func nativeBlock(
|
|
status: String,
|
|
onLoad: @escaping () -> Void,
|
|
nativeAd: NativeAd?
|
|
) -> some View {
|
|
Text("Status: \(status)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Button("Load") { onLoad() }
|
|
.buttonStyle(.bordered)
|
|
.disabled(status == "Loading...")
|
|
|
|
if let nativeAd {
|
|
NativeAdViewRepresentable(nativeAd: nativeAd)
|
|
.frame(height: 300)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
|
|
private static var rootViewController: UIViewController? {
|
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
|
|
return nil
|
|
}
|
|
return windowScene.windows.first?.rootViewController
|
|
}
|
|
}
|
|
|
|
struct NativeAdViewRepresentable: UIViewRepresentable {
|
|
let nativeAd: NativeAd
|
|
|
|
func makeUIView(context: Context) -> NativeAdView {
|
|
let nativeAdView = NativeAdView()
|
|
|
|
// Create UI elements
|
|
let stackView = UIStackView()
|
|
stackView.axis = .vertical
|
|
stackView.spacing = 8
|
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
// Media view for images/videos
|
|
let mediaView = MediaView()
|
|
mediaView.translatesAutoresizingMaskIntoConstraints = false
|
|
mediaView.contentMode = .scaleAspectFit
|
|
|
|
let headlineLabel = UILabel()
|
|
headlineLabel.font = .boldSystemFont(ofSize: 16)
|
|
headlineLabel.numberOfLines = 0
|
|
headlineLabel.text = nativeAd.headline
|
|
|
|
let bodyLabel = UILabel()
|
|
bodyLabel.font = .systemFont(ofSize: 14)
|
|
bodyLabel.numberOfLines = 0
|
|
bodyLabel.textColor = .secondaryLabel
|
|
bodyLabel.text = nativeAd.body
|
|
|
|
let ctaButton = UIButton(type: .system)
|
|
ctaButton.setTitle(nativeAd.callToAction, for: .normal)
|
|
ctaButton.layer.cornerRadius = 8
|
|
ctaButton.clipsToBounds = true
|
|
var configuration = UIButton.Configuration.filled()
|
|
configuration.baseBackgroundColor = .systemBlue
|
|
configuration.baseForegroundColor = .white
|
|
configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)
|
|
ctaButton.configuration = configuration
|
|
|
|
stackView.addArrangedSubview(mediaView)
|
|
stackView.addArrangedSubview(headlineLabel)
|
|
stackView.addArrangedSubview(bodyLabel)
|
|
stackView.addArrangedSubview(ctaButton)
|
|
|
|
// Set media view height constraint
|
|
NSLayoutConstraint.activate([
|
|
mediaView.heightAnchor.constraint(equalToConstant: 200)
|
|
])
|
|
|
|
nativeAdView.addSubview(stackView)
|
|
NSLayoutConstraint.activate([
|
|
stackView.topAnchor.constraint(equalTo: nativeAdView.topAnchor, constant: 12),
|
|
stackView.leadingAnchor.constraint(equalTo: nativeAdView.leadingAnchor, constant: 12),
|
|
stackView.trailingAnchor.constraint(equalTo: nativeAdView.trailingAnchor, constant: -12),
|
|
stackView.bottomAnchor.constraint(equalTo: nativeAdView.bottomAnchor, constant: -12)
|
|
])
|
|
|
|
nativeAdView.mediaView = mediaView
|
|
nativeAdView.headlineView = headlineLabel
|
|
nativeAdView.bodyView = bodyLabel
|
|
nativeAdView.callToActionView = ctaButton
|
|
nativeAdView.nativeAd = nativeAd
|
|
|
|
nativeAdView.backgroundColor = .secondarySystemBackground
|
|
nativeAdView.layer.cornerRadius = 12
|
|
|
|
return nativeAdView
|
|
}
|
|
|
|
func updateUIView(_ uiView: NativeAdView, context: Context) {
|
|
uiView.nativeAd = nativeAd
|
|
(uiView.headlineView as? UILabel)?.text = nativeAd.headline
|
|
(uiView.bodyView as? UILabel)?.text = nativeAd.body
|
|
(uiView.callToActionView as? UIButton)?.setTitle(nativeAd.callToAction, for: .normal)
|
|
}
|
|
}
|
|
|
|
struct BannerAdView: UIViewRepresentable {
|
|
let adManager: AdMobManager
|
|
|
|
func makeUIView(context: Context) -> BannerView {
|
|
return adManager.loadBannerAd()
|
|
}
|
|
|
|
func updateUIView(_ uiView: BannerView, context: Context) {}
|
|
}
|