diff --git a/README.md b/README.md index 23761ad..11ef24a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift b/Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift index 217e755..9fd927c 100644 --- a/Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift +++ b/Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift @@ -46,15 +46,9 @@ public struct NativeAdvertisement: 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, diff --git a/Sources/AdMobUI/Components/ElementFrame.swift b/Sources/AdMobUI/Components/ElementFrame.swift new file mode 100644 index 0000000..459f9df --- /dev/null +++ b/Sources/AdMobUI/Components/ElementFrame.swift @@ -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 does. + let frame: CGRect +} diff --git a/Sources/AdMobUI/Components/ElementFramePreferenceKey.swift b/Sources/AdMobUI/Components/ElementFramePreferenceKey.swift new file mode 100644 index 0000000..d083d68 --- /dev/null +++ b/Sources/AdMobUI/Components/ElementFramePreferenceKey.swift @@ -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) + } +} diff --git a/Sources/AdMobUI/Components/NativeAdvertisementCoordinateSpaceName.swift b/Sources/AdMobUI/Components/NativeAdvertisementCoordinateSpaceName.swift new file mode 100644 index 0000000..54fa19c --- /dev/null +++ b/Sources/AdMobUI/Components/NativeAdvertisementCoordinateSpaceName.swift @@ -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 {} diff --git a/Sources/AdMobUI/Components/TypedAnchor.swift b/Sources/AdMobUI/Components/TypedAnchor.swift deleted file mode 100644 index 70e2cb0..0000000 --- a/Sources/AdMobUI/Components/TypedAnchor.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// TypedAnchor.swift -// AdMobUI -// -// Created by Takashi Ushikoshi on 2025/07/17. -// -// - -import SwiftUI - -internal struct TypedAnchor { - let viewType: NativeAdChildViewType - let anchor: Anchor -} diff --git a/Sources/AdMobUI/Components/TypedAnchorBoundsPreferenceKey.swift b/Sources/AdMobUI/Components/TypedAnchorBoundsPreferenceKey.swift deleted file mode 100644 index 6671f66..0000000 --- a/Sources/AdMobUI/Components/TypedAnchorBoundsPreferenceKey.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// TypedAnchorBoundsPreferenceKey.swift -// AdMobSwiftUI -// -// Created by Takashi Ushikoshi on 2025/07/09. -// -// - -import SwiftUI - -internal struct TypedAnchorBoundsPreferenceKey: PreferenceKey { - static var defaultValue: [TypedAnchor] = [] - - static func reduce( - value: inout [TypedAnchor], - nextValue: () -> [TypedAnchor] - ) { - // nextValue always returns single or no elements because inside anchorPreference modifier returns an array containing a single element. - let nextValueElement = nextValue().first - - // If nextValueElement is nil, it means there are no new anchors to process. - guard let nextValueElement else { return } - - // if the nextValueElement already exists in the value array, - // update its anchor instead of appending a new one. - if let existingTypedAnchor = value.first(where: { $0.viewType == nextValueElement.viewType }) { - let updatedTypedAnchor: TypedAnchor = .init( - viewType: existingTypedAnchor.viewType, - anchor: nextValueElement.anchor - ) - - let existingElementRemovedAnchors = value.filter { $0.viewType != existingTypedAnchor.viewType } - - value = existingElementRemovedAnchors + [updatedTypedAnchor] - - return - } - - value.append(nextValueElement) - } -} diff --git a/Sources/AdMobUI/Components/UIKit/ElementFittingConstraints.swift b/Sources/AdMobUI/Components/UIKit/ElementFittingConstraints.swift new file mode 100644 index 0000000..2c673a5 --- /dev/null +++ b/Sources/AdMobUI/Components/UIKit/ElementFittingConstraints.swift @@ -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] + } +} diff --git a/Sources/AdMobUI/Components/UIKit/_UINativeAdView.swift b/Sources/AdMobUI/Components/UIKit/_UINativeAdView.swift index 32a73c3..165c47c 100644 --- a/Sources/AdMobUI/Components/UIKit/_UINativeAdView.swift +++ b/Sources/AdMobUI/Components/UIKit/_UINativeAdView.swift @@ -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] = [] diff --git a/Sources/AdMobUI/Components/UIViewRepresentable/_RepresentedUINativeAdView.swift b/Sources/AdMobUI/Components/UIViewRepresentable/_RepresentedUINativeAdView.swift index 9af9d05..7846264 100644 --- a/Sources/AdMobUI/Components/UIViewRepresentable/_RepresentedUINativeAdView.swift +++ b/Sources/AdMobUI/Components/UIViewRepresentable/_RepresentedUINativeAdView.swift @@ -9,11 +9,6 @@ import GoogleMobileAds import SwiftUI -internal struct ElementFrame { - let elementType: NativeAdChildViewType - let frame: CGRect -} - internal struct _RepresentedUINativeAdView: UIViewRepresentable { typealias UIViewType = _UINativeAdView @@ -54,13 +49,18 @@ internal struct _RepresentedUINativeAdView: UIViewRepresentable { let staleElementTypes: Set = 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 } @@ -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: @@ -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: @@ -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 diff --git a/Sources/AdMobUI/Extensions/View+.swift b/Sources/AdMobUI/Extensions/View+.swift index d99cb8f..f56350f 100644 --- a/Sources/AdMobUI/Extensions/View+.swift +++ b/Sources/AdMobUI/Extensions/View+.swift @@ -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()) + ) + ) + ] + ) + } + ) } }