JavaScript basics for iOS developers

What you need to know to manipulate content in WKWebView, how to make Swift & JavaScript talk to each other, how to call JavaScript functions and more.

So in case you are scratching your head, think why would you possibly need JavaScript for developing iOS apps with Swift, here is an explanation. WKWebView. You might be working on an app that needs to use web view to display some content. And you might need to either modify the page somehow (like hide extra elements) or observe the events, for example button clicks.

This is definitely quite a niche topic, but I already used JavaScript knowledge while working with WKWebView and I think it would be beneficial to have a short reference/guide for these cases.

We'll start by explaining the needed parts of the JavaScript and then we'll see how to actually execute this code in the context of a WKWebView.

Selectors

If you want to work with HTML elements on the page, you first need to grab a reference. There are a few ways to do this. I prefer the methods document.querySelector and document.querySelectorAll.

The first one will return the first match, the second will get all. The most important thing is to correctly specify your selector so the correct element is returned. You can also call these methods on instances of HTML elements to further narrow your search.

The selector parameter is basically selector from CSS. These can get really complicated, really fast. But usually websites have pretty good markup so you can either select by CSS class or element ID.

Here is very short HTML example from this blog:

<article class="post-content">
     <h1 class="post-title">Randomness in Swift: Comprehensive overview</h1>
</article>

If we wanted to grab the title of the post, we would use code like this:

const title = document.querySelector('.post-title');

Couple of things. JavaScript uses const for constants and let for regular variables. Here in the selector I have . which means that this is a name of the class.

If we wanted to be more safe regarding querying the title element, we could do it like this:

const articleContent = document.querySelector('.post-content');
const title = articleContent.querySelector('.post-title'); 

Actually if you wanted to grab the title of the entire page, you could access document.title.

Let's briefly look at querying by id:

<h1 id="post-title">Randomness in Swift: Comprehensive overview</h1>

And the JS code:

const title = articleContent.querySelector('#post-title'); 

In the CSS selectors, ids are marked with #. Using id is preferable because by conventions they should be unique. Classes are way more general.

If you have control over the website you need to work with, then it makes sense to set ids to all the elements you are going to work with.

Hiding elements

We already looked at getting the elements. Let's look at manipulating them. Once again there are many ways to hide element from the page.

Here is one that uses the display property to hide an element:

const title = document.querySelector('.post-title');
title.style.display = 'none';

And done! Element is no longer on the page. Of course after refresh it will still be there.

Checking if element exists

If you are not 100% certain that the element will be present, it is a good idea to be careful. The easiest is to just wrap element in a if condition:

const title = document.querySelector('.post-title');
if (title) {
        title.style.display = 'none';
}

Debugging JavaScript

Before you embed JS code into your app and start injecting it into the WKWebView. It is essential to test it first. You already have the perfect debugging environment - your browser. You can open the built-in dev tools, which gives you access to the console.

Here you can run any kind of JavaScript code you want. If you run the above code on this page, then the title will disappear.

Just one caveat. The console is global scope and if you tried to run the document.querySelector multiple times, you will get an error, because it will try to redeclare the constant. This is fixed by refresh or you can use let.

About the global scope

This global scope can be an issue when running your code in WKWebView. If you are not careful, your code might start crashing due to redeclaration errors or other issues. The simplest fix is to wrap your code in a function and then call that in one go.

function hideTitle() {
        const title = document.querySelector('.post-title');
        title.style.display = 'none';
}
hideTitle();

And you can run this code as many times as you want without any errors. There is actually an even shorter version:

{
        const title = document.querySelector('.post-title');
        title.style.display = 'none';
}

You can introduce local scope with the parenthesis for the same effect. I prefer the function because it has additional context.

Detecting clicks and other change events

Let's look at how we can get notified when user clicks/taps HTML button or changes some form element for example.

JavaScript provides us with the addEventListener method we can use. We need to tell it what event we want and what should then happen.

There is great site called JSFiddle where you can experiment with HTML, CSS and JavaScript.

We will use this simple HTML example:

<button id="loginButton">
Login
</button>

And now, we can subscribe to the click event like this:

const loginButton = document.querySelector('#loginButton');
loginButton.addEventListener('click', () => {
        console.log("Login clicked");
});

This will print "Login clicked" each time you click the button.

This uses the short "arrow functions" in JavaScript so we can have inline code. There is a caveat though and bit strange if you are not used to the peculiarities of JavaScript. If you found some other code that for example reads the data-url attribute from the tapped button, it could use this keyword which in some instances can refer to the clicked button.

But because we are using the arrow function, this will refer to the outer context and won't work. I find this confusing and try not to use it, but I wanted to mention it because sample code can often contain this keyword.

What about finding which button got clicked? In this example we can just refer to the loginButton from outer scope. But what you frequently may want to do is to attach event listener to multiple buttons and then change logic according to which one got clicked.

For example pretend we have multiple share buttons like these on the page:

<button class="share-button" data-url="https://nemecek.be">
Share this!
</button>

We can first look at how to query all the buttons:

const shareButtons = document.querySelectorAll('.share-button');

And we can then use forEach to loop over all the buttons:

shareButtons.forEach((button) => {
    console.log(button.dataset.url);
});

Here I have a chance to demonstrate another frequent task which is to read the data- attributes from a HTML element. In my example I have data-url attribute. These are available under the dataset property as you can see. This will print the url of my site to the console.

And let's see updated click example:

shareButtons.forEach((button) => {
    button.addEventListener('click', (e) => {
        console.log(e.target.dataset.url);
  });
});

The e represents the click event a via the target attribute we can get the button that was actually clicked.

So far we have looked only at clicks, but there are other events that might be useful. For example input or change or elements like <input>, <select> or <textarea>.

Reading elements content

We have last JavaScript topic to go through before we take a look at communication with Swift code via WKWebView.

Let's return to the first example with the title on my blog:

const title = document.querySelector('.post-title');

How can we get the text content? With the innerText property. I think this is the best option, since there is also textContent property which will do the same thing in this case. But innerText will return only "human-readable" text and is aware of styling, so it won't return hidden text for example. With textContent you can get even the textual content of a <style> block.

For more info check out the MDN docs.

Above we already saw how to read the data- attributes using the dataset property. If you have image element (<img>) it has src attribute for the URL source of the image. This is quite easy to read:

const sourceUrl = image.src;

And last example, the <input> element. This is used in forms and it is quite versatile because changing the type attribute can make it date picker, number picker, file picker and more.

Example HTML:

<input type="email" name="email" id="emailInput" value="hello@hey.com"/>

This is also quite easy with the value property:

const input = document.querySelector('#emailInput');
console.log(input.value);

Running JavaScript code inside WKWebView

There are a few ways of executing our JavaScript code in WKWebView. Prior iOS 14 we had this method:

webView.evaluateJavaScript(javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)?)

You pass your JS code in and optionally get a result. This is useful if you need to really pull out some kind of data. Or you can return true or false from your JS code to signify whether the action succeeded. If you don't explicitly return something, the JS runtime will give you back undefined which is quite hard to parse in Swift code. In these cases, I think it is better to skip the completion handler.

So if we return to the previous example with hiding the title, the Swift code would look like this:

let hideTitle = """
    {
         const title = document.querySelector('.post-title');
         title.style.display = 'none';
    }
"""

webView.evaluateJavaScript(hideTitle)

Of course the next issue is that we cannot just run this code whenever we want. We need to wait for the page to load. This can be accomplished with the WKNavigationDelegate.

First set it up:

webView.navigationDelegate = self

And then implement the delegate method:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        runJSCode()
}

Let's try getting the title out to our Swift code:

let getTitle = """
    {
        const title = document.querySelector('.post-title');
        title.innerText;
    }
"""

webView.evaluateJavaScript(getTitle) { (result, error) in
    if let title = result as? String {
        print("Title: \(title)")
    }
}

You might be tempted to put return before the title.innerText but that won't work. The return is only valid inside a function. So we can either leave it like this or convert it back to a proper JS function:

let getTitle = """
    function getTitle() {
        const title = document.querySelector('.post-title');
        return title.innerText;
    }

    getTitle();
"""

The error we might get when calling evaluateJavaScript actually tends to be pretty helpful when trying to uncover what went wrong.

iOS 14 brings a bit improved variant of evaluateJavaScript:

webView.evaluateJavaScript(javaScript: String, in: WKFrameInfo?, in: WKContentWorld, completionHandler: ((Result<Any, Error>) -> Void)?)

I am not actually sure what the WKFrameInfo is supposed to do, since there is no documentation. But the key to this method is the WKContentWorld parameter. This lets us run your JS code in separate environment. Meaning we are not risking any kind of clashes with JS code already on the page. And lastly the Result in completion handler is nice improvement over the double optional completion handler.

Using this new method, we would hide the title like this:

webView.evaluateJavaScript(hideTitle, in: nil, in: .defaultClient)

The .defaultClient is one of the default content worlds, another is .page which will specifically run the code alongside the page's JS. And you can create your own instances of WKContentWorld to further sandbox your code.

Apart from this, in iOS 14 we can also make use of the method callAsyncJavacript.

webView.callAsyncJavaScript(functionBody: String, in: WKFrameInfo?, in: WKContentWorld)

As you can see, the parameter is specifically named functionBody. So we can pass "bare" JS code and it will run it as a function, so we don't have to worry about global scope.

let hide2 = """
          const title = document.querySelector('.post-title');
          title.style.display = 'none';
"""

webView.callAsyncJavaScript(hide2, in: nil, in: .defaultClient)

What does the "async" mean in the method name? It signals support for asynchronous JavaScript code. So if you ever need to run something that does not return immediately, this method is smart enough to wait and call the completion handler only when the JS code really finishes execution.

And that's not all, you can optionally pass variables that can then be used in the JS code. This makes it really easy to reuse some JS. For example the selector could be dynamic.

let hide3 = """
          const title = document.querySelector(selector);
          title.style.display = 'none';
"""

webView.callAsyncJavaScript(hide3, arguments: ["selector": ".post-title"], in: nil, in: .defaultClient, completionHandler: nil)

This will once again hide the title, but now we can easily reuse the above JS code to hide as many elements as we want.

Using JavaScript to communicate with Swift code

So far we used the Swift code to trigger JavaScript code. Let's look at how to reverse this. We will write JavaScript code that will trigger Swift code. This is useful for example if you wanted to react to button click on the webpage.

We have already looked at the addEventListener in JavaScript. Let's expand that example and show how to co stuff in Swift code.

Using these techniques you can for example even enhance existing pages with new buttons and then react natively when user taps them.

This is done with the WKScriptMessageHandler protocol. It works like this. You add handler to your webView configuration and then you can send messages to it from your JavaScript.

In code it looks like this:

webView.configuration.userContentController.add(self, name: "handler")

Just as before, there is the content world variant. So for iOS 14 and above, you would use this:

webView.configuration.userContentController.add(self, contentWorld: .defaultClient, name: "handler")

Apologies for the generic name.. Once you add the protocol conformance to WKScriptMessageHandler you need to implement single required method:

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
}

The WKScriptMessage can optionally let you know which webView and which of its frames actually sent the message. If you have single webView with single frame, you don't need to care about this.

The most important property is the body which contains the data your JavaScript code sent. This depends on your usecase. You can send simple String commands or attempt to send more complex JSON objects and parse them.

Let's move to the JavaScript world and see how we can actually send the message to our newly added handler.

In WebKit browsers (meaning Safari and WKWebView), you can access the window.webkit.messageHandlers. So for example:

window.webkit.messageHandlers.handler.postMessage("Hello from the page");

The handler is the name we specified above.

If we wanted to simply print the messages in Xcode console, we would do something like this:

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if let msg = message.body as? String {
            print(msg)
        }
}

Phew. That was longer than I initially planned. But hopefully it showed you what is possible when integrating WKWebView and how to go about common tasks.

If I missed something important, please, let me know.

Uses: Xcode 12 & Swift 5.3

Filip Němeček profile photo

WRITTEN BY

Filip Němeček @nemecek_f

iOS blogger and developer with interest in Python/Django. Telling other devs' stories with iOS Chat.