WKWebview - Complex communication between Javascript & native code
Unfortunately I couldn't find a native solution.
But the following workaround solved my problem
Use javascript promises & you can call the resolve function from your iOS code.
UPDATE
This is how you can use promise
In JS
this.id = 1;
this.handlers = {};
window.onMessageReceive = (handle, error, data) => {
if (error){
this.handlers[handle].resolve(data);
}else{
this.handlers[handle].reject(data);
}
delete this.handlers[handle];
};
}
sendMessage(data) {
return new Promise((resolve, reject) => {
const handle = 'm'+ this.id++;
this.handlers[handle] = { resolve, reject};
window.webkit.messageHandlers.<yourHandler>.postMessage({data: data, id: handle});
});
}
in iOS
Call the window.onMessageReceive
function with appropriate handler id
I managed to solve this problem - to achieve two-way communication between the native app and the WebView (JS) - using postMessage
in the JS and evaluateJavaScript
in the Native code.
The solution from high-level was:
- WebView (JS) code:
- Create a general function to get data from Native (I called it
getDataFromNative
for Native, which calls another callback function (I called itcallbackForNative
), which can be reassigned - When wanting to call Native with some data and requiring a response, do the following:
- Reassign
callbackForNative
to whatever function you want - Call Native from the WebView using
postMessage
- Reassign
- Create a general function to get data from Native (I called it
- Native code:
- Use the
userContentController
to listen to incoming messages from the WebView (JS) - Use
evaluateJavaScript
to call yourgetDataFromNative
JS function with whatever params you want
- Use the
Here is the code:
JS:
// Function to get data from Native
window.getDataFromNative = function(data) {
window.callbackForNative(data)
}
// Empty callback function, which can be reassigned later
window.callbackForNative = function(data) {}
// Somewhere in your code where you want to send data to the native app and have it call a JS callback with some data:
window.callbackForNative = function(data) {
// Do your stuff here with the data returned from the native app
}
webkit.messageHandlers.YOUR_NATIVE_METHOD_NAME.postMessage({ someProp: 'some value' })
Native (Swift):
// Call this function from `viewDidLoad()`
private func setupWebView() {
let contentController = WKUserContentController()
contentController.add(self, name: "YOUR_NATIVE_METHOD_NAME")
// You can add more methods here, e.g.
// contentController.add(self, name: "onComplete")
let config = WKWebViewConfiguration()
config.userContentController = contentController
self.webView = WKWebView(frame: self.view.bounds, configuration: config)
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print("Received message from JS")
if message.name == "YOUR_NATIVE_METHOD_NAME" {
print("Message from webView: \(message.body)")
sendToJavaScript(params: [
"foo": "bar"
])
}
// You can add more handlers here, e.g.
// if message.name == "onComplete" {
// print("Message from webView from onComplete: \(message.body)")
// }
}
func sendToJavaScript(params: JSONDictionary) {
print("Sending data back to JS")
let paramsAsString = asString(jsonDictionary: params)
self.webView.evaluateJavaScript("getDataFromNative(\(paramsAsString))", completionHandler: nil)
}
func asString(jsonDictionary: JSONDictionary) -> String {
do {
let data = try JSONSerialization.data(withJSONObject: jsonDictionary, options: .prettyPrinted)
return String(data: data, encoding: String.Encoding.utf8) ?? ""
} catch {
return ""
}
}
P.S. I'm a Front-end Developer, so I'm very skilled in JS, but have very little experience in Swift.
P.S.2 Make sure your WebView is not cached, or you might get frustrated when the WebView doesn't change despite changes to the HTML/CSS/JS.
References:
This guide helped me a lot: https://medium.com/@JillevdWeerd/creating-links-between-wkwebview-and-native-code-8e998889b503
There is a way to get a return value back to JS from the native code using WkWebView. It is a little hack but works fine for me without problems, and our production app uses a lot of JS/Native communication.
In the WKUiDelegate assigned to the WKWebView, override the RunJavaScriptTextInputPanel. This uses the way that the delegate handles the JS prompt function to accomplish this:
public override void RunJavaScriptTextInputPanel (WebKit.WKWebView webView, string prompt, string defaultText, WebKit.WKFrameInfo frame, Action<string> completionHandler)
{
// this is used to pass synchronous messages to the ui (instead of the script handler). This is because the script
// handler cannot return a value...
if (prompt.StartsWith ("type=", StringComparison.CurrentCultureIgnoreCase)) {
string result = ToUiSynch (prompt);
completionHandler.Invoke ((result == null) ? "" : result);
} else {
// actually run an input panel
base.RunJavaScriptTextInputPanel (webView, prompt, defaultText, frame, completionHandler);
//MobApp.DisplayAlert ("EXCEPTION", "Input panel not implemented.");
}
}
In my case, I am passing data type=xyz,name=xyz,data=xyz to pass the args in. My ToUiSynch() code handles the request and always returns a string, which goes back to the JS as a simple return value.
In the JS, I am simply calling the prompt() function with my formatted args string and getting a return value:
return prompt ("type=" + type + ";name=" + name + ";data=" + (typeof data === "object" ? JSON.stringify ( data ) : data ));
This answer uses the idea from Nathan Brown's answer above.
As far as I know, currently there is no way to return data back to javascript synchronous way. Hopefully apple will provide the solution in future release.
So hack is to intercept the prompt calls from js. Apple provided this functionality in order to show native popup design when js calls the alert, prompt etc. Now since prompt is the feature, where you show the data to user (we will exploit this as method param ) and the response from user to this prompt will be returned back to js (we'll exploit this as return data)
Only string can be returned. This happens in synchronous way.
We can implement the above idea as follows:
At the javascript end: call the swift method in the following way:
function callNativeApp(){
console.log("callNativeApp called");
try {
//webkit.messageHandlers.callAppMethodOne.postMessage("Hello from JavaScript");
var type = "SJbridge";
var name = "functionOne";
var data = {name:"abc", role : "dev"}
var payload = {type: type, functionName: name, data: data};
var res = prompt(JSON.stringify (payload));
//{"type":"SJbridge","functionName":"functionOne","data":{"name":"abc","role":"dev"}}
//res is the response from swift method.
} catch(err) {
console.log('The native context does not exist yet');
}
}
At the swift/xcode end do as follows:
Implement the protocol
WKUIDelegate
and then assign the implementation to WKWebviewsuiDelegate
property like this:self.webView.uiDelegate = self
Now write this
func webView
to override (?) / intercept the request forprompt
from javascript.func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { if let dataFromString = prompt.data(using: .utf8, allowLossyConversion: false) { let payload = JSON(data: dataFromString) let type = payload["type"].string! if (type == "SJbridge") { let result = callSwiftMethod(prompt: payload) completionHandler(result) } else { AppConstants.log("jsi_", "unhandled prompt") completionHandler(defaultText) } }else { AppConstants.log("jsi_", "unhandled prompt") completionHandler(defaultText) }}
If you don't call the completionHandler()
then js execution will not proceed. Now parse the json and call appropriate swift method.
func callSwiftMethod(prompt : JSON) -> String{
let functionName = prompt["functionName"].string!
let param = prompt["data"]
var returnValue = "returnvalue"
AppConstants.log("jsi_", "functionName: \(functionName) param: \(param)")
switch functionName {
case "functionOne":
returnValue = handleFunctionOne(param: param)
case "functionTwo":
returnValue = handleFunctionTwo(param: param)
default:
returnValue = "returnvalue";
}
return returnValue
}