Over the past few days I’ve been experimenting with Javascript and iOS cross
communication using a WKWebView
.
I was searching for a way reuse some existing JavaScript code
from the native side on iOS.
I would generally recommend writing the iOS code in pure native Swift or Objective-C, however this was a requirement for a job.
Communicate with the WebView without Cordova. Most of the app will be iOS native, but just “borrow” some existing logic that is too complex to spend time rewriting and execute it from a hidden iOS webview in the app.
The resulting code from my testing can be found on GitHub. For anyone who is curious to see the full project. I may convert it to a pod later if it proves useful.
Part of the task involved taking the existing apple APIs and wrapping them so that the implementation could be swapped out at a later date in case things change.
How Does it Work?
In my Swift project there are two main classes that abstract the heavy lifting:
NativeMethodManager
- takes care of handling calls from JavascriptJavascript
- handles sending messages to Javascript.
NativeMethodManager
- Adds Methods to Javascript
NativeMethodManager abstracts registering new methods, more properly known as message handlers, with the WebView so that the JS can call them and also acts as a delegate to handle the callback response from JS.
Let me demonstrate with pure swift vs. my NativeMethodManager
class.
Pure Swift:
To create and expose a new method to JavaScript in pure swift:
class ViewController: UIViewController {
/// holds a reference to the WKWebView on this ViewController.
var webView: WKWebView?
override func viewDidLoad() {
let config = WKWebViewConfiguration()
//adds a new messageHandler to the javascript called `newJsMethod`
//and sets the delegate for the callback to this ViewController.
config.userContentController.add(self, name: "newJsMethod")
self.webView = WKWebView(frame: webFrame, configuration: config)
}
}
/// Adds support for the WKScriptMessageHandler to ViewController.
extension ViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
print("Received message from native: \(message)")
}
}
This gives us access in the JavaScript to a WebKit message handler called newJsMethod
,
which can be invoked from the JavaScript as:
window.webkit.messageHandlers.newJsMethod.postMessage(message)
Where message
can be of any JavaScript data type.
Using My NativeMethodManager
:
Using the NativeMethodManager
wrapper I created will hide some of the details
and get us set up quicker, but does the exact same as the example above under
the hood, with some extra functionality.
class ViewController: UIViewController {
/// holds a reference to the WKWebView on this ViewController.
var webView: WKWebView?
override func viewDidLoad() {
let config = WKWebViewConfiguration()
//set up a new NativeMethodManager
let methodManager = NativeMethodManager(configuration: config)
//adds a new messageHandler to the javascript called `newJsMethod`
//and sets a callback to be fired when this specific method is invoked from
//the js side.
methodManager.addMethod(name: "sayQuack", method: { (message) in
if let name = message as? String {
print ("Quack, \(name)")
}
print("Received `sayQuack` with message: '\(message)'")
})
self.webView = WKWebView(frame: webFrame, configuration: config)
}
}
As the name suggests the NativeMethodManager
also manages these methods as well.
In this example, when we get a message from the JavaScript for a message
handler called sayQuack
the closure provided gets executed.
In the pure Swift example you’d have to implement checks yourself to see if
message.name
matches the method you are expecting.
Javascript
- Executes Arbitrary JS in WebView
The purpose of the Javascript
class is to send messages the other way and
handle the response from the web view. The response could be anything from
a function’s return value, an exception or any other kind of error
in sending the JS to the web view.
Executing JS in Pure Swift:
class ViewController: UIViewController {
func someFunction() {
self.webView.evaluateJavaScript(code, completionHandler: { (result: Any?, error: Error?) in
if error == nil {
print("Js execution successful")
}
else {
print("Received an error from JS: \(error)")
}
})
}
}
Using My Javascript
class:
class ViewController: UIViewController {
/// allows us to execute arbitrary JavaScript code inside the WebView.
var javascript: Javascript?
/// holds a reference to the WKWebView on this ViewController.
var webView: WKWebView?
override func viewDidLoad() {
// ... set up code from previous examples removed for brevity ...
self.javascript = Javascript(webView: self.webView!)
}
func someOtherFunction() {
self.javascript!.exec("someJavascriptFunction('Hey there squeaky'))",
completion: { (result: JavascriptResult) in
print("JS code executed. Result: \(result)")
}
}
}
As I’m writing this article it’s actually making me wonder if I shouldn’t have
abstracted the Javascript.exec(_, completion:)
function more to handle calling
a function and passing parameters for you instead of requiring you type type in
and escape your own javascript as a string.
I also encapsulated the result
and error
in a single object. I’m thinking
it may make more sense to have different closures to pass for error and success.
I’ll also be doing an implementation of this using PromiseKit, at which point I’ll make use of the promise’s resolve and reject functions to handle that in a cleaner fashion.
Results
What I’ve found is that it’s possible to send information back and forth between
the WebView. Swift receives the result as an Any?
object from JavaScript.
The Swift native can then cast the object to a Dictionary
or something more
usable from native, from WKScriptMessage
and
WKScriptMessage.body:
Allowed types are
NSNumber
,NSString
,NSDate
,NSArray
,NSDictionary
, andNSNull
.
See also: WKScriptMessageHandler
Snags I Ran Into
Along the way I hit a few bumps. The only one worth mentioning was trying to
put the WKWebView
on the ViewController for my example app. Now in production
it will most likely be hidden, but for the demo I needed to be able to interact
with both native and web directly for testing.
Adding a WKWebView
to your ViewController:
Unfortunately the only way I could figure out that makes sense to add the
WKWebView
to the view controller was to make a containing controller and
add it as a subview:
-
Add a
UIView
to your view controller on Storyboard (if you use it). This view will contain our webview. -
Create the outlet for the view:
@IBOutlet weak var container: UIView!
Then connect the outlet.
-
To set up the web view’s frame, we can steal some information from the container itself (height, width):
var webFrame = self.container!.frame //set origin to 0, 0 or we will have some extra padding inside our container. webFrame.origin.x = 0 webFrame.origin.y = 0
-
Create the webview, and add it as a sub view:
//configuration is used elsehwere for adding message handlers to the webview let config = WKWebViewConfiguration() //... in my demo, the code that adds message handlers would come here ... //instantiate webview self.webView = WKWebView(frame: webFrame, configuration: config) //add it as a subview to container: self.container!.addSubview(webView!)