htmliosswiftswiftuiwkwebview

Swift WKUserContentController and WKScriptMessageHandler are not set up right


Hello I am using WKUserContentController and WKScriptMessageHandler to send a message from my embed html to swift in order to change a value of a bool. When I run the code below the onReady block is executed but the message is never sent. The if statement to send the message is evaluated to false which means that the webView is nil and not set up right. When I run the app on my iPhone, connect my phone to my Mac, navigate to the website on my Mac, press "Develop", then hover over my phone, I don't see any web views open. This means that the web view is not set up right. This question is an extension of How to communicate from embedded HTML to Swift to change bool. The goal of sending the message is to communicate from the html to swift to let the view know when the video is ready to play. Id appreciate any help to figure out how to get the message sent.

struct SmartReelView: UIViewRepresentable {
    let link: String
    @Binding var isPlaying: Bool
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let webConfiguration = WKWebViewConfiguration()
        webConfiguration.allowsInlineMediaPlayback = true
        let webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.navigationDelegate = context.coordinator

        let userContentController = WKUserContentController()

        userContentController.add(context.coordinator, name: "toggleMessageHandler")
        
        webView.configuration.userContentController = userContentController

        loadInitialContent(in: webView)
        
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        let jsString = "isPlaying = \((isPlaying) ? "true" : "false"); watchPlayingState();"
        uiView.evaluateJavaScript(jsString, completionHandler: nil)
    }
    
    class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
        var parent: SmartReelView

        init(_ parent: SmartReelView) {
            self.parent = parent
        }

        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            print("message recieved")
            self.parent.isPlaying = true
        }
    }
    
    private func loadInitialContent(in webView: WKWebView) {
        let embedHTML = """
        <style>
            .iframe-container iframe {
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
            }
        </style>
        <div class="iframe-container">
            <div id="player"></div>
        </div>
        <script>
            var tag = document.createElement('script');
            tag.src = "https://www.youtube.com/iframe_api";
            var firstScriptTag = document.getElementsByTagName('script')[0];
            firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);

            var player;
            function onYouTubeIframeAPIReady() {
                player = new YT.Player('player', {
                    width: '100%',
                    videoId: '\(link)',
                    playerVars: { 'playsinline': 1, 'controls': 0},
                    events: {
                        'onReady': function(event) {
                            if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.toggleMessageHandler) {
                                window.webkit.messageHandlers.toggleMessageHandler.postMessage({"message": "click now" });
                            }
                        }
                    }
                });
            }
        </script>
        """
        
        webView.scrollView.isScrollEnabled = false
        webView.loadHTMLString(embedHTML, baseURL: nil)
    }
}

Solution

  • In the JavaScript code, you are checking the existence of window.webkit.messageHandlers.toggleMessageHandler inside the onReady event handler of the YouTube player. That might run before the view is completely loaded.

    Consider instead setting up a JavaScript function to send the message and invoke that function only after the WKWebView has completely loaded.

    In your embedded HTML, define a JavaScript function like sendMessage that you will call when you want to send the message.

    function sendMessage() {
        if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.toggleMessageHandler) {
            window.webkit.messageHandlers.toggleMessageHandler.postMessage({"message": "video ready"});
        }
    }
    

    In the WKNavigationDelegate's webView(_:didFinish:) method, evaluate the sendMessage() JavaScript function. That would make sure that you are executing the JavaScript code only after the WebView has fully loaded the HTML content.

    The relevant part of the updated Swift code with these changes would be:

    class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
        var parent: SmartReelView
    
        init(_ parent: SmartReelView) {
            self.parent = parent
        }
    
        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            print("message received")
            self.parent.isPlaying = true
        }
    
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            print("WebView did finish loading")
            webView.evaluateJavaScript("sendMessage()", completionHandler: { (result, error) in
                if let error = error {
                    print("JavaScript execution failed: \(error)")
                }
            })
        }
    }
    

    In your YouTube player's onReady event, you can now simply call sendMessage().

    events: {
        'onReady': function(event) {
            sendMessage();
        }
    }
    

    That way, the message will only be sent after both the WebView has finished loading and the YouTube video is ready to play.

    See also "Two-way communication between an iOS WKWebView and a web page" from Ioannis Diamantidis for an alternative approach.


    Also, the updateUIView function tries to inject JavaScript to control the video. That might not execute at the time you expect, and it depends on the webpage having already loaded.

    You could utilize the webView(_:didFinish:) method from WKNavigationDelegate to make sure that your JavaScript code executes only after the WebView has finished loading.

    Add an instance variable to the Coordinator class to hold the state of isPlaying

    var isPlaying: Bool = false
    

    Modify the updateUIView function like this:

    func updateUIView(_ uiView: WKWebView, context: Context) {
        context.coordinator.isPlaying = self.isPlaying
    }
    

    Inject JavaScript code in the webView(_:didFinish:) method of the Coordinator class:

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        print("WebView did finish loading")
        
        // Update the isPlaying JavaScript variable
        let jsString = "isPlaying = \(self.isPlaying ? "true" : "false"); watchPlayingState();"
        webView.evaluateJavaScript(jsString, completionHandler: { (result, error) in
            if let error = error {
                print("JavaScript execution failed: \(error)")
            } else {
                print("JavaScript execution succeeded")
            }
        })
        
        // Existing code to send the message
        webView.evaluateJavaScript("sendMessage()", completionHandler: { (result, error) in
            if let error = error {
                print("JavaScript execution failed: \(error)")
            }
        })
    }
    

    The JavaScript code to update isPlaying and to send the message will then only run after the WebView has finished loading. This ensures that the JavaScript environment is ready to execute your code, reducing the risk of errors or unexpected behavior.


    "undefined is not an object (evaluating 'window.webkit.messageHandlers')": that could mean the WKUserContentController and the JavaScript bridge are not set up properly, or not recognized within the web view's context.

    It is also possible, as you noted, that YouTube is actively blocking this API in some way, although that is less likely compared to a misconfiguration issue.


    Utilizing intentionally thrown JavaScript errors as a means of communication between HTML and native code is an unorthodox approach. It can "work", but mishandle the semantic of error handling (catch and respond to exceptional conditions that a software must handle). That can result is a code hard to read, maintain and debug.

    While your approach may work as a short-term solution, it is generally not recommended for a production environment. Leveraging conventional methods like WKScriptMessageHandler or direct API calls for communication would be more advisable.