Friday, August 12, 2022

Building a Bridge between Swift and JS

This was a challenge for me to make Swift talk to JS or JS to Swift. I have been working on an iOS app for years. With so many years of practice, I think I am very good at a deep technical level. I have also done a lot of html and JS programming. What I know about JS is mainly based on what I need to do on my blog.

In my app updates, what I need to do is to provide some information to users about how to use the app. An HTML view is a good choice for this purpose. However, I encountered a challenge that some of the information HTML needs is actually available easily on the Swift side. How can I let JS make a request to Swift, and then Swift pass the requested information back to JS?

This is what I mean by a bridge between Swift and JS.

Working Environment

In my project, I use a simple ViewController with a WKWebView UI component. The class is a very simple one. In the viewDidLoad event, an html file will be loaded into WKWebView. The content defined in the html file will be displayed on the view screen.

  1. class MyWebViewController: UIViewController
  2. {
  3.   @IBOutlet weak var webView: WKWebView!
  4.   {
  5.     didSet {
  6.       setup() // Place 1: magic will be injected from here.
  7.     }
  8.   }
  9.   var htmlUrl: URL? = nil
  10.   override func viewDidLoad()
  11.   {
  12.      super.viewDidLoad()
  13.      guard let url = htmlUrl else { return }
  14.      webView.loadFileURL(
  15.            url
  16.            allowingReadAccessTo: url)
  17.      let request = URLRequest(url: url)
  18.      webView.load(request)
  19.   }
  20.    override func viewWillDisappear(_ animated: Bool)
  21.    {
  22.        super.viewWillDisappear(animated)
  23.        // Place 2: some codes to be added here.
  24.    }
  25. }

My html file contains information about how JS is loaded. Normally, a JS script file is defined in the head section. For example,

  1. <head>
  2.   <meta charset='UTF-8'/>
  3.   <!-- JS functions used in html  -->
  4.   <script type="text/javascript" src="../myScriptFile.js"></script>
  5.   ...
  6. </head>

As you can see from the above structure, I have a view controller with a web viewer, in Swift code to present html content. The JS files which will be referenced from html are in my project bundle. This is my working environment.

A Case Study

Here is my case. In my html content, there is one element for displaying my app version.

<h2 class="center">Version <span id="appVersion">to be filed</span></h2>

This could be easily done by using a JS function like this:

  1. function swift2JSCallFor(appVersion) {
  2.    let e = document.getElementById("appVersion");
  3.    e.innerHTML = appVersion;
  4. }

The version string can be easily obtained in Swift code. How can I make a call from Swift to this JS function with version information?

WKScriptMessageHandler as a Gateway

Fortunately, Apple provides an API for the case of my view controller with WKWebView. WKScriptMessageHandler is the name of this API. This handler protocol can be implemented as a message channel gateway between Swift and JS.

This gateway could be implemented in my view controller class. There are some codes required to set it up. The disadvantage of doing this in my view controller is that I would have to repeat a block of similar code again and again in other view controller classes if I needed to do a similar thing. Another point is that adding some codes just for this kind of gateway would pollute my view controller class and make it harder to read and maintain.

Build My Gateway Class

After my thorough study and tests of this framework and a good understanding of what is required, I decided to create a new class to encapsulate the business logic or structure outline inside my class and expose the necessary settings for customizing.

  1. import WebKit
  2. typealias ScriptMessageHandlerBlock =
  3.    ((WKUserContentController,
  4.      WKScriptMessage) -> ())
  5. class MyWKScriptBridgeHelper: NSObject
  6. {
  7.    init(
  8.        observer: String,
  9.        scriptMessageHandlerBlock: ScriptMessageHandlerBlock?
  10.    )
  11.    {
  12.        webObserver = observer
  13.        wKScriptMessageHandlerBlock = scriptMessageHandlerBlock
  14.    }
  15.    private var webObserver: String = ""
  16.    private var wKScriptMessageHandlerBlock:
  17.    ScriptMessageHandlerBlock? = nil
  18.    var webObserverName: String {
  19.        return webObserver
  20.    }
  21. }
  22. extension WKScriptMessageHandler: WKScriptMessageHandler
  23. {
  24.    func userContentController(
  25.        _ userContentController: WKUserContentController,
  26.        didReceive message: WKScriptMessage)
  27.    {
  28.        guard let block = wKScriptMessageHandlerBlock else
  29.        {
  30.            return
  31.        }
  32.        block(userContentController, message)
  33.    }
  34. }

The class logic is simple. Only two parameters are required to create an instance of this class. One is a webObserver name, which will be called from JS to Swift to the gateway implemented in this class. Another one is a block of code where Swift information will be gethered or prepared and JS functions will be called.

Set it up on the Swift Side

OK, so far so good. It is time to put it all together in practice!

In my view controller class, there are two places where I will put some code. The first place is to set up my gateway instance of the MyWKScriptBridgeHelper class.

  1. class MyViewController
  2. {
  3.  private var scriptHelper: MyWKScriptBridgeHelper!
  4.    ...
  5.  private func setup()
  6.  {
  7.    let webObserver = "appVersion"
  8.    let block: ScriptMessageHandlerBlock
  9.        = {
  10.            [weak self] (userController, message) in
  11.            guard let this = self else {
  12.                return
  13.            }
  14.            // Make sure that it is html js function callback
  15.            guard message.name ==
  16.                    webObserver else {
  17.                return
  18.            }
  19.            // get app version string
  20.            let v = ... // get app version
  21.            let jsFunc = String(
  22.                format: "swift2JSCallFor('%@')",
  23.                v)
  24.            // Sends back app version to hml js function
  25.            this.webView.evaluateJavaScript(
  26.                jsFunc,
  27.                completionHandler: nil)
  28.        }
  29.        scriptHelper = MyWKScriptBridgeHelper(
  30.            observer: webObserver,
  31.            scriptMessageHandlerBlock: block)
  32.  }
  33.  ...
  34. }

In this setup(), two parameters are prepared for creating the instance of MyWKScriptBridgeHelper. One is the name of "webObserver" as the message name of the gateway. Another one is the block to verify the right message is received, to prepare the app version, and finally call a JS function "swift2JSCallFor()" to send the app version to the JS side.

The second location is in the view controller disappear event, where you should perform some cleaning by removing any message handlers and deallocating scriptHelper to avoid memory leaks.

  1. override func viewWillDisappear(_ animated: Bool)
  2. {
  3.   super.viewWillDisappear(animated)
  4.   guard let sh = scriptHelper else { return }
  5.   // Clear message handler from script helper
  6.   webView.configuration.userContentController.removeScriptMessageHandler(
  7.       forName: sh.webObserverName)
  8.   scriptHelper = nil
  9. }

That's all I need to do to set my gateway up. First, the gateway will handle a message requesting the app version from the JS side. Then it will call a JS function to pass the app version to JS.

HTML and JS Configuration

On the html and JS side, the first step is to make a request to Swift for the app version. This can be done by calling a JS function on html body element's onload attribute.

  1. <body onload="updateVersion()">
  2. ...
  3. <h2 class="center">Version
  4. <span id="appVersion">to be updated...</span></h2>
  5. ...
  6. </body>

The second step is to set up Javascript functions to make a request and to handle Swift calls.

  1. function updateVersion()
  2. {
  3.  try {
  4.    var msg = {}; // for empty parameter, it has to be {}
  5.    // "appVersion" is the request message name on Swift side
  6.    window.webkit.messageHandlers.appVersion.postMessage(msg);
  7.  } catch(err) {}
  8. }
  9. // function to be called from Swift to pass app version
  10. // to JS.
  11. function swift2JSCallFor(appVersion) {
  12.  let e = document.getElementById("appVersion");
  13.  e.innerHTML = appVersion;
  14. }

The comment description in the Javascript explains everything. That's all. With the above configuration, Swift and JS can talk to each other and pass the required information as needed.

Here is a snapshot of my html view with the correct version updated. I am so happy that this update will be automatically done and I don't need to touch the html file in future updates.

I have a short summary at the conclusion of this blog. Swift-JS communication was new to me. I spent more than one week investigating and doing my tests. The above strategy is based on other resources (see references) and my effort. This is not the best and complete solution. I may miss something. Please leave comments and provide your better solutions. Critics are welcome.

References

0 comments: