WKWebView advanced tutorial (catch JS events, access properties etc...) (Swift)
WKWebView advanced tutorial (catch JS events, access properties etc..) (Swift)
Introduction
I have never been a fan of cross-platform, HTML based iOS and Android frameworks (PhoneGap, Cordova). They always seem to lag behind in features and responsiveness, and that’s a compromise I’m rarely willing to take.
However, sometimes you can’t avoid embedding HTML and JavaScript into your project. In those situations, iOS uses the WKWebView
component for loading and displaying web pages embedded within the application. WKWebView
is based on Safari browser and uses webkit
so it’s speed and responsiveness are on par with the latest and greatest of the mobile browser world.
In this tutorial, I’ll show how to:
- Embed a
WKWebView
inside of your iOS Universal App - Load an HTML webpage via URL
- Perform an action on the
WKWebView
html via JavaScript as a result of native controll action - Respond to JavaScript events from your native application
- Access JavaScript variables from your native application
Preparation
In order to test our WKWebView
behaviour, we will create a simple, static HTML page using node.js
. NodeJS is a simple, lightweight JavaScript based web server, and as such - is perfect for the needs of this tutorial.
If you only need the iOS tutorial, you can skip this step.
Begin by cloning this git
repository.
Make sure that you have NodeJS installed. There are a lot of tutorials on how to install NodeJS on the platform of your choice so that won’t be covered in this tutorial.
After that move to the cloned folder and run
npm install
and start the server by calling
node server.js
Now go to your browser and open localhost:3000
. You should see an image of either Brad Pitt or Edward Norton in the role of Tyler Durden from the 90’s SF classic Fight Club
All this simple sample site does is expose a JS function changeImage(actorName)
. Inspecting the JS code shows
function changeImage(actorName){
var image = $("#tyler_durden_image");
if(actorName == "pitt"){
image.attr("src", "/durden_pitt.jpg");
image.trigger("imagechanged", [true]);
} else if(actorName == "norton"){
image.attr("src", "/durden_norton.jpg");
image.trigger("imagechanged", [true])
} else{
image.trigger("imagechanged", [false]);
}
}
$("#tyler_durden_image").on("imagechanged", function(event, isSuccess){
if(isSuccess){
console.log("did it");
}
});
We attached an event imagechanged
to the img
DOM object and we trigger it when the function changes the image. We also respond to that event by logging "did it"
onto the JS console.
If you’ve done everything correctly, you should have your Node server running smoothly on localhost:3000
and you’re ready to begin with the iOS development.
Creating the WKWebView and the WebView Wrapper
If you’d like to access the files of the project used in this sample, it’s available on github - https://github.com/mislavjavor/WKWebViewTutorial-iOS
Setting up
Create a new iOS Universal App. Select Swift
as a language. Once the project is open in Xcode, create a new .swift
file called WKWebViewWrapper. This whill be the file into which we put our code for accessing variables and calling events
Creating a WKWebView using Storyboards
Allow Arbitrary Loads
In order to comply to Apples App Transport Security you must whitelist all the domains your app will use. We will allow all domains with Allow Arbitrary Loads
You should never use Allow Arbitrary Loads - this is for demonstration purposes only
Copy and paste this code into your Info.plist file
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
Create and bind storyboard views
In your storyboard , place one UIView
and one UIButton
. Assuming the UIView
is called WKContainerView
and the UIButton
is called ChangeImageButton
, your storyboard should look like this:
- Ctrl+drag
WKContainerView
to theViewController
as anoutlet
and call itcontainerView
. - Ctrl+drag
ChangeImageButton
to theViewController
as anaction
of theTouchUp Inside
type and call itchangeImageButtonClicked
Create a WKWebView and load localhost:3000
In the ViewController, import WebKit
In your ViewController override the viewWillAppear
method and initialize the WKWebView
inside like this:
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
let wkWebView = WKWebView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: containerView.frame.height))
view.addSubview(wkWebView)
wkWebView.loadRequest(NSURLRequest(URL: NSURL(string: "http://localhost:3000")!))
}
This process will, of course, vary massively from project to project since it’s UI specific. Creating UIs is not the topic of this tutorial so those specifics are not important.
What is important is how you load the request into the WKWebView
. You do this by calling
if let url = NSURL(string: "http://localhost:3000") {
wkWebView.loadRequest(NSURLRequest(URL: url));
}
It’s implemented somewhat differently in the upper snippet of code but for a reason. The code in the latter snippet is good while the good in the first snippet is bad
I’m leaving to the reader to deduce why the first code snippet is much worse than the second one
Finishing up in the ViewController
After everything, your ViewController should look like this
import UIKit
import WebKit
class ViewController: UIViewController {
@IBOutlet weak var containerView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
let wkWebView = WKWebView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: containerView.frame.height))
view.addSubview(wkWebView)
wkWebView.loadRequest(NSURLRequest(URL: NSURL(string: "http://localhost:3000")!))
}
@IBAction func changeImageButtonClicked(sender: AnyObject) {
}
}
Creating the WKWebViewWrapper
Navigate to the WKWebViewWrapper.swift file and do the following:
- Create a class called
WKWebViewWrapper
- Import
Foundation
andWebKit
- Make
WKWebViewWrapper
class inheritNSObject
and implement theWKScriptMessageHandler
protocol - Make an initializer that thakes a single
WKWebView
as a parameter
The end result should look something like this:
import Foundation
import WebKit
class WKWebViewWrapper : NSObject, WKScriptMessageHandler{
wkWebView : WKWebView
init(forWebView webView : WKWebView){
wkWebView = webView
super.init()
}
func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
}
}
Once you’ve done this, create a method called setUpPlayerAndEventDelegation
. In it, we’ll configure the WKWebView
for receiveing JS events.
Before implementing that method, create a constant called eventNames
in which you will save the names of all the events that your objects can fire (to be more precise - all the events that you want to catch)
e.g.
let events = ["imagechanged", "documentReady"]
Now in the setUpPlayerAndEventDelegation
create a WKUserContentController
and assign it to the controller
property of wkWebView.configuration
. After that, use the controller to add all the events and make self
the event listener.
self
can be the event listener only because before we made WKWebViewWrapper
implement the WKScriptMessageHandler
protocol.
The setUpPlayerAndEventDelegation
function should look something like this
func setUpPlayerAndEventDelegation(){
let controller = WKUserContentController()
wkWebView.configuration.userContentController = controller
for eventname in eventNames {
controller.addScriptMessageHandler(self, name: eventname)
}
}
Initializing events as a dictionary of <String, EventHandler>
Here we use the very interesting property of the Swift programming lanugage that states
Functions are first class objects
In the WKWebViewWrapper
class, create a variable called eventFunctions
. It should be a dictionary where the key is a String
and the value a function that receives a String
and returns Void
. Declare the variable like this
var eventFunctions : Dictionary<String, (String)->Void> = Dictionary<String, (String)->Void>()
in the setUpPlayerAndEventDelegation
function, initialize each and every one of the functions declared in eventNames
to be an empty function (we to this to assure that it’s different than null
)
To do that, in the for eventname in eventNames
loop, under the addScriptMessageHandler
, add the following line of code
eventFunctions[eventname] = { _ in }
What this does is it initializes an empty function that does nothing for every entry in the eventNames
constant
Inject event handlers
Requires JQuery on the web server. It can be done without it, but I prefer using JQuery
In the setUpPlayerAndEventDelegation
s for loop add the following line at the end:
wkWebView.evaluateJavaScript("$(#tyler_durden_image).on('imagechanged', function(event, isSuccess) { window.webkit.messageHandlers.\(eventname).postMessage(JSON.stringify(isSuccess)) }", completionHandler: nil)
When we called addScriptMessageHandler
before, WKWebView created a new messageHandler
object on the webkit
object that it injected during initialization. The name of the messageHandler
is the name we gave to it in the addScriptMessageHandler
and calling the postMessage(String)
function on that message handler triggers the userContentController
function that we implemented in order to satisfy the WKScriptMessageHandler
protocol.
The userContentController
function in our WKWebViewWrapper
will be called every time the postMessage
function is called on a messageHandler
.
In the userContentController
we will handle this triggering in the following way:
func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
if let contentBody = message.body as? String{
if let eventFunction = eventFunctions[message.name]{
eventFunction(contentBody)
}
}
}
Now every time the function gets triggered, one of those “empty” functions that we declared earlier get called. Those functions will later be implemented by the ViewController that uses the WKWebViewWrapper
so this function will effectively trigger those functions.
Accessing javascript properties
Reasoning behind certain decisions
The main issue we will face here is approaching this issue from a proper perspective. We would like for our variables in the native app to be synchronized with the variables in the JavaScript frontend.
The easiest way to do this would be to simply call
wkWebView.evaluateJavaScript("yourJavaScriptVariable", {
result in
//Handle your variable
})
but this gets us deep into closures and makes everything dependant on callbacks. It’s safe to say that this is not the ideal solution for every situation.
However, in some cases it may just be enought to get the job done without much hesitation.
Personally, what I like to do is define a JavaScript object with relevant data, and send it to the application once a second via an event. The time period of one second is arbitrary and you can set it anyway you like.
If your object gets too heavy, you might want to employ batching and have several separate functions. Store all things in compartments filtered by access times and weight. This will be very specific for each project and I won’t be getting into this topic very much in this article.
As a rule of thumb, I allocate a 100th of a second to each primitive variable sans strings. I have not empirically tested this practice, but it’s proven to be efficient in all of my tests so far.
Implementation
In your JavaScript, call
window.setInterval(function(){
//We'll fill this later in the tutorial
}, 1000)
NOTE: All of this can be done via injection of JavaScript in the WKWebView, but for the sake of tutorial, this gives the content much more clarification
This function repeats itself every 1000 milliseconds. This would be our “very slow” function for sending heavy objects. You could implement something like this:
window.setInterval(function(){
}, 10)
for sending one int
variable 100 times a second. Remember to never put any logic in these functions. They are for fetching only.
Let’s prepare the scene for the sending of the event to the application:
Firstly, create an object which contains all of your data. So in the JavaScript project add:
var applicationState = {
// your application state
}
In our concrete case it will be
var applicationState = {
actorName = window.actorName
}
since this is the only variable we have. This may not be the best way to demonstrate this, but I believe it server the purpose of demonstrating the principle quite well
After you created your applicationState
object, you can now send it to your Swift
code in your setInterval
function.
window.setInterval(function(){
window.webkit.messageHandlers.updateApplicationState.postMessage(JSON.stringify(window.applicationState))
}, 10)
Now add updateApplicationState
into your eventNames
variable. In the section where you handle the events, add special case
for the updateApplicationState
event, parse the applicationState JSON object and update your local or external variables
Performing actions of JavaScript properties and general behaviour
Now we’ve established getting our properties is a matter of interval updates, but accessing them is even easier.
All you need to do is call evaluateJavaScript
with the desired javascript and you’re done. Let’s handle this the following way.
Create a function in JavaScript that performs some setting operation, such as
function setVariable(string actorName){
window.actorName = actorName
}
Now in your wrapper, call
wkWebView.evaluateJavaScript("setVariable(\(self.actorName)") //self.actorName is just a placeholder of course
or any other action you wish to perform
Using the WKWebView
And you’re more or less done. All you need to do now, is create the instance of your Wrapper in the ViewController and add a function by calling
wrapper.eventFunctions["functionName"] = {
result in
// Handle
}
Now with this knowledge, you should be able to modify the github projects and make your iOS device perform actions on your JavaScript app
Mislav Javor
I'm an entrepreneur and a software developer. CEO of a blockchain startup aiming to simplify buying and selling of electricity. Actively participating in the proliferation of healthy (technology oriented) blockchain culture. Organizer of Blockchain Development Meetup Zagreb, lectured at HUB385 Academy and University of Osijek on topics of smart comtract development. In my free time, I'm a singer and a guitar/piano player. Contact at mislav@ampnet.io