feat: add Request/options inits and ad event modifiers to NativeAdvertisement - #8
Conversation
…tisement - Add progressively disclosed NativeAdvertisement initializers so callers can pass a custom Request and GADAdLoaderOptions when they need them, keeping the ad-unit-id-only initializer as the simple default. - Deliver NativeAdDelegate events (onTap, onSwipeGesture, onWillAppear, onWillDisappear, onDismiss, onAdvertisementMuted) through modifiers on NativeAdvertisement, wired via a Coordinator on _RepresentedUINativeAdView so the loader stays load-only. - Remove the custom AdLoader injection TODO from the README and document the new request customization and ad event callbacks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 005476d147
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // The NativeAd instance only exists after the async load completes, so this is the | ||
| // only point where its delegate can be wired. The event callbacks are already held | ||
| // by the Coordinator from makeCoordinator, so nothing else needs to be pushed here. | ||
| nativeAd.delegate = context.coordinator |
There was a problem hiding this comment.
Refresh coordinator callbacks on update
When the event closures change after this UIViewRepresentable has been mounted (for example a callback captures a parent value such as the current screen/ad id, or swaps to a new analytics object), SwiftUI keeps the existing coordinator rather than rebuilding it for the new struct value. Since updateUIView only reassigns nativeAd.delegate here and the coordinator stores the original closures as immutable lets, native ad events continue invoking stale handlers; update the coordinator's stored actions from the latest representable value before assigning it as the delegate.
Useful? React with 👍 / 👎.
Keep only the designated (adUnitId/request/options) initializer on the main type declaration and move the two convenience initializers into a single extension. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…rence Hold a reference to the representable in the Coordinator and refresh it in updateUIView instead of capturing the event closures once in makeCoordinator. This prevents stale handlers when a callback closure captures a value that changes across renders, while keeping updateUIView to a single assignment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
Describe what each modifier does from the caller's perspective (mirroring SwiftUI's onAppear wording) instead of noting the internal handler, and drop the imperative sentence from the callback comment while keeping the rationale for the argument-less signature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
There was a problem hiding this comment.
Pull request overview
This PR extends NativeAdvertisement to support caller-provided AdMob Request and GADAdLoaderOptions, and surfaces native ad interaction lifecycle events via SwiftUI-style modifiers by wiring NativeAdDelegate through a UIViewRepresentable coordinator. The README is updated to document request customization and the new event callbacks.
Changes:
- Add progressively disclosed
NativeAdvertisementinitializers supporting customRequestand ad loaderoptions. - Forward
NativeAdDelegateevents (tap, swipe gesture click, full-screen present/dismiss, mute) into SwiftUI modifiers via_RepresentedUINativeAdView.Coordinator. - Update README to document request customization and ad event callbacks; remove the AdLoader injection TODO.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| Sources/AdMobUI/Components/UIViewRepresentable/_RepresentedUINativeAdView.swift | Adds callback closures + a coordinator implementing NativeAdDelegate to forward native ad events to SwiftUI. |
| Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift | Adds request/options initializers and event modifiers; passes callbacks into the representable. |
| Sources/AdMobUI/Components/AdMobNativeAd/NativeAdLoader.swift | Allows configuring the loader with a custom Request and ad loader options, and reuses the provided request for loads. |
| README.md | Documents request customization and the new ad event callback modifiers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Describe the concrete problem (per-view loaders re-requesting ads on scroll in List/LazyVStack) and the intended direction (ad pool/cache keyed by ad unit id, batch preloading via GADMultipleAdsAdLoaderOptions, opt-in shared-loader API). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…oaderdelegate-usage-msa1sb
…IView .equatable() skipped updateUIView whenever only the event callback closures changed, since the representable's Equatable implementation intentionally ignores them to avoid comparing captured state. That left the coordinator holding stale callbacks in exactly the case the parent-reference refresh was meant to fix, since the refresh never ran either. Move the same comparison (same NativeAd instance, same element frames) into updateUIView as an early return, and refresh the coordinator unconditionally before it. Cost is unchanged (same O(n) comparison, no extra allocation), and the heavy work (asset registration, constraint rebuilding) still only runs when something actually changed. Also add dismantleUIView to unregister the ad view when a cell is torn down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
Pure rename in preparation for giving this per-view object a second responsibility: borrowing from a shared NativeAdvertisementLoader instead of always running its own request. No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…oader NativeAdvertisement used to own a private AdLoader and request a fresh ad in onAppear. In a List/LazyVStack, SwiftUI destroys and recreates a cell as it scrolls out and back into view, so every reappearance re-requested an ad even though a native ad's asset views were only ever bound to one NativeAdView at a time. Ad requests are billed and rate limited, so a scrolling feed could send far more requests than the number of ads it actually shows. Add NativeAdvertisementLoader, a shared pool keyed by ad unit id that loads ads once, lends them out to views, and takes them back when a view goes away, so a cell scrolling back into view reuses an ad instead of re-requesting one. It keeps multiple AdLoaders in flight per ad unit id (Configuration's maximumConcurrentLoads) to preserve today's parallel first-load behavior, can batch a single request into several ads via Configuration.numberOfAdvertisements (opt-in, since AdMob only serves Google ads and disables mediation for a multi-ad request), and drops ads older than the SDK's ~1 hour validity window. NativeAdvertisementBinder now either drives its own AdLoader (when the caller passed an explicit request/options) or borrows from the shared loader supplied through the new `View.nativeAdvertisementLoader(_:)` environment modifier, returning what it borrowed on deinit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…TODO Add a "Reusing ads in a feed" section explaining the default reuse behavior, how to configure NativeAdvertisementLoader.Configuration (including the mediation trade-off of numberOfAdvertisements and the ~1 hour validity window), and why an explicit request: opts a view out of the shared loader. The "Improve performance" TODO is resolved by this change, so it's removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…-msa1sb' into claude/gadadloaderdelegate-usage-msa1sb
NativeAdvertisementLoader is a public type, so methods satisfying the public NativeAdLoaderDelegate/AdLoaderDelegate requirements must themselves be public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
Use NotificationCenter's Combine publisher (iOS 13+, compatible with this package's iOS 14 minimum) instead of the @objc selector-based observer API. The AnyCancellable cancels itself on deinit, so the explicit deinit/ removeObserver pair is no longer needed either. Also trim the class doc comment back to describing what the type is; the usage recipe involving View.nativeAdvertisementLoader(_:) belongs in the README, not in NativeAdvertisementLoader's own summary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
- Rename the binder's loadAd(preferring:) to loadAd(with loader:); the old "sharedLoader" parameter name read too close to "source.adLoader" (the SDK's AdLoader) in the same scope. - Rename NativeAdvertisementBinder.OwnSource to Source (mirrors the "where do ads come from" naming Kingfisher uses for its own Source type). - Drop the redundant "AdUnitId" from every forAdUnitId/adUnitId parameter pair across NativeAdvertisementLoader/Binder; Swift API Design Guidelines call for the preposition alone (for adUnitId:) since the internal name already carries the meaning. - Name every firstIndex(where:) result by what it found instead of leaving it as bare `index` (availableEntryIndex / matchingEntryIndex). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
giveBack(_:for:) dropped an expired entry without calling ensureLoadInFlight, so any waiter already queued for that ad unit id (registered via lend(for:requester:onChange:)) would never be resolved: nothing else would have triggered a new load on their behalf. This is reachable whenever an ad is held long enough to cross the ~1 hour retention window before being returned, e.g. scrolling back to a long-idle cell. Also document several other deliberate, non-obvious choices that had no comment explaining them: why purgeExpiredEntries/trimRetainedAdvertisements only ever touch idle (not lent) entries, why a load that returns zero ads is failed out to every waiter instead of retried automatically, and why dismantleUIView unregisters the ad view (a returned pool ad can be lent straight back out to a different view). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
The last two runs failed identically before compiling a single source file:
"Unable to find a destination matching ... { generic:1, platform:iOS
Simulator }" because the current macos-15-arm64 runner image doesn't have
the iOS simulator runtime installed. This library has no test target (the
workflow's own has_tests check confirms it), so it never needs to run on a
simulator — generic/platform=iOS builds and links for iOS without requiring
any simulator runtime to be present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…ng feedback - Rewrite adLoaderDidFinishLoading's guard as an if/early-return so the function's tail is the normal continuation (ensureLoadInFlight) and the terminal-failure branch reads as the exceptional case, not the other way around. - Rewrite nativeAdvertisementLoader(_:)'s doc comment to state what it achieves (ads shared/reused across the subtree), not the environment plumbing used to do it, mirroring AsyncImage's asyncImageURLSession(_:). - Add the previously-missing nativeAdDidRecordImpression handling, exposed as onImpressionRecorded(perform:). - Rename onWillAppear/onWillDisappear to onWillPresent/onWillDismiss to match the underlying nativeAdWillPresentScreen/nativeAdWillDismissScreen delegate methods (these are full-screen presentation events, not the SwiftUI view-appearance ones onDismiss already avoided colliding with). - Rename NativeAdvertisementBinder's sharedLoader to advertisementLoader: it holds whatever loader was attached via loadAd(with:), not necessarily NativeAdvertisementLoader.shared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…default Google's native ads guidance for iOS asks apps to (1) precache the list of ads a feed is about to show, (2) clear and reload the cache after ~1 hour, (3) cache only what's about to be visible, and (4) discard ads once they're no longer needed. This addressed (2) only lazily (on lend/giveBack) and used a maximumRetainedAdvertisements default that was looser than "only what's visible." - Add NativeAdvertisementLoader.prefetch(_:for:), so an app can request ads ahead of a list appearing instead of relying on each cell's onAppear to trigger a load. It only tops up what's missing (idle entries plus what in-flight loads are expected to yield) and never re-requests once enough supply exists. - Sweep every ad unit id for expired entries proactively — on a 5 minute timer and on returning to the foreground — instead of only discovering staleness the next time something calls lend/giveBack for that specific ad unit id. - Lower the default maximumRetainedAdvertisements from 10 to 5, and clamp it internally to never go below numberOfAdvertisements (a lower retention cap would otherwise trim members of a batch the moment it arrives, wasting part of what was just paid for). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…sion The compiler flagged @preconcurrency on the AdLoaderDelegate conformance as having no effect, so remove it. Also move prefetch(_:for:) out of the lend/cancelLending/giveBack extension (the borrow-and-return protocol the Binder uses) into its own extension, since precaching is a separate, directly caller-facing concern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
Every other "how many" knob on NativeAdvertisementLoader (numberOfAdvertisements, maximumConcurrentLoads, maximumRetainedAdvertisements) is set once via Configuration when the loader is constructed. Letting prefetch take its own count reintroduced the same decision at the call site through a second, easily out-of-sync source of truth, and left the caller guessing what the number should represent (ad slots on screen? the whole list?). prefetch(for:) now simply tops up to Configuration.maximumRetainedAdvertisements, which the caller already set for the list in question. Also fix the DocC symbol reference syntax (``Type/member``, not a plain code span with a dot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementLoader.swift:35
Configurationdocuments valid ranges (e.g.numberOfAdvertisements1...5), but the initializer currently accepts any values. Invalid values can flow intoMultipleAdsAdLoaderOptions.numberOfAdsor concurrency/retention logic and lead to undefined SDK behavior; it’s better to fail fast with preconditions (or clamp) at initialization time.
public init(configuration: Configuration) {
self.configuration = configuration
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementBinder.swift:84
- When
NativeAdvertisementBinderis using the caller-suppliedrequest/optionspath,loadAd(with:)will callsource.adLoader.load(...)on every.onAppear, even after a successful load. That can trigger repeated (billed/rate-limited) loads when the view reappears (e.g. navigation/tab switches). Consider skipping the load once a.successad is already present.
func loadAd(with loader: NativeAdvertisementLoader) {
if let source {
source.adLoader.load(source.request)
return
}
.github/workflows/build-and-test.yml:60
- Building for
generic/platform=iOScan unexpectedly require device signing settings and diverges from the simulator destination used for tests. Unless there’s a specific reason to build the iphoneos SDK here, prefer building the same destination as tests (simulator) to avoid CI failures due to code signing/provisioning.
xcodebuild build \
-scheme AdMobUI \
-destination 'generic/platform=iOS'
- NativeAdvertisementBinder.loadAd(with:) never guarded the own-request (request:/options: init) path against being called again: every onAppear (a NavigationStack pop, a TabView switch bringing the view back, etc.) re-called source.adLoader.load(_:) even after a successful load, sending another billed request. Add the same idempotency guard already used on the shared-loader path, via a hasStartedOwnLoad flag. - A caller-configured Configuration.maximumConcurrentLoads of 0 or less made ensureLoadInFlight's "is a slot free" check permanently false, so a waiter registered via lend(for:requester:onChange:) would sit stuck forever with no load ever allowed to start and no error surfaced. Clamp it to at least 1 the same way maximumRetainedAdvertisements is already clamped against numberOfAdvertisements. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…n-thread requirement
_RepresentedUINativeAdView.updateUIView(_:context:) set
context.coordinator.parent = self after the "guard let nativeAd else
{ return }", contradicting its own comment that the refresh happens
"even when the rest of this update is skipped below." Move it above
the guard so it always runs, matching the comment's intent.
Also document that NativeAdvertisementLoader must be driven from the
main thread: it mutates several plain dictionaries from its public API
and from AdLoaderDelegate/NativeAdLoaderDelegate callbacks with no
internal synchronization, relying entirely on the fact that SwiftUI
(and the ad SDK's own callbacks) only ever touch it there.
…inactor A doc comment saying "call this from the main thread" is not a contract, it's a suggestion nobody is forced to follow. Mark NativeAdvertisementLoader @mainactor so the compiler actually rejects calls from off the main thread, instead of only documenting the requirement. NativeAdvertisementBinder is marked @mainactor too since it calls straight into the loader synchronously and is itself only ever driven from SwiftUI. shared is nonisolated(unsafe) since reading a reference to an already-initialized singleton is safe from any thread; only the loader's actual mutable state needs the actor's protection.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementLoader.swift:18
NativeAdvertisementLoaderis@MainActor, butsharedis declarednonisolated(unsafe). Becausestatic letinitialization can occur on whatever thread first touches it, this can construct the loader off the main actor (setting up UIKit notifications/timers) and violate the class’s actor-isolation assumptions.
@MainActor
public final class NativeAdvertisementLoader: NSObject {
public nonisolated(unsafe) static let shared: NativeAdvertisementLoader = .init(configuration: .default)
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementBinder.swift:102
- When using the caller-supplied
AdLoaderpath, the binder never handlesadLoaderDidFinishLoading(_:). If the SDK finishes without callingdidReceiveordidFail(the loader has a defensivenoAdvertisementReceivedfallback for this), the view will stay stuck in.emptyindefinitely.
extension NativeAdvertisementBinder: NativeAdLoaderDelegate {
func adLoader(_ adLoader: AdLoader, didReceive nativeAd: NativeAd) {
self.nativeAdvertisementPhase = .success(nativeAd)
}
func adLoader(_ adLoader: AdLoader, didFailToReceiveAdWithError error: any Error) {
self.nativeAdvertisementPhase = .failure(error)
}
}
.github/workflows/build-and-test.yml:60
- This repo currently has no SwiftPM test targets (so the
Buildstep always runs), and the destination was changed togeneric/platform=iOS. Building for a generic iOS (device) destination can introduce code-signing/device-SDK related CI failures; using the Simulator destination (and/or explicitly disabling code signing) tends to be more robust for library builds.
set -o pipefail
xcodebuild build \
-scheme AdMobUI \
-destination 'generic/platform=iOS'
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift:161
- The PR description says the new modifiers are
onWillAppear/onWillDisappear, but the implementation (and README) addonWillPresent/onWillDismiss(plusonImpressionRecorded). Please update the PR description to match the shipped API names so reviewers/users aren’t misled.
/// Adds an action to perform before the ad presents a full screen view.
/// - Parameter action: The action to perform.
public func onWillPresent(perform action: @escaping () -> Void) -> Self {
var view: Self = self
view.onWillPresentAction = action
return view
}
/// Adds an action to perform before the ad's full screen view is dismissed.
/// - Parameter action: The action to perform.
public func onWillDismiss(perform action: @escaping () -> Void) -> Self {
var view: Self = self
view.onWillDismissAction = action
return view
}
…ct via delegate adaptor Mark each function that touches the loader's shared mutable state @mainactor individually instead of the whole class. init(configuration:) and the pure configuration-reading helpers (effectiveMaximumRetainedAdvertisements, effectiveMaximumConcurrentLoads, effectiveNumberOfAdvertisements, isExpired) stay nonisolated since they never touch that state, which also means `shared` no longer needs nonisolated(unsafe): its initializer doesn't cross into isolated code. Introduce NativeAdLoaderDelegateAdaptor, a small NSObject-based type that bridges AdLoader's Objective-C delegate callbacks to plain closures. Both NativeAdvertisementLoader and NativeAdvertisementBinder used to inherit NSObject solely to satisfy that delegate protocol themselves; now they just hand each AdLoader its own adaptor instance and drop NSObject entirely.
The review question "why are there two places creating an AdLoader" came from a documentation gap, not a structural one. NativeAdvertisementBinder drives its own AdLoader for the request:/options: initializer and borrows from a shared NativeAdvertisementLoader otherwise, but nothing in the code said why. Both initializers now state which path they take and why, and NativeAdvertisementLoader.startLoad(for:) notes that its AdLoader is the pool's, feeding shared inventory rather than one view. Also fixes three compile errors that had CI red: deinit is nonisolated even on a @mainactor class, so it can neither read @published's synthesized accessor nor call the loader's @mainactor methods directly. A plain currentNativeAd property mirrors what deinit needs, and the giveBack/cancelLending calls hop through a Task without capturing self. Two smaller fixes: - EnvironmentValues.nativeAdvertisementLoader now defaults to nil instead of .shared, so reading it no longer builds the shared loader and starts the subscriptions and repeating timer its init sets up. .shared is substituted in loadAd(with:) on the borrowing path only. - dismantleUIView was written as an instance method taking context:, which satisfies no UIViewRepresentable requirement and never ran, leaving unregisterAdView() uncalled. Corrected to the static coordinator: form.
The comment narrated the fix (instance method vs. static) rather than stating anything about the resulting code. The static + coordinator: signature already speaks for itself.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementBinder.swift:132
loadAd(with:)only lends/loads once per binder instance (guard !hasStartedOwnLoad/guard advertisementLoader == nil). After a terminal failure, subsequentonAppearevents will never retry, even thoughNativeAdvertisementLoader’s finish-loading path expects callers to re-runlend(...)to retry. Consider allowing retry when the phase is.failure, while still preventing duplicate loads when a request is already in flight or has succeeded.
func loadAd(with loader: NativeAdvertisementLoader?) {
if let source {
// Without this guard, every onAppear (e.g. a NavigationStack pop or TabView switch
// bringing this view back) would call load(_:) again and send another billed
// request, even though the first one already succeeded or is still in flight.
.github/workflows/build-and-test.yml:60
- The CI “Build” step now targets
generic/platform=iOSinstead of the simulator. Building for device typically requires code signing and can fail on GitHub Actions runners when no signing identity is configured. If the goal is a sign-free build check, keep the destination oniOS Simulator.
xcodebuild build \
-scheme AdMobUI \
-destination 'generic/platform=iOS'
…an ad The own-AdLoader path passed a no-op for onFinishLoading, so a request that completed without either didReceive or didFailToReceiveAdWithError firing (a case the SDK documents as possible but not expected) left the phase stuck at .empty indefinitely. Mirror the shared pool's existing fallback: fail with NativeAdvertisementLoaderError.noAdvertisementReceived only if the phase is still .empty when the load finishes.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdLoaderDelegateAdaptor.swift:28
- Initializer doesn’t call super.init(). As an NSObject subclass, this won’t compile (Swift requires super.init() before returning).
init(
onReceive: @escaping (_ adLoader: AdLoader, _ nativeAd: NativeAd) -> Void,
onFailure: @escaping (_ adLoader: AdLoader, _ error: any Error) -> Void,
onFinishLoading: @escaping (_ adLoader: AdLoader) -> Void
) {
self.onReceive = onReceive
self.onFailure = onFailure
self.onFinishLoading = onFinishLoading
}
| xcodebuild build \ | ||
| -scheme AdMobUI \ | ||
| -destination 'generic/platform=iOS Simulator' | ||
| -destination 'generic/platform=iOS' |
…empty NativeAdvertisementPhase has no case of its own for "loading", so .empty already covers both "not started" and "in flight". Leaving a finished-but-nothing-received result at .empty too would erase the distinction between those states and "the attempt is settled" for callers, which is why this falls back to .failure instead — matching what the shared pool's own finish-loading handler already does.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Both delegateAdaptor and delegateAdaptorsByAdLoader are assigned but never read back within their own type. That's intentional: AdLoader.delegate is weak, so without a strong reference held somewhere, the adaptor would be deallocated as soon as the initializer/startLoad(for:) returns, silently breaking every subsequent delegate callback.
…njection NativeAdvertisement(adUnitId:request:) and (adUnitId:request:options:) did the same thing as applying a NativeAdvertisementLoader whose Configuration carries that request — Configuration already exposes both request and options, so the two were the same knob. The initializer form was also the worse of the two. Its loader lived inside the view's own @StateObject, so a List rebuilding a cell threw away the loaded ads with it and reloaded from scratch, which is the very thing the shared loader exists to prevent. A loader the caller holds survives view recreation and keeps its inventory. This leaves one customization axis, matching Kingfisher and NukeUI, both of which expose loader replacement and no initializer variants. It also collapses NativeAdvertisementBinder to a single path: Source, delegateAdaptor, hasStartedOwnLoad and the own-AdLoader branch are all gone, and AdLoader is now constructed in exactly one place (NativeAdvertisementLoader.startLoad(for:)). Not a breaking change: main only ever exposed init(adUnitId:adContent:). The removed initializers were added in this unmerged branch.
Both comments described who used the code rather than what the code is, so each one needed editing whenever a consumer changed — and the startLoad(for:) one had already gone stale, still claiming that NativeAdvertisementBinder drives an AdLoader of its own. NativeAdLoaderDelegateAdaptor now states what it does for any type that adopts it. startLoad(for:) loses its comment entirely: it existed to explain why a second AdLoader construction site was there, and there is only one now.
"Borrowing" named the internal lend/giveBack model, not something a caller needs to know. State the effect that actually matters to them: a view that reappears without a new identity doesn't send another request.
… style "Apply X to choose Y" told the reader what to do; Apple's own discussion prose (e.g. AsyncImage.init(request:scale:)) states what happens instead.
…oaderdelegate-usage-msa1sb
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
.github/workflows/build-and-test.yml:59
- The build-only job now targets
generic/platform=iOS(device). If this workflow runs on PRs without tests, CI will no longer compile the package for the iOS Simulator, which can hide simulator-only build issues (and diverges from thexcodebuild testdestination below). Consider keeping the destination as iOS Simulator here as well.
-destination 'generic/platform=iOS'
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementBinder.swift:63
loadAd(with:)permanently no-ops after the first call because it only checksadvertisementLoader == nil. That makes a.failurephase effectively non-retryable for a view that reappears with the same identity, which contradicts the loader’s own retry strategy (it expects the caller to calllendagain after terminal failure). Consider allowing retries when the current phase is.failure, while still preventing duplicate waiter registration during an in-flight request.
// Asking again once a loader is bound would duplicate a billed request.
guard advertisementLoader == nil else { return }
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift:121
- PR description mentions delivering
onWillAppear/onWillDisappearcallbacks, but the public API added here isonWillPresent/onWillDismiss(matching the underlyingNativeAdDelegatescreen presentation callbacks). Please update the PR description (or rename the API) so the documented callback names match what’s actually shipped.
/// Adds an action to perform before the ad presents a full screen view.
/// - Parameter action: The action to perform.
public func onWillPresent(perform action: @escaping () -> Void) -> Self {
var view: Self = self
view.onWillPresentAction = action
return view
}
/// Adds an action to perform before the ad's full screen view is dismissed.
/// - Parameter action: The action to perform.
public func onWillDismiss(perform action: @escaping () -> Void) -> Self {
pass a custom Request and GADAdLoaderOptions when they need them, keeping the
ad-unit-id-only initializer as the simple default.
onWillDismiss, onDismiss, onAdvertisementMuted) through modifiers on
NativeAdvertisement, wired via a Coordinator on _RepresentedUINativeAdView so
the loader stays load-only.
request customization and ad event callbacks.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz