Valerio
Valerio

Reputation: 3617

Call evaluateJavascript from a SwiftUI button

Assume you have this WKWebView implementation:

import Combine
import SwiftUI
import WebKit

class WebViewData: ObservableObject {
    @Published var parsedText: NSAttributedString? = nil

    var isInit = false
    var shouldUpdateView = true
}

struct WebView: UIViewRepresentable {
    let text: String
    @ObservedObject var data: WebViewData

    func makeUIView(context: Context) -> WKWebView {
        context.coordinator.view.navigationDelegate = context.coordinator
        return context.coordinator.view
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        guard data.shouldUpdateView else {
            data.shouldUpdateView = false
            return
        }

        let html = """
            <html>
                <head>
                    <meta charset="UTF-8" />
                    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
                </head>
                <body>
                    \(text)

                <script>
                    let isScrolling = false;
                    let timer;

                    function toggleScrolling() {
                        if(!isScrolling) {
                            timer = setInterval(function() {
                                window.scrollBy(0, 1);
                            }, \(80 / autoScrollVelocity));
                        } else {
                            clearInterval(timer)
                        }

                        isScrolling = !isScrolling;
                    }
                </script>
                </body>
            </html>
        """

        uiView.loadHTMLString(html, baseURL: nil)
    }

    func makeCoordinator() -> WebViewCoordinator {
        return WebViewCoordinator(view: self)
    }
}

class WebViewCoordinator: NSObject, WKNavigationDelegate {
    let view: WebView

    init(view: WebView) {
        self.view = view

        super.init()
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        DispatchQueue.main.async {
            if !self.view.data.isInit {
                self.view.data.isInit = true
                // useless text parsing here...
            }
        }
    }
}

in this view

import SwiftUI

struct ReadingView: View {
    @ObservedObject var webViewData = WebViewData()
    private let text: String

    init(text: String?) {
        self.text = text ?? "Sorry, this reading is empty"
    }

    var body: some View {
        VStack {
            Button("Auto scroll") {
                ??????
            }
            WebView(title: self.title, text: self.text, data: self.webViewData)
        }
        .onReceive(self.webViewData.$parsedText, perform: { parsedText in
            if let parsedText = parsedText {
               print(parsedText)
            }
        })
    }
}

Now, in the button with label Auto scroll, how is it possible to call the javascript inside the html toggleScrolling() (or moving this code in a WKUserScript if necessary)? I'm pretty lost here.

Thanks in advance for any suggestion

Upvotes: 2

Views: 1814

Answers (1)

jnpdx
jnpdx

Reputation: 52347

I'm going to address the question itself (calling evaluateJavascript from a SwiftUI button) and not necessarily the javascript itself (your toggleScrolling function), which I haven't tested.

I think this is a great opportunity to use Combine (which means you have to make sure to import Combine at the top of your file) to pass messages between the views through the ObservableObject you have set up.

Here's the final code (I had to change a few minor things about the original that wouldn't compile):


class WebViewData: ObservableObject {
    @Published var parsedText: NSAttributedString? = nil

    var functionCaller = PassthroughSubject<Void,Never>()
    
    var isInit = false
    var shouldUpdateView = true
}

struct WebView: UIViewRepresentable {
    let text: String
    @StateObject var data: WebViewData

    func makeUIView(context: Context) -> WKWebView {
        let webview = WKWebView()
        webview.navigationDelegate = context.coordinator
        return webview
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        guard data.shouldUpdateView else {
            data.shouldUpdateView = false
            return
        }

        context.coordinator.tieFunctionCaller(data: data)
        context.coordinator.webView = uiView
        
        let html = """
            <html>
                <head>
                    <meta charset="UTF-8" />
                    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
                </head>
                <body>
                    \(text)

                <script>
                    function doAlert() { document.body.innerHTML += "hi"; }
                </script>
                </body>
            </html>
        """

        uiView.loadHTMLString(html, baseURL: nil)
    }

    func makeCoordinator() -> WebViewCoordinator {
        return WebViewCoordinator(view: self)
    }
}

class WebViewCoordinator: NSObject, WKNavigationDelegate {
    var parent: WebView
    var webView: WKWebView? = nil

    private var cancellable : AnyCancellable?
    
    init(view: WebView) {
        self.parent = view
        super.init()
    }
    
    func tieFunctionCaller(data: WebViewData) {
        cancellable = data.functionCaller.sink(receiveValue: { _ in
            self.webView?.evaluateJavaScript("doAlert()")
        })
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        DispatchQueue.main.async {
            if !self.parent.data.isInit {
                self.parent.data.isInit = true
                // useless text parsing here...
            }
        }
    }
}


struct ReadingView: View {
    @StateObject var webViewData = WebViewData()
    var text : String

    init(text: String?) {
        self.text = text ?? "Sorry, this reading is empty"
    }

    var body: some View {
        VStack {
            Button("Call javascript") {
                webViewData.functionCaller.send()
            }
            WebView(text: text, data: webViewData)
        }
        .onReceive(webViewData.$parsedText, perform: { parsedText in
            if let parsedText = parsedText {
               print(parsedText)
            }
        })
    }
}

What happens?

  1. There's a PassthroughSubject on WebViewData that doesn't take an actual value (it just takes Void) that is used to send a signal from the SwiftUI view to the WebViewCoordinator.

  2. The WebViewCoordinator subscribes to that publisher and runs evaluateJavasscript. In order to do this, it has to have a reference to the WKWebView, which you can see I pass along in updateUIView

  3. You weren't actually returning a WKWebView in makeUIView (or maybe you were but the simplified code for the question had mangled it a bit)

Upvotes: 4

Related Questions