Sometimes you may need to display HTML content in your app, even if you don’t use a framework to make hybrid apps.
Usually you can just open the mobile version of your website to show a particular page, but what if your app need to work offline? Turns out it is still a requirement, even in the era of 5G.
This article is about handling custom URL schemes inside a WKWebView, and I’ll give you a couple of examples that allow you to serve HTML content offline if necessary.
First, I’ll show local assets inside the HTML page, then I’ll implement a simple REST service backed by a sqlite database.
As usual, all the code can be found on GitHub.
WKURLSchemeHandler
WKWebView was introduced back in 2014 with iOS 8 to replace the old UIWebView, but in order to implement custom url scheme we had to wait for iOS 11 that added WKURLSchemeHandler for that purpose.
Two functions must be implemented to conform to the protocol: a start function called when the handler needs to load a resource, and a stop function to stop loading data. My understanding is that the stop function may be used for cleanup, the most important one is the start where you need to respond with data loading. Note that didReceive and didFinish may raise an exception if stop was called, so you may need to store a state and avoid calling didReceive and didFinish in that case.
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url,
let fileUrl = fileUrlFromUrl(url),
let mimeType = mimeType(ofFileAtUrl: fileUrl),
let data = try? Data(contentsOf: fileUrl) else { return }
let response = HTTPURLResponse(url: url,
mimeType: mimeType,
expectedContentLength: data.count, textEncodingName: nil)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
}
the start function is passed a WKURLSchemeTask object. From it, you can get the URLRequest, and you can send back a response via didReceive(URLResponse) and send data by calling didReceive(Data). By calling didFinish we notify the web view that the task is completed and all the data is sent.
In order to add a custom scheme handler, we need to use a WKWebViewConfiguration.
let webView: WKWebView
init(withSchemeHandlers handlers: [SchemeHandlerEntry]) {
let preferences = WKPreferences()
let configuration = WKWebViewConfiguration()
configuration.preferences = preferences
for entry in handlers {
configuration.setURLSchemeHandler(entry.schemeHandler,
forURLScheme: entry.urlScheme)
}
webView = WKWebView(frame: CGRect.zero, configuration: configuration)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
webView.scrollView.bounces = false
super.init()
}
each URL scheme handler has a custom URL scheme, so for example you can register the asset handler with the string “assets” and the one dealing with SQL with “sqlite”.
Serve local assets
Let’s start with the first example: serving local assets to an HTML page.
The code sample I pasted previously for the start function of the scheme handler is the one for serving local assets. This is the entire class
import Foundation
import UniformTypeIdentifiers
import WebKit
class AssetsSchemeHandler: NSObject, SchemeHandler {
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url,
let fileUrl = fileUrlFromUrl(url),
let mimeType = mimeType(ofFileAtUrl: fileUrl),
let data = try? Data(contentsOf: fileUrl) else { return }
let response = HTTPURLResponse(url: url,
mimeType: mimeType,
expectedContentLength: data.count, textEncodingName: nil)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
}
// MARK: - Private
private func fileUrlFromUrl(_ url: URL) -> URL? {
guard let assetName = url.host else { return nil }
return Bundle.main.url(forResource: assetName,
withExtension: "",
subdirectory: "assets")
}
private func mimeType(ofFileAtUrl url: URL) -> String? {
guard let type = UTType(filenameExtension: url.pathExtension) else {
return nil
}
return type.preferredMIMEType
}
}
for the sake of this example, we assume the page will have an url like assets://test.jpg and test.jpg will be found in the assets folder of the app. This way we can extract the host part from the url (test.jpg) and look for this file into the app Bundle.
In order to set a correct MIME type to the HTTP response, I used UTType. It is a struct describing type information, by creating it with a file name extension I’m able to get the preferredMIMEType and use it for the response. For example test.jpg will return image/jpeg.
If you want to try some assets with my GitHub project just add files to the assets directory, and refer to them with assets:// in the HTML page
<h2>Test page</h2>
<img src="assets://test.jpg">
this is a test page with an image called test.jpg
Support REST calls
As I mentioned at the beginning, we may need to support offline mode in our app. While serving local assets could be easy if the HTML and the assets are in the same directory, the page may need to make REST calls to a server. One way to support offline for REST call, is to implement a custom scheme and have a local DB like CoreData or, in this example, a SQLite DB so you can make queries and return a dictionary with the result to the page.
<script>
function getData() {
var xhr = new XMLHttpRequest();
xhr.open('GET', "sqlite://products", true);
xhr.responseType = 'json';
xhr.onload = function() {
var status = xhr.status;
if (status === 200) {
console.log(xhr.response);
} else {
console.log("error")
}
document.getElementById("results").innerHTML = JSON.stringify(xhr.response);
};
xhr.send();
}
</script>
<h2>Test page</h2>
<img src="assets://test.jpg">
<div id="button" onclick="getData()">Get data</div>
<div id="results"></div>
this is a simple page with some javascript to make a GET and expect a JSON back. We use the sqlite:// schema, in a real scenario you may try to call the https service first and fall back to the local DB if it fails, but you may also use the local DB every time. The advantage of this approach is that you can use the same code you have for calling a remote web service and only change the URL.
If you see a Access-Control-Allow-Origin in the console when trying to use the custom scheme, try enabling universal access from file URL in your WKWebView configuration like this
let configuration = WKWebViewConfiguration()
configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
The handler is similar to the one for local assets, let’s have a look
class SQLiteSchemeHandler: NSObject, SchemeHandler {
init(databasePath: String) {
sqlWrapper = SQLiteWrapper(path: databasePath)
}
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url else { return }
let data = dataForURL(url)
let response = HTTPURLResponse(url: url,
mimeType: "application/json",
expectedContentLength: data?.count ?? 0,
textEncodingName: "utf-8")
urlSchemeTask.didReceive(response)
if let data = data {
urlSchemeTask.didReceive(data)
}
urlSchemeTask.didFinish()
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
}
}
// MARK: - Private
private var sqlWrapper: SQLiteWrapper?
private func dataForURL(_ url: URL) -> Data? {
var data: Data?
guard let wrapper = sqlWrapper,
let urlValue = url.host,
let jsonObject = wrapper.performQuery("select * from \(urlValue)") else { return nil }
if JSONSerialization.isValidJSONObject(jsonObject) {
data = try? JSONSerialization.data(withJSONObject: jsonObject,
options: .fragmentsAllowed)
}
return data
}
this time, the MIME type is always application/json. We then use the host part of the url to get the name of the table and perform a query, then try to convert the result to a JSON data and send it back to the page.
Not bad, but we can easily do better than that and allow the page to filter our data.
private func dataForURL(_ url: URL) -> Data? {
var data: Data?
guard let wrapper = sqlWrapper,
let host = url.host else { return nil }
let queryItems = URLComponents(string: url.absoluteString)?.queryItems ?? []
var query: String = ""
if queryItems.count > 0 {
var whereStr = ""
for item in queryItems {
if let value = item.value {
if whereStr != "" {
whereStr += " AND "
}
whereStr += "\(item.name) = \(value)"
}
}
query = "select * from \(host) WHERE \(whereStr)"
}
else {
query = "select * from \(host)"
}
guard let jsonObject = wrapper.performQuery(query) else { return nil }
if JSONSerialization.isValidJSONObject(jsonObject) {
data = try? JSONSerialization.data(withJSONObject: jsonObject,
options: .fragmentsAllowed)
}
return data
}
in the function above, everything in query string will be converted to a WHERE statement in SQL.
For example, calling sqlite://products?name=test&brand=testbrand will become SELECT * from products where name = test and brand = testbrand
By adding more complexity, we could support other filters or even POST/PUT requests and convert them to INSERT or UPDATE in SQL.
This is a simple implementation to support POST and make an insert. You can find the implementation on the project on GitHub
private func responseAndData(fromRequest request: URLRequest) -> (URLResponse, Data?) {
guard let url = request.url,
let method = request.httpMethod else {
return Self.responseError(forUrl: request.url,
message: "Error while parsing request")
}
if method == "POST" {
return responseFromPOST(url: url, data: request.httpBody)
}
else {
let data = dataForURL(url)
let response = HTTPURLResponse.jsonResponse(url: url, data: data)
return (response, data)
}
}
private func responseFromPOST(url: URL, data: Data?) -> (URLResponse, Data?) {
guard let insertQuery = SQLQueryHelper.makeInsert(url: url, data: data) else {
return Self.responseError(forUrl: url,
message: "Error while parsing request")
}
guard let jsonObject = sqlWrapper.performQuery(insertQuery) else {
return Self.responseError(forUrl: url, message: "error while inserting data")
}
let returnData = JSONSerialization.data(fromJSON: jsonObject)
let response = HTTPURLResponse.jsonResponse(url: url, data: data)
return (response, returnData)
}
If you have CoreData in your project, I also made a sample SchemeHandler to get and insert data in CoreData, check CoreDataSchemeHandler and CoreDataHelper
Test custom schemes
We all love testing our code. Ok, maybe you don’t really love it, but it is a good idea to cover your code with unit tests.
If you’ve implemented a custom scheme you need to interact with some HTML content, but testing that part is beyond the scope of this article.
Let’s see how you can test the WKURLSchemeHandler to make sure it will be able to act correctly when invoked by your HTML content, and will respond with the correct data.
The first thing I needed to do was creating a concrete type for the WKURLSchemeTask protocol, in order to create a valid task to pass to the scheme handler and test the response. This is the implementation (link to the file on GitHub)
class WKURLSchemeTaskTest: NSObject {
private (set) var request: URLRequest
init(withRequest: URLRequest) {
self.request = withRequest
}
func expectData(data: Data, completionHandler: @escaping (Bool) -> Void) {
self.expectedData = data
self.completionHandler = completionHandler
}
private var completionHandler: ((Bool) -> Void)?
private var expectedData: Data?
private var receivedData: Data?
}
extension WKURLSchemeTaskTest: WKURLSchemeTask {
func didReceive(_ response: URLResponse) {
}
func didReceive(_ data: Data) {
if receivedData == nil {
receivedData = data
}
else {
receivedData?.append(data)
}
}
func didFinish() {
if receivedData == expectedData {
completionHandler?(true)
}
else {
completionHandler?(false)
}
}
func didFailWithError(_ error: Error) {
completionHandler?(false)
}
}
The test class can be initialised with a URLRequest to satisfy the requirement of WKURLSchemeTask, and I added a function to set the expected data. This way, when didFinish is called, I can check if the received data is equal to the expected data and use this information for my unit test.
func testCoreDataGet() {
let productName = "Name1"
guard let data = coreDataTest.dataArray(forProductName: productName) else {
XCTFail("Cannot get data for product")
return
}
guard let task = schemeHandlerTest.schemeTask(forProductName: productName) else {
XCTFail("Cannot create scheme task")
return
}
let expectation = expectation(description: "testCoreDataGet")
task.expectData(data: data) { success in
XCTAssertTrue(success)
expectation.fulfill()
}
coreDataSchemeHandler.webView(webViewTest, start: task)
waitForExpectations(timeout: 1.0)
}
the code sample above is for a test involving CoreData, first I generated the Data with an array of one Product, then I created the WKURLSchemeHandler object with the correct URLResponse to retrieve the data and I called the start function, the one that would be called after the HTML content asked for a resource in GET or POST.
I had to use expectation as expectData returns it result asynchronously, but I’m going to show you another way to test this kind of code by using async await
func testCoreDataGetAsync() async {
let productName = "Name1"
guard let data = coreDataTest.dataArray(forProductName: productName) else {
XCTFail("Cannot get data for product")
return
}
guard let task = schemeHandlerTest.schemeTask(forProductName: productName) else {
XCTFail("Cannot create scheme task")
return
}
let success: Bool = await withUnsafeContinuation{ continuation in
task.expectData(data: data) { success in
continuation.resume(returning: success)
}
coreDataSchemeHandler.webView(webViewTest, start: task)
}
XCTAssertTrue(success)
}
if you’re interested, check out my previous article Test asynchronous code.
Conclusion
I gave you a couple of example of custom scheme handlers, but you may have many more use cases, like making a bridge to Core Data, or to the user photo library, or assets you don’t have locally on your device but need to fetch with some custom code in Swift.
Happy coding 🙂