From eca301b115f8066fcb90a2fee8cc042262f6ae32 Mon Sep 17 00:00:00 2001 From: topAmity <112688936+topAmity@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:35:07 +0700 Subject: [PATCH] Google ads component in UIKit --- .../Paginator/FixedFrequencyAdInjector.swift | 2 + .../Ads/Paginator/UIKitPaginator.swift | 51 ++++++++++++++++++ .../AmityCommunityFeedComponent.swift | 14 +++++ .../NewsFeed/AmityGlobalFeedComponent.swift | 13 +++++ .../NewsFeed/AmityNewsFeedComponent.swift | 23 ++++++++ .../SocialHome/NewsFeed/BannerAdView.swift | 28 ++++++++++ .../NewsFeed/PostFeedViewModel.swift | 53 ++++++++++++++++++- .../ChildViews/CommentCoreViewModel.swift | 2 + .../ChildViews/CommentListView.swift | 2 + .../Story/Models/AmityStoryTargetModel.swift | 2 + .../ViewStory/ChildViews/StoryCoreView.swift | 2 + 11 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/BannerAdView.swift diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Ads/Paginator/FixedFrequencyAdInjector.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Ads/Paginator/FixedFrequencyAdInjector.swift index cc4911f2..2058bf12 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Ads/Paginator/FixedFrequencyAdInjector.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Ads/Paginator/FixedFrequencyAdInjector.swift @@ -198,6 +198,8 @@ class FixedFrequencyAdsInjector { Log.add(event: .info, "[\(index)] - 🐶 Ad \(ad.adId)") case .content(let value): Log.add(event: .info, "[\(index)] - Content \(modelIdentifier(value))") + case .bannerAd(let placement): + Log.add(event: .info, "[\(index)] - 📢 BannerAd \(placement.id)") } } } diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Ads/Paginator/UIKitPaginator.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Ads/Paginator/UIKitPaginator.swift index 455248ee..93d95b3c 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Ads/Paginator/UIKitPaginator.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Ads/Paginator/UIKitPaginator.swift @@ -8,6 +8,56 @@ import Foundation import AmitySDK import Combine +import SwiftUI + +// MARK: - Banner Ad Models +// Defined here so every file that sees UIKitPaginator also sees these types. + +/// Represents a single banner-ad slot that will be rendered inside the feed. +public struct BannerAdPlacement: Identifiable { + public let id: String + public let adUnitID: String + + public init(adUnitID: String) { + self.id = "banner-ad-\(UUID().uuidString)" + self.adUnitID = adUnitID + } +} + +/// Controls how / whether banner ads are injected into the news feed. +/// Configure from your host app **before** the feed loads. +public class BannerAdFeedConfig { + public static let shared = BannerAdFeedConfig() + + /// Master switch – set to `true` to start injecting ads. + public var isEnabled: Bool = false + + /// The ad-unit ID forwarded to every `BannerAdPlacement`. + public var adUnitID: String = "ca-app-pub-3940256099942544/2934735716" + + /// Insert a banner ad after every *frequency* content posts. + public var frequency: Int = 5 + + /// Maximum number of banner ads in the feed at any time. + public var maxAdsCount: Int = 3 + + /// Height (points) for the ad row. Defaults to 250. + public var adHeight: CGFloat = 250 + + /// Closure that returns the SwiftUI view for a given placement. + /// The host app **must** set this to supply the actual ad view. + /// + /// ```swift + /// BannerAdFeedConfig.shared.adViewBuilder = { placement in + /// AnyView(BannerAdView(adUnitID: placement.adUnitID)) + /// } + /// ``` + public var adViewBuilder: ((BannerAdPlacement) -> AnyView)? + + private init() {} +} + +// MARK: - Paginated Item enum PaginatedItemType { case content @@ -18,6 +68,7 @@ class PaginatedItem: Identifiable, Equatable { enum ItemType { case content(_ value: Content) case ad(_ value: AmityAd) + case bannerAd(_ value: BannerAdPlacement) } var id: String diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/CommunityProfile/AmityCommunityFeedComponent.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/CommunityProfile/AmityCommunityFeedComponent.swift index a5552a89..1cacd822 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/CommunityProfile/AmityCommunityFeedComponent.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/CommunityProfile/AmityCommunityFeedComponent.swift @@ -85,6 +85,20 @@ public struct AmityCommunityFeedComponent: AmityComponentView { .frame(height: 8) .opacity(postFeedViewModel.postItems.count - 1 == index ? 0 : 1) } + + case .bannerAd(let placement): + if let adView = BannerAdFeedConfig.shared.adViewBuilder?(placement) { + VStack(spacing: 0) { + adView + .frame(height: BannerAdFeedConfig.shared.adHeight) + .frame(maxWidth: .infinity) + + Rectangle() + .fill(Color(viewConfig.theme.baseColorShade4)) + .frame(height: 8) + .opacity(postFeedViewModel.postItems.count - 1 == index ? 0 : 1) + } + } case .content(let post): diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/AmityGlobalFeedComponent.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/AmityGlobalFeedComponent.swift index 279a008f..7738bad9 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/AmityGlobalFeedComponent.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/AmityGlobalFeedComponent.swift @@ -41,6 +41,19 @@ public struct AmityGlobalFeedComponent: AmityComponentView { .fill(Color(viewConfig.theme.baseColorShade4)) .frame(height: 8) } + + case .bannerAd(let placement): + if let adView = BannerAdFeedConfig.shared.adViewBuilder?(placement) { + VStack(spacing: 0) { + adView + .frame(height: BannerAdFeedConfig.shared.adHeight) + .frame(maxWidth: .infinity) + + Rectangle() + .fill(Color(viewConfig.theme.baseColorShade4)) + .frame(height: 8) + } + } case .content(let post): diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/AmityNewsFeedComponent.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/AmityNewsFeedComponent.swift index 23d46d6d..42d9b9ad 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/AmityNewsFeedComponent.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/AmityNewsFeedComponent.swift @@ -110,6 +110,29 @@ public struct AmityNewsFeedComponent: AmityComponentView { .frame(height: 8) } + case .bannerAd(let placement): + if let adView = BannerAdFeedConfig.shared.adViewBuilder?(placement) { + VStack(spacing: 0) { + // "Sponsored" header + HStack { + AdSponsorLabel() + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(viewConfig.theme.backgroundColor)) + + // The actual ad view supplied by the host app + adView + .frame(height: BannerAdFeedConfig.shared.adHeight) + .frame(maxWidth: .infinity) + + Rectangle() + .fill(Color(viewConfig.theme.baseColorShade4)) + .frame(height: 8) + } + } + case .content(let post): VStack(spacing: 0){ diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/BannerAdView.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/BannerAdView.swift new file mode 100644 index 00000000..40de9f4a --- /dev/null +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/BannerAdView.swift @@ -0,0 +1,28 @@ +import SwiftUI +import GoogleMobileAds + +struct BannerAdView: UIViewRepresentable { + + let adUnitID: String + + func makeUIView(context: Context) -> BannerView { + let banner = BannerView(adSize: AdSizeBanner) + banner.adUnitID = adUnitID + banner.rootViewController = UIApplication.shared.rootViewController + banner.load(Request()) + return banner + } + + func updateUIView(_ uiView: BannerView, context: Context) {} +} + +// Helper to get root view controller +extension UIApplication { + var rootViewController: UIViewController? { + guard let scene = connectedScenes.first as? UIWindowScene, + let root = scene.windows.first?.rootViewController else { + return nil + } + return root + } +} diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/PostFeedViewModel.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/PostFeedViewModel.swift index 22c000ee..124519bd 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/PostFeedViewModel.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Social/Components/SocialHome/NewsFeed/PostFeedViewModel.swift @@ -35,6 +35,11 @@ class PostFeedViewModel: ObservableObject { private var globalPinnedPostsIds: Set = [] private var feedPosts: [PaginatedItem] = [] + /// Cached banner-ad slots keyed by their position index (0, 1, 2 …). + /// Reusing the same instances keeps their `id` stable so SwiftUI does + /// not recreate (and therefore "blink") the ad views on every re-render. + private var cachedBannerAds: [Int: PaginatedItem] = [:] + public enum FeedType: Equatable { case community(communityId: String) case globalFeed @@ -229,6 +234,9 @@ extension PostFeedViewModel { let feedPosts = prepareFeedPosts() listItems.append(contentsOf: feedPosts) + // Inject external banner ads (e.g. Google Ads) into the feed + listItems = injectBannerAds(into: listItems) + self.postItems = listItems } @@ -258,6 +266,8 @@ extension PostFeedViewModel { if canRenderPost(post: post) && !globalPinnedPostsIds.contains(post.postId) { finalItems.append(PaginatedItem(id: $0.id, type: .content(AmityPostModel(post: post)))) } + case .bannerAd: + break // banner ads are injected later in renderFeed() } } @@ -268,6 +278,45 @@ extension PostFeedViewModel { let filterCondition = !post.childrenPosts.contains { $0.dataType == "file" || $0.dataType == "audio" || $0.structureType == "mixed" } return filterCondition } + + // MARK: - External Banner Ad Injection + + /// Inserts `BannerAdPlacement` items into the list at a fixed frequency + /// controlled by `BannerAdFeedConfig`. + /// Placements are cached so their IDs stay stable across re-renders, + /// preventing SwiftUI from recreating (blinking) the ad views. + private func injectBannerAds(into items: [PaginatedItem]) -> [PaginatedItem] { + let config = BannerAdFeedConfig.shared + guard config.isEnabled, config.frequency > 0, config.adViewBuilder != nil else { + return items + } + + var result = [PaginatedItem]() + var adsInserted = 0 + var contentCount = 0 + + for item in items { + result.append(item) + + if case .content = item.type { + contentCount += 1 + } + + if contentCount > 0, + contentCount % config.frequency == 0, + adsInserted < config.maxAdsCount { + + // Reuse the cached item for this slot index, or create once + let slotIndex = adsInserted + if cachedBannerAds[slotIndex] == nil { + let placement = BannerAdPlacement(adUnitID: config.adUnitID) + cachedBannerAds[slotIndex] = PaginatedItem(id: placement.id, type: .bannerAd(placement)) + } + result.append(cachedBannerAds[slotIndex]!) + adsInserted += 1 + } + } + + return result + } } - - diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Components/StoryComment/ChildViews/CommentCoreViewModel.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Components/StoryComment/ChildViews/CommentCoreViewModel.swift index 106961db..d197a118 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Components/StoryComment/ChildViews/CommentCoreViewModel.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Components/StoryComment/ChildViews/CommentCoreViewModel.swift @@ -140,6 +140,8 @@ class CommentCoreViewModel: ObservableObject { return PaginatedItem(id: $0.id, type: .content(AmityCommentModel(comment: comment))) case .ad(let ad): return PaginatedItem(id: $0.id, type: .ad(ad)) + case .bannerAd(let placement): + return PaginatedItem(id: $0.id, type: .bannerAd(placement)) } } items.append(contentsOf: mappedLoadedItems) diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Components/StoryComment/ChildViews/CommentListView.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Components/StoryComment/ChildViews/CommentListView.swift index ee6e9c45..4f43ac1a 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Components/StoryComment/ChildViews/CommentListView.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Components/StoryComment/ChildViews/CommentListView.swift @@ -109,6 +109,8 @@ struct CommentListView: View where Content: View { AmityCommentAdComponent(ad: ad, selctedAdInfoAction: { ad in commentCoreViewModel.adSeetState = (true, ad) }) + case .bannerAd: + EmptyView() case .content(let comment): Section { if !comment.isDeleted { diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Models/AmityStoryTargetModel.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Models/AmityStoryTargetModel.swift index 6338fb69..daac6139 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Models/AmityStoryTargetModel.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Models/AmityStoryTargetModel.swift @@ -140,6 +140,8 @@ public class AmityStoryTargetModel: ObservableObject, Identifiable, Equatable { videoURLs.append(videoURL) } return PaginatedItem(id: story.storyId, type: .content(AmityStoryModel(story: story))) + case .bannerAd(let placement): + return PaginatedItem(id: placement.id, type: .bannerAd(placement)) } } diff --git a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Pages/ViewStory/ChildViews/StoryCoreView.swift b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Pages/ViewStory/ChildViews/StoryCoreView.swift index 11d04140..a23ba78c 100644 --- a/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Pages/ViewStory/ChildViews/StoryCoreView.swift +++ b/UpstraUIKit/AmityUIKit4/AmityUIKit4/Story/Pages/ViewStory/ChildViews/StoryCoreView.swift @@ -160,6 +160,8 @@ struct StoryCoreView: View, AmityViewIdentifiable { StoryAdView(ad: ad, gestureView: getGestureView) case .content(let storyModel): getStoryView(storyModel) + case .bannerAd: + EmptyView() } HStack(spacing: 0) {