Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ AdMobUI works by overlaying an invisible [`NativeAdView`](https://developers.goo

Each element of the native ad provided to the closure can be annotated with the `nativeAdElement` modifier, which automatically aligns and sizes the transparent [`NativeAdView`](https://developers.google.com/admob/ios/api/reference/Classes/GADNativeAdView) overlay.

Internally, `nativeAdElement` uses `anchorPreference` and `overlayPreferenceValue` to capture the bounds of the annotated elements, enabling the layout of [`NativeAdView`](https://developers.google.com/admob/ios/api/reference/Classes/GADNativeAdView) to be computed automatically.
Internally, `nativeAdElement` measures each annotated element in a coordinate space named on the `NativeAdvertisement` container and reports it through a `PreferenceKey`, enabling the layout of [`NativeAdView`](https://developers.google.com/admob/ios/api/reference/Classes/GADNativeAdView) to be computed automatically.

## Example

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,9 @@ public struct NativeAdvertisement<AdContent: View>: View {

public var body: some View {
adContent(nativeAdvertisementBinder.nativeAdvertisementPhase)
.overlayPreferenceValue(TypedAnchorBoundsPreferenceKey.self, alignment: .center) { namedAnchors in
.coordinateSpace(name: NativeAdvertisementCoordinateSpaceName())
.overlayPreferenceValue(ElementFramePreferenceKey.self, alignment: .center) { elementFrames in
GeometryReader { overlayGeometry in
let elementFrames: [ElementFrame] = namedAnchors.map {
.init(
elementType: $0.viewType,
frame: overlayGeometry[$0.anchor]
)
}

_RepresentedUINativeAdView(
nativeAd: nativeAdvertisementBinder.nativeAdvertisementPhase.nativeAd,
elementFrames: elementFrames,
Expand Down
17 changes: 17 additions & 0 deletions Sources/AdMobUI/Components/ElementFrame.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// ElementFrame.swift
// AdMobUI
//
// Created by Takashi Ushikoshi on 2026/07/29.
//
//

import CoreGraphics

internal struct ElementFrame {
let elementType: NativeAdChildViewType

// Measured in the coordinate space named on the advertisement container, so the value
// never passes through the window-relative arithmetic that a resolved Anchor<CGRect> does.
let frame: CGRect
}
42 changes: 42 additions & 0 deletions Sources/AdMobUI/Components/ElementFramePreferenceKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// ElementFramePreferenceKey.swift
// AdMobUI
//
// Created by Takashi Ushikoshi on 2025/07/09.
//
//

import SwiftUI

internal struct ElementFramePreferenceKey: PreferenceKey {
static var defaultValue: [ElementFrame] = []

static func reduce(
value: inout [ElementFrame],
nextValue: () -> [ElementFrame]
) {
// nextValue always returns single or no elements because the preference modifier inside
// nativeAdElement(_:) attaches a value containing a single element.
let nextValueElement = nextValue().first

// If nextValueElement is nil, it means there are no new element frames to process.
guard let nextValueElement else { return }

// if the nextValueElement already exists in the value array,
// update its frame instead of appending a new one.
if let existingElementFrame = value.first(where: { $0.elementType == nextValueElement.elementType }) {
let updatedElementFrame: ElementFrame = .init(
elementType: existingElementFrame.elementType,
frame: nextValueElement.frame
)

let existingElementRemovedFrames = value.filter { $0.elementType != existingElementFrame.elementType }

value = existingElementRemovedFrames + [updatedElementFrame]

return
}

value.append(nextValueElement)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// NativeAdvertisementCoordinateSpaceName.swift
// AdMobUI
//
// Created by Takashi Ushikoshi on 2026/07/29.
//
//

// A dedicated type rather than a string, so a coordinate space the host app happens to name
// the same way can never end up resolving an element's frame against the wrong ancestor.
internal struct NativeAdvertisementCoordinateSpaceName: Hashable {}
14 changes: 0 additions & 14 deletions Sources/AdMobUI/Components/TypedAnchor.swift

This file was deleted.

41 changes: 0 additions & 41 deletions Sources/AdMobUI/Components/TypedAnchorBoundsPreferenceKey.swift

This file was deleted.

25 changes: 25 additions & 0 deletions Sources/AdMobUI/Components/UIKit/ElementFittingConstraints.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// ElementFittingConstraints.swift
// AdMobUI
//
// Created by Takashi Ushikoshi on 2026/07/29.
//
//

import UIKit

// Addressing each constraint by role, rather than by its position in an array, is what lets
// updateUIView adjust a moved element's constants in place instead of deactivating and
// recreating the whole set on every frame change.
internal struct ElementFittingConstraints {
let leading: NSLayoutConstraint
let top: NSLayoutConstraint
let width: NSLayoutConstraint
let height: NSLayoutConstraint
}

extension ElementFittingConstraints {
var allConstraints: [NSLayoutConstraint] {
[leading, top, width, height]
}
}
2 changes: 1 addition & 1 deletion Sources/AdMobUI/Components/UIKit/_UINativeAdView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import SwiftUI

internal class _UINativeAdView: NativeAdView {
internal var lastAppliedElementFrames: [NativeAdChildViewType: CGRect] = [:]
internal var elementFittingConstraints: [NativeAdChildViewType: [NSLayoutConstraint]] = [:]
internal var elementFittingConstraints: [NativeAdChildViewType: ElementFittingConstraints] = [:]

private var superviewFittingConstraints: [NSLayoutConstraint] = []

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@
import GoogleMobileAds
import SwiftUI

internal struct ElementFrame {
let elementType: NativeAdChildViewType
let frame: CGRect
}

internal struct _RepresentedUINativeAdView: UIViewRepresentable {
typealias UIViewType = _UINativeAdView

Expand Down Expand Up @@ -54,13 +49,18 @@ internal struct _RepresentedUINativeAdView: UIViewRepresentable {
let staleElementTypes: Set<NativeAdChildViewType> = Set(nativeAdView.lastAppliedElementFrames.keys)
.subtracting(currentElementTypes)

// Adding or removing an asset view changes which views the ad has to be registered
// against; moving one is settled entirely by the constraint updates below, so it must
// not also trigger a re-registration.
var hasChangedAssetViews: Bool = !staleElementTypes.isEmpty

// Remove elements no longer present in the current SwiftUI layout, so a stale
// tracking view doesn't keep sitting at its last known position/size and
// doesn't keep being registered as a clickable/trackable asset on the NativeAd.
staleElementTypes.forEach { type in
removeElementView(for: type, from: nativeAdView)

NSLayoutConstraint.deactivate(nativeAdView.elementFittingConstraints[type] ?? [])
NSLayoutConstraint.deactivate(nativeAdView.elementFittingConstraints[type]?.allConstraints ?? [])
nativeAdView.elementFittingConstraints[type] = nil
nativeAdView.lastAppliedElementFrames[type] = nil
}
Expand All @@ -70,6 +70,12 @@ internal struct _RepresentedUINativeAdView: UIViewRepresentable {
let type: NativeAdChildViewType = elementFrame.elementType
let frame: CGRect = elementFrame.frame

// headlineView, callToActionView, etc. are only ever set together with
// lastAppliedElementFrames[type] below, and cleared together with it during stale
// removal above, so its absence here is exactly the condition under which the
// switch below takes its "create a new view" branch.
let isNewAssetView: Bool = nativeAdView.lastAppliedElementFrames[type] == nil

let view: UIView = {
switch type {
case .headline:
Expand Down Expand Up @@ -176,9 +182,12 @@ internal struct _RepresentedUINativeAdView: UIViewRepresentable {
nativeAdView.addSubview(mediaView)
}

// Unlike the other asset views, the media view renders its content
// itself, so it needs the media content assigned.
mediaView.mediaContent = nativeAd.mediaContent
// Unlike the other asset views, the media view renders its content itself.
// Reassigning the same content would restart whatever it's currently
// playing, so this only takes effect when the content actually changed.
if mediaView.mediaContent !== nativeAd.mediaContent {
mediaView.mediaContent = nativeAd.mediaContent
}

return mediaView
case .adChoices:
Expand All @@ -200,29 +209,45 @@ internal struct _RepresentedUINativeAdView: UIViewRepresentable {

view.translatesAutoresizingMaskIntoConstraints = false

if isNewAssetView {
hasChangedAssetViews = true
}

// Skip updating the constraints if the frame hasn't changed since the last time
guard nativeAdView.lastAppliedElementFrames[type] != frame else { return }

// view.constraints only holds constraints owned by view itself (e.g. width/height);
// the leading/top constraints below are owned by their nearest common ancestor
// (nativeAdView), so the constraints we installed last time must be tracked explicitly.
NSLayoutConstraint.deactivate(nativeAdView.elementFittingConstraints[type] ?? [])

let fittingConstraints: [NSLayoutConstraint] = [
view.leadingAnchor.constraint(
equalTo: nativeAdView.leadingAnchor, constant: frame.origin.x),
view.topAnchor.constraint(
equalTo: nativeAdView.topAnchor, constant: frame.origin.y),
view.widthAnchor.constraint(equalToConstant: frame.width),
view.heightAnchor.constraint(equalToConstant: frame.height),
]
if let existingConstraints = nativeAdView.elementFittingConstraints[type] {
// A constraint's constant is mutable, so a moved element is settled by updating
// the four already-installed constraints instead of tearing them down and
// reinstalling a fresh set on every frame change.
existingConstraints.leading.constant = frame.origin.x
existingConstraints.top.constant = frame.origin.y
existingConstraints.width.constant = frame.width
existingConstraints.height.constant = frame.height
} else {
let fittingConstraints: ElementFittingConstraints = .init(
leading: view.leadingAnchor.constraint(
equalTo: nativeAdView.leadingAnchor, constant: frame.origin.x),
top: view.topAnchor.constraint(
equalTo: nativeAdView.topAnchor, constant: frame.origin.y),
width: view.widthAnchor.constraint(equalToConstant: frame.width),
height: view.heightAnchor.constraint(equalToConstant: frame.height)
)

NSLayoutConstraint.activate(fittingConstraints.allConstraints)

nativeAdView.elementFittingConstraints[type] = fittingConstraints
}

NSLayoutConstraint.activate(fittingConstraints)

nativeAdView.elementFittingConstraints[type] = fittingConstraints
nativeAdView.lastAppliedElementFrames[type] = frame
}

// Registration is skipped when only an element's position changed, since re-running it
// repeats work the SDK already considers done (asset-view association, click/impression
// tracking, the AdChoices overlay) and can retrigger SDK-owned debug UI such as ad
// inspector.
guard !hasSameAdvertisement || hasChangedAssetViews else { return }

// The NativeAd instance only exists after the async load completes, so this is
// the only point where its delegate can be set.
nativeAd.delegate = context.coordinator
Expand Down
19 changes: 16 additions & 3 deletions Sources/AdMobUI/Extensions/View+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,22 @@ import SwiftUI

extension View {
public func nativeAdElement(_ elementViewType: NativeAdChildViewType) -> some View {
anchorPreference(key: TypedAnchorBoundsPreferenceKey.self, value: .bounds) { anchor in
return [TypedAnchor(viewType: elementViewType, anchor: anchor)]
}
background(
GeometryReader { elementGeometry in
Color.clear
.preference(
key: ElementFramePreferenceKey.self,
value: [
ElementFrame(
elementType: elementViewType,
frame: elementGeometry.frame(
in: .named(NativeAdvertisementCoordinateSpaceName())
)
)
]
)
}
)
}
}

Expand Down
Loading