iosswiftwkwebviewswift-optionals

Swift WKwebView.evaluateJavaScript Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value


I want to execute javascript in an external screen's webview. In my main view I'm trying to call the pop() function in the External View like this:

let ex = ExternalDisplayViewController()
ex.pop(str: "Hello!")

When I run it I get "Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value" at self.webView.evaluateJavaScript("go('\(str)')", completionHandler: nil) in my External View:

import UIKit
import WebKit

class ExternalDisplayViewController: UIViewController, WKUIDelegate {
    
    private var webView: WKWebView!

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        
        let config = WKWebViewConfiguration()
        config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
        config.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
        webView = WKWebView(frame: view.frame, configuration: config)
        webView.scrollView.contentInsetAdjustmentBehavior = .never
        webView.uiDelegate = self
        
        // load local html file
        let bundleURL = Bundle.main.resourceURL!.absoluteURL
        let html = bundleURL.appendingPathComponent("external.html")
        webView.loadFileURL(html, allowingReadAccessTo:bundleURL)
        view.addSubview(webView)
    }
    
    func pop(str: String) {
        self.webView.evaluateJavaScript("go('\(str)')", completionHandler: nil)
    }
}

Thanks!

EDIT (2023-03-01):

Sorry, I'm new to Swift and working with multiple views. If I use viewDidLoad() or loadView() I'm getting the same result:

import UIKit
import WebKit

class ExternalDisplayViewController: UIViewController, WKUIDelegate {

    private var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let config = WKWebViewConfiguration()
        config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
        config.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
        webView = WKWebView(frame: view.frame, configuration: config)
        webView.scrollView.contentInsetAdjustmentBehavior = .never
        webView.uiDelegate = self

        // load local html file
        let bundleURL = Bundle.main.resourceURL!.absoluteURL
        let html = bundleURL.appendingPathComponent("external.html")
        webView.loadFileURL(html, allowingReadAccessTo:bundleURL)
        view.addSubview(webView)
    }

    func pop(str: String) {
        if (self.isViewLoaded) {
            // viewController is visible
            print("view controller should be visible")
            self.webView.evaluateJavaScript("go('\(str)')", completionHandler: nil)
        } else {
            print("view controller is not loaded")
            /*_ = self.view
            self.webView.evaluateJavaScript("go('\(str)')", completionHandler: nil)*/
        }
    }
}

This produces the following output:

View Loaded?
view controller is not loaded

How can I initialize this view so it's accessible?


Solution

  • I want to execute javascript in an external screen's webview.

    I'm not completely sure of what you meant by external screen so I am assuming that you want to somehow init an UIViewController with a WKWebView, loads external.html from your bundle and executes a javascript function without presenting the view controller. Please correct me if I'm wrong.

    (1) You should keep a strong reference to your ExternalDisplayViewController as Swift's ARC might deallocate it. You should do something like this:

    class MainDisplayViewController: UIViewController {
    
        let ex = ExternalDisplayViewController() 
    
        func foo() {
            self.ex.pop(str: "Hello!") // This might not work, will explain more in (3).
        }
    }
    

    (2) UIViewController lifecycle functions like viewDidLoad and viewWillLayoutSubviews won't be called until the its view is loaded into the view hierarchy. You can understand more about the lifecycle from here.

    The only function that will be called in ExternalDisplayViewController in your codes now is the init function, so you will have to move the WKWebView initialisation codes into it.

    class ExternalDisplayViewController: UIViewController, WKUIDelegate {
      
        init() {
            ... // Remember to super.init first before calling self.
            self.setup()
        }
    
        func setup() {
            let config = WKWebViewConfiguration()
            ....
            self.view.addSubview(webView)
        }
    }
    

    (3) Depending on where and when you call your pop function, you might not be able to call the javascript function you desire from the webview because there will be a race condition between the webview loading of your html and calling of the javascript function. You can prevent this by using WKNavigationDelegate's didFinish delegate function which will let you know when your webview has finish loading the html.

    class ExternalDisplayViewController: UIViewController, WKUIDelegate, WKNavigationDelegate {
    
        func setup() {
            ....
            self.webView.uiDelegate = self
            self.webView.navigationDelegate = self
            ....
        }
    
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            self.webView.evaluateJavaScript("go('\(str)')", completionHandler: nil)
        }
    }
    

    (4) You can also consider moving your WKWebView codes into your main view controller since you are not planning to present your ExternalDisplayViewController (assuming my assumption is correct).

    UPDATE 2023-03-07

    The reason why it wasn't working is because you are actually initialising two different ExternalDisplayViewController.

    SceneDelegate

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        ....
        window.rootViewController = ExternalDisplayViewController()
        ....
    }
    

    ViewController

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        ....
        let ex = ExternalDisplayViewController()
        ....
    }
    

    This will not work because the ExternalDisplayViewController instance presented in your external display is initialised from SceneDelegate. The instance initialised in ViewController is not presented and basically does nothing and will be dealloc by ARC.

    There are a few ways to resolve this. One of it is to use NotificationCenter as mentioned by the other answer, but do remember to remove the observer when you dismiss your ExternalDisplayViewController else it may become a zombie object.

    Another way which is slightly more straightforward is to reference the ExternalDisplayViewController instance that has been presented in your external display directly from your ViewController.

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "toEx", let msg = message.body as? String {
                guard let sceneDelegate = UIApplication.shared.connectedScenes.first(where: { $0.session.configuration.name == "External Display Configuration" })?.delegate as? SceneDelegate, let ex = sceneDelegate.window?.rootViewController as? ExternalDisplayViewController else {
                return
            }
            ex.pop(str: msg)
        }
    }
    

    "External Display Configuration" is the name you provided for your scene configuration in AppDelegate.

    Hope this helps.