Integration in a native iOS app

This guide describes how to setup a Swift iOS application that integrates with Seamly Web UI using a WKWebView and how to retain data across initializations.

Important This guide assumes that there is a hosted app.html which uses the built-in appStorageProvider. Usually this will be provided in your implementation. For this demo we use https://developers.seamly.ai/clients/web-ui/demos/app/index.html.

A demo app for this example can be found at https://gitlab.com/seamly-app/examples/ios-demo-app.

Components

On iOS, the best way to include the client is through the use of a WKWebView. This works almost completely out of the box, but a web view does not retain it's data. To connect the client to data storage we will use a JavaScript bridge. WKWebView already offers a method (evaluateJavascript) to run JavaScript on the embedded web page, but this only allows you to run JavaScript on the page without two-way communication. To store and retrieve data we will therefore use a UserContentController in this guide instead.

Connecting the web page

As mentioned before, connecting the app itself is very easy. We simply let the WKWebView load a page that has the app embedded and be on our way. Our demo app uses SwiftUI and we will need a delegate for our interactions, so we will also setup a ViewModel and UIViewRepresentable with a Coordinator. As this is quite verbose you can have a look at the demo app or the documentation mentioned above for the details. With a WkWebView in place, simply have it load the URL of the app page. If you have the client running on your local machine and are testing on a Simulator on the same machine, you can just use localhost. Our ViewModel has an init that directly loads a page, reducing the actual call to a single line:

@ObservedObject var model = WebViewModel(link: "https://developers.seamly.ai/clients/web-ui/demos/app/index.html?platform=ios")

If your ViewModel is different, use the appropriate way to load the client. You must always pass the query parameter platform=ios to the app page, as otherwise it will initialize the chat before the storage data is available.

At this point you can run the app and should be able to load the page but it will not display a chat client yet. In the next two paragraphs we'll initialize the chat client, store the data and retrieve it again.

Storing data

As our UserContentController we will use the Coordinator created above, but you can use any Object you prefer. Just make sure it's a WKScriptMessageHandler delegate. With this delegate in place, we add it as a UserContentController for the JavaScript bridge in the function where we initialize the WKWebView. In SwiftUI, this would be the makeUIView of our UIViewRepresentable:

webView.configuration.userContentController.add(context.coordinator, name: "seamlyBridge")

Now, we can use this connection to actually store data. In this example we'll be using iOS UserDefaults to do that. The client will never store large amounts of data, so this might be fine for your needs too. If you have another data storage solution in place you can substitute this in the code below. Also verify if the solution matches the sensitivity of the data and your privacy settings.

Since we chose UserDefaults and this only stores simple objects, we will pass our data in JSON format. This keeps our messageHandler itself relatively straightforward:

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
  if message.name == "seamlyBridge" {
    UserDefaults.standard.set(message.body, forKey: "Seamly")
  }
}

On the JavaScript side, the appStorageProvider will post a message to this messageHandler.

You should be able to build and run the app again, but nothing will change. The data will be stored inside your app, but it isn't being retrieved yet, nor will the chat be initialized. Let's tackle that next.

Initialization and retrieving data

We still need to initialize the chat client and provide any stored data. Fortunately we can run a bit of JavaScript to do just that.

All we need to do is go back to our makeUIView function, write it to the webpage and initialize the client. Note that we use a WKUserScript to inject this code instead of just evaluating it directly to avoid timing issues:

let defaults = UserDefaults.standard
var bridgeSource: String;
if let storedData = defaults.string(forKey: "Seamly") {
    // Communicate the stored data to JavaScript on initialization.
    // When changing data storage, it would be easiest to keep these lines
    // and substitute your own variable.
    // If you want to make other changes here, for instance to combine JavaScript,
    // be sure to keep the naming ('window.seamlyBridge') and data format intact
    bridgeSource="window.seamlyBridgeData=" + storedData
} else {
    bridgeSource="window.seamlyBridgeData={}"
}

// We also add the seamly-ui initialization code so
// we ensure the UI will always be initialized after
// the bridge data has been set.
bridgeSource = bridgeSource + ";window.seamly = window.seamly || []; window.seamly.push({action: 'init'});"

let bridgeScript = WKUserScript(source: bridgeSource, injectionTime: .atDocumentStart, forMainFrameOnly: false)
webView.configuration.userContentController.addUserScript(bridgeScript)

The appStorageProvider uses the data left by our initialization process.

Our app will now build and run and when you start a chat you can leave it and come back and the chat will continue where you left of. Our storage solution is working! But there is one small detail we overlooked...

Avoiding synchronisation issues

We only transmit the stored data on initialization. This should be fine, since normally the storageProvider get function will only be called when the client starts. But if it ever gets called again, we would be providing it with older data as we never update the internal variable after initialization. Fortunately this is an easy fix, we can simply return our data to the Javascript directly after receiving it. All we need to do is change our message handler so that it reads, in full, like this:

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
  if message.name == "seamlyBridge" {
    UserDefaults.standard.set(message.body, forKey: "Seamly")
    parent.webView.evaluateJavaScript("window.seamlyBridgeData=(\(message.body)");
  }
}