WKWebview - Complex communication between Javascript & native code
Asked Answered
M

7

33

In WKWebView we can call ObjectiveC/swift code using webkit message handlers eg: webkit.messageHandlers.<handler>.pushMessage(message)

It works well for simple javascript functions without parameters. But;

  1. Is it possible to call native code with JS callback function as parameters?
  2. Is it possible to return a value to JS function from native code?
Metaphase answered 25/3, 2015 at 6:45 Comment(7)
You should use PhoneGap (phonegap.com) for this purpose. Its cross platform and provides most reliable communication between webview and native code.Arroba
my app uses some third party skds to connect with external hardware so I can't move to phone gap. I could do this wit UIwebview + JSCore, I am looking for similar solution like thatMetaphase
Ok then you might need to check out this : github.com/marcuswestin/WebViewJavascriptBridgeArroba
Basically it communicates from javascript to native through UIWebviewDelegate methods; and from native to javascript through stringByEvaluatingJavaScriptFromString method. You can create your own bridge like this too.Arroba
I'm trying to solve the same problem without any 3rd party libraries. I tried with saving callback in a global dictionary in JS with a key request ID. Native code calls back to webView.evaluateJavaScript(request ID). This worked in some cases but not all. Prob because the global variable is probably per frame. Still investigating this.Kulda
@Kulda did you find a solution?Glialentn
Could you turn the Swift object into a JSON string and pass the string to JavaScript (which then decodes the JSON)?Glialentn
M
15

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

Metaphase answered 19/5, 2015 at 5:15 Comment(5)
Example? "Use Javascript promises" isn't very clear.Thera
i am thinking to use either wkwebview or crosswalkwebview for returning value from obj c method,can you provide ios codeCadell
Thanks friend! I think about Promises, but in case of small experience in JS - couldn't implement it. Your snippet helps me to understand it.Salve
@ClementPrem did you manage to make it to work with this approach? I am having trouble understanding it(I am not a JS guru). How do I add new handlers? Just hardcoding them in this.handlers? Are handlers functions? Is the syntax on line 3 correct?(=>) Who calls sendMessage(data)?Tedtedd
For doing this in synchronous way, this answer works.Phira
S
15

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 ));
Setup answered 27/9, 2016 at 15:29 Comment(6)
Nice! I'm porting a project using webViews from android (where native calls can return values) to iOS. This has saved me quite a bit of time and code changes. Thanks for sharing, sir.Staford
Do you know how to do this in Objective-c?Margretmargreta
There is no such function to override in WKUiDelegate.Phira
This is a deviously clever idea; thanks for sharing.Bodoni
Warning, this method is not stable! If lots of messages are sent very quickly, the WKWebView will crash (turn black) or reload.Jester
@Nathan Brown, are you using jQuery? If so, how do you solve issues with prompt interrupting setting values to html elements. In cases, when html elements are set before prompt is called? ThxHighup
P
10

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:

  1. Implement the protocol WKUIDelegate and then assign the implementation to WKWebviews uiDelegate property like this:

    self.webView.uiDelegate = self
    
  2. Now write this func webView to override (?) / intercept the request for prompt 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
}
Phira answered 25/3, 2018 at 9:22 Comment(2)
I have a code that should execute before the prompt call, e.g. document.getElementId('hint').innerHTML = "hello world"; return prompt("send message to native"); and overide for RunJavaScripttextInputPanel(...) in swift as was mentioned here. The code before the prompt is never executed; prompt just executes first and after returning from native, lines above prompt (e.g. document.getElementId('hint').innerHTML = "hello world";) are never executed again.Highup
I think the javascript prompt method is definitely on the right track. However, I tried to implement it and noticed that it only worked for the 1st time (the page will do a dozen and half such requests). Has anyone seen similar issue before?Addington
G
4

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 it callbackForNative), 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
  • Native code:
    • Use the userContentController to listen to incoming messages from the WebView (JS)
    • Use evaluateJavaScript to call your getDataFromNative JS function with whatever params you want

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

Gelignite answered 27/5, 2019 at 11:39 Comment(0)
P
3

XWebView is the best choice currently. It can automatically expose native objects to javascript environment.

For the question 2, you have to pass an JS callback function to native to get result, because synchronized communication from JS to native is impossible.

For More details, check the sample app.

Potentiality answered 28/4, 2015 at 16:26 Comment(2)
Will this keep Javascript's scope context? For example, if I pass var a = 2; var cb = function() { console.warn(a, arguments); }; ios.doSomething(cb); will it log 2?Brink
Unfortunately no. Native code can only evaluate callback functions in global scope. To workaround: var a = 2; var cb = function(a) { console.warn(a, arguments); }; ios.doSomething(cb.bind(this, a));Potentiality
I
1

I have a workaround for question1.

PostMessage with JavaScript

window.webkit.messageHandlers.<handler>.postMessage(function(data){alert(data);}+"");

Handle It in your Objective-C project

-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    NSString *callBackString = message.body;
    callBackString = [@"(" stringByAppendingString:callBackString];
    callBackString = [callBackString stringByAppendingFormat:@")('%@');", @"Some RetString"];
    [message.webView evaluateJavaScript:callBackString completionHandler:^(id _Nullable obj, NSError * _Nullable error) {
        if (error) {
            NSLog(@"name = %@ error = %@",@"", error.localizedDescription);
        }
    }];
}
Importunacy answered 24/2, 2017 at 9:22 Comment(0)
T
-2

You can't. As @Clement mentioned, you can use promises and call the resolve function. Quite good (although using Deferred - which is considered to be anti-pattern now) example is GoldenGate.

In Javascript you can create object with two methods: dispatch and resolve: (I've compiled cs to js for easier reading)

this.Goldengate = (function() {
  function Goldengate() {}

  Goldengate._messageCount = 0;

  Goldengate._callbackDeferreds = {};

  Goldengate.dispatch = function(plugin, method, args) {
    var callbackID, d, message;
    callbackID = this._messageCount;
    message = {
      plugin: plugin,
      method: method,
      "arguments": args,
      callbackID: callbackID
    };
    window.webkit.messageHandlers.goldengate.postMessage(message);
    this._messageCount++;
    d = new Deferred;
    this._callbackDeferreds[callbackID] = d;
    return d.promise;
  };

  Goldengate.callBack = function(callbackID, isSuccess, valueOrReason) {
    var d;
    d = this._callbackDeferreds[callbackID];
    if (isSuccess) {
      d.resolve(valueOrReason[0]);
    } else {
      d.reject(valueOrReason[0]);
    }
    return delete this._callbackDeferreds[callbackID];
  };

  return Goldengate;

})();

Then you call

  Goldengate.dispatch("ReadLater", "makeSomethingHappen", []);

And from the iOS side:

    func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
        let message = message.body as! NSDictionary
        let plugin = message["plugin"] as! String
        let method = message["method"] as! String
        let args = transformArguments(message["arguments"] as! [AnyObject])
        let callbackID = message["callbackID"] as! Int

        println("Received message #\(callbackID) to dispatch \(plugin).\(method)(\(args))")

        run(plugin, method, args, callbackID: callbackID)
    }

    func transformArguments(args: [AnyObject]) -> [AnyObject!] {
        return args.map { arg in
            if arg is NSNull {
                return nil
            } else {
                return arg
            }
        }
    }

    func run(plugin: String, _ method: String, _ args: [AnyObject!], callbackID: Int) {
        if let result = bridge.run(plugin, method, args) {
            println(result)

            switch result {
            case .None: break
            case .Value(let value):
                callBack(callbackID, success: true, reasonOrValue: value)
            case .Promise(let promise):
                promise.onResolved = { value in
                    self.callBack(callbackID, success: true, reasonOrValue: value)
                    println("Promise has resolved with value: \(value)")
                }
                promise.onRejected = { reason in
                    self.callBack(callbackID, success: false, reasonOrValue: reason)
                    println("Promise was rejected with reason: \(reason)")
                }
            }
        } else {
            println("Error: No such plugin or method")
        }
    }

    private func callBack(callbackID: Int, success: Bool, reasonOrValue: AnyObject!) {
        // we're wrapping reason/value in array, because NSJSONSerialization won't serialize scalar values. to be fixed.
        bridge.vc.webView.evaluateJavaScript("Goldengate.callBack(\(callbackID), \(success), \(Goldengate.toJSON([reasonOrValue])))", completionHandler: nil)
    }

Please consider this great article about promises

Teddman answered 27/5, 2015 at 8:40 Comment(1)
Don't randomly use variables in your answer with absolutely no explanation of what it is: see "bridge.run(plugin, method, args)"Thera

© 2022 - 2024 — McMap. All rights reserved.