In SwiftUI showing an AdMob Interstitial breaks NavigationLink objects - what am I doing wrong?

168 views Asked by At

I have a simple app with a Home View, a Content View and a Settings View. I am trying to show an AdMob Interstitial when I navigate from the Home View to the Content View. The navigation between views is using a combination of NavigationStack and Navigation Links. It all works fine when I don't show the ad. When I do show the ad, all of the Navigation Links are no longer responsive.

using SwiftUI 5.0 on iOS 17.0 in XCode 15

Expected flow: HOME >> AD >> CONTENT >> SETTINGS When ad is shown HOME >> AD >> CONTENT >> no response (back button works then CONTENT link is broken)

Source code is below (to try it you'll have to install the GoogleMobileAds library and add the requisite plist fields). To replicate, run this app (wait a sec for an ad to load) and click on the 'To Content' link. A test ad should show. When you dismiss it you see the Content View. Click on 'To Settings' and the link doesn't work. Click 'Back' and the 'To Content' link doesn't work anymore.

The app flow is HOME >> CONTENTVIEW (with Ad) >> SETTINGSVIEW

[UPDATED WITH SIMPLIFIED CODE]

App Code:

import SwiftUI
import GoogleMobileAds

@main
struct AdsTestApp: App {
    
    let adManager = InterstitialAdsManager.shared
    
    init(){
        initMobileAds()
        
    }
    
    var body: some Scene {
        WindowGroup {
            HomeView()
        }
    }
    
    func initMobileAds() {
   
        GADMobileAds.sharedInstance().start(completionHandler: nil)
        GADMobileAds.sharedInstance().disableSDKCrashReporting()
        InterstitialAdsManager.shared.loadInterstitialAd()
    }
}

HomeView Code

import SwiftUI

struct HomeView: View {
    @State var showAd: Bool = true
    
    var body: some View {
        NavigationStack{
            VStack {
                NavigationLink(destination: ContentView(showAd: $showAd)){
                   
                    
                    Image(systemName: "globe")
                        .imageScale(.large)
                        .foregroundStyle(.tint)
                    Text("To Content")
                }
            }
            .padding()

        }
        
    }
       
}

#Preview {
    HomeView()
}

ContentView Code

import SwiftUI

struct ContentView: View {
    @Binding var showAd: Bool
    let adsManager = InterstitialAdsManager.shared
  
    var body: some View {
        
        ZStack() {
            
                NavigationLink {
                    SettingsView()
                    
                } label: {
                    Label("To Settings", systemImage: "slider.horizontal.3")
                        .font(/*@START_MENU_TOKEN@*/.title/*@END_MENU_TOKEN@*/)
                        .frame(maxWidth: .infinity, maxHeight: .infinity )
                }

        }
        .background(.yellow)
        .onAppear(){
            if(showAd){
                adsManager.displayInterstitialAd()
            }
            showAd.toggle()
        }
        
    }
        
}

SettingsView Code

import SwiftUI

struct SettingsView: View {
    var body: some View {
        VStack() {
           
                Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
                    .font(/*@START_MENU_TOKEN@*/.title/*@END_MENU_TOKEN@*/)
                    .frame(maxWidth: .infinity, maxHeight: .infinity )
                    .padding(.all)

        }
        .background(.green)
    }
}

#Preview {
    SettingsView()
}

InterstitialAdManager Code

import Foundation
import GoogleMobileAds

class InterstitialAdsManager: NSObject, GADFullScreenContentDelegate, ObservableObject {
    
    // Properties
   @Published var interstitialAdLoaded:Bool = false
    var interstitialAd:GADInterstitialAd?
    static let shared = InterstitialAdsManager()
    
    override init() {
        super.init()
    }

    
    func loadInterstitialAd(){
        GADInterstitialAd.load(withAdUnitID: "ca-app-pub-3940256099942544/4411468910", request: GADRequest()) { [weak self] add, error in
            guard let self = self else {return}
            if let error = error{
                print(": \(error.localizedDescription)")
                self.interstitialAdLoaded = false
                return
            }
            #if DEBUG
            print(": Ad Loading succeeded")
            #endif
            self.interstitialAdLoaded = true
            self.interstitialAd = add
            self.interstitialAd?.fullScreenContentDelegate = self
        }
    }
    
    func displayInterstitialAd(){
        
        let scenes = UIApplication.shared.connectedScenes
        let windowScene = scenes.first as? UIWindowScene
        let window = windowScene?.windows.first
        
        guard let root = window?.rootViewController else {
            return
        }
        
        if let ad = interstitialAd{
            ad.present(fromRootViewController: root)
            self.interstitialAdLoaded = false
        }else{
            print(": Ad not ready")
            self.interstitialAdLoaded = false
            self.loadInterstitialAd()
        }
    }
    
    // Failure notification
    func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
        #if DEBUG
        print(": Failed to display interstitial ad: \(error.localizedDescription)")
        #endif
        self.loadInterstitialAd()
    }
    
    // Indicate notification
    func adWillPresentFullScreenContent(_ ad: GADFullScreenPresentingAd) {
        #if DEBUG
        print(": Displayed an interstitial ad")
        #endif
        self.interstitialAdLoaded = false
    }
    
    // Close notification
    func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
        #if DEBUG
        print(": Interstitial ad closed")
        #endif
        self.loadInterstitialAd()
    }
}

1

There are 1 answers

2
zBandit On

I finally found an answer which is basically build a navigation stack that doesn't use NavigationStack or NavigationLink. These constructs do not play well with the need for the AdMob interstitial to have access to the rootViewController. I got the answer from this blog (this is not my blog and I am not affiliated) about how to build a router using UIHostingController and pushing the View objects on and off in the form of UIViewControllers.

Edit: adding the solution here:

There are three parts to the solution:

  1. create a Router class, Host and Master View
  2. Make the router View your top level View
  3. Add Routes to each of your sub views
  4. Call push or pop from the class to navigate

Here is the Code for the Router classes:

import Foundation

import SwiftUI

enum AppRoute: Equatable { case Home case Game case Settings case Instructions }

class Router<Route: Equatable>: ObservableObject {

var routes = [Route]()
var onPush: ((Route) -> Void)?
var onPop: (() -> Void)?
    
init(initial: Route? = nil) {
    if let initial = initial {
        routes.append(initial)
    }
}


func push(_ route: Route) {
        routes.append(route)
        onPush?(route)
}

func pop() {
    routes.removeLast()
    onPop?()
}

}

struct RouterHost<Route: Equatable, Screen: View>: UIViewControllerRepresentable {

let router: Router<Route>

@ViewBuilder
let builder: (Route) -> Screen

func makeUIViewController(context: Context) -> UINavigationController {
    let navigation = UINavigationController()
    
    for route in router.routes {
        navigation.pushViewController(
            UIHostingController(rootView: builder(route)), animated: false
        )
    }
    
    router.onPush = { route in
        navigation.pushViewController(
            UIHostingController(rootView: builder(route)), animated: true
        )
    }
    
    router.onPop = {
        navigation.popViewController(animated: true)
    }
    
    return navigation
}

func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
}

typealias UIViewControllerType = UINavigationController

}

struct RouterView: View {

@StateObject var router = Router(initial: AppRoute.Home)

var body: some View {
    RouterHost(router: router) { route in
        switch route {
        case .Home: StartupView()
        case .Settings: SettingsView()
        case .Game: GameTableView()
        case .Instructions: InstructionsView()
        }
    }.environmentObject(router)
    .accentColor(Color("textDark"))
    .navigationBarBackButtonHidden(true)
    .background(Color("backgroundGreen"))
    .ignoresSafeArea(.all)
}

}

Then the app code:

The app:

import SwiftUI
import GoogleMobileAds

@main
struct AdsTestApp: App {
    
    @StateObject var router = Router(initial: AppRoute.Home)
    @State var showAd: Bool = true
    
    let adV = InterstitialAd.shared
    
    init(){
        initMobileAds()
        
    }
    
    var body: some Scene {
        
        WindowGroup {
            RouterView(showAd: $showAd)
        }
    }
    
    func initMobileAds() {
   
        GADMobileAds.sharedInstance().start(completionHandler: nil)
        GADMobileAds.sharedInstance().disableSDKCrashReporting()
        InterstitialAd.shared.loadAd()
    }
}

Home View:

import SwiftUI

struct HomeView: View {
    @State var path = NavigationPath()
    @Binding var showAd: Bool
    @EnvironmentObject var router: Router<AppRoute>
    
    var body: some View {
        NavigationStack(path: $path){
            VStack {
                Button {
                    router.push(.Content)
                } label: {
                    Text("To Content")
                }
            }
            .padding()
        }
    }
}

#Preview {
    HomeView(showAd: .constant(true))
}

Content View:

import SwiftUI

struct ContentView: View {
    
   
    @Environment (\.isPresented) var isPresented
    @EnvironmentObject var router: Router<AppRoute>
    @Binding var showAd: Bool

    var body: some View {
        
            VStack() {
                Spacer()

                Button {
                    router.pop()
                } label: {
                    Text("Home")
                        .frame(maxWidth: .infinity )
                }
                
                Button {
                    router.push(.Settings)
                } label: {
                    Text("Settings")
                        .frame(maxWidth: .infinity )
                }
                
                Spacer()
                
            }
            .background(.yellow)
            .background(){
                InterstitialAdView(isPresented: $showAd)
            }
    }
}


#Preview {
    ContentView(showAd: .constant(false))
}

Settings View:

import SwiftUI

struct SettingsView: View {
    var body: some View {
        VStack() {
           
                Text(/*@START_MENU_TOKEN@*/"Settings"/*@END_MENU_TOKEN@*/)
                    .font(/*@START_MENU_TOKEN@*/.title/*@END_MENU_TOKEN@*/)
                    .frame(maxWidth: .infinity, maxHeight: .infinity )
                    .padding(.all)

        }
        .background(.green)
    }
}

#Preview {
    SettingsView()
}

Ad Object Code:

import GoogleMobileAds
import SwiftUI
import UIKit

class InterstitialAd: NSObject, ObservableObject {
    var interstitialAd: GADInterstitialAd?
    
    static let shared = InterstitialAd()
    
    func loadAd() {
        let req = GADRequest()
        /*
         
         Test ads: ca-app-pub-3940256099942544/4411468910
         
         
         */
        let id = "ca-app-pub-3940256099942544/4411468910"
    
        GADInterstitialAd.load(withAdUnitID: id, request: req) { interstitialAd, err in
            if let err = err {
#if DEBUG
                print(": Failed to display interstitial ad: \(err.localizedDescription)")
#endif
                return
            }
            
            self.interstitialAd = interstitialAd
        }  
    }
}

struct InterstitialAdView: UIViewControllerRepresentable {
    
    let interstitialAd = InterstitialAd.shared.interstitialAd
    @Binding var isPresented: Bool
    @Environment(\.presentationMode) var presentationMode

    
    init(isPresented: Binding<Bool>) {
        self._isPresented = isPresented
        
    }
    
    func makeUIViewController(context: Context) -> UIViewController {
        let view = UIViewController()
 
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1)) {
            self.showAd(from: view)
        }
        return view
 
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func showAd(from root: UIViewController) {
        
        if let ad = interstitialAd {
            ad.present(fromRootViewController: root)
        } else {
            print("Ad not ready")
            self.isPresented.toggle()
            InterstitialAd.shared.loadAd()

        }
    }
    
    class Coordinator: NSObject, GADFullScreenContentDelegate {
        var parent: InterstitialAdView
        
        init(_ parent: InterstitialAdView) {
            self.parent = parent
            super.init()
            parent.interstitialAd?.fullScreenContentDelegate = self
        }
        
        func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
            InterstitialAd.shared.loadAd()
            parent.isPresented.toggle()
        }
    }
}