I've implemented what seems to be the only way of communicating from javascript to objective-c on iOS using the UIWebView delegate shouldStartLoadWithRequest() method.
It seemed to work fine at first, but now I notice that if I make multiple calls from javascript to objective-c within a short period of time, the second call is usually ignored (The application is a piano keyboard, each keypress triggers a call to native code, when dealing with multiple touches the native code doesn't get called for every finger).
This is my objective-c code to respond to javascript calls. It's pretty derpy I know but I just wanted something that works for now.
- (BOOL)webView:(UIWebView *)webView2 shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType
{
// Intercept custom location change, URL begins with "js-call:"
NSString * requestString = [[request URL] absoluteString];
if ([requestString hasPrefix:#"js-call:"])
{
// Extract the selector name from the URL
NSArray * components = [requestString componentsSeparatedByString:#":"];
NSString * functionCall = [components objectAtIndex:1];
NSArray * params = [functionCall componentsSeparatedByString:#"%20"];
NSString * functionName = [params objectAtIndex:0];
// Parse playnote event
if ([functionName isEqualToString:#"playNote"])
{
NSString * param = [params objectAtIndex:1];
NoteInstanceID note_id = [m_audioController playNote:[param intValue]];
NSString * jscall = [NSString stringWithFormat:#"document.PlayNoteCallback(%i);", note_id];
NSLog(#"playNote: %i", (int)note_id);
[m_webView stringByEvaluatingJavaScriptFromString:jscall];
}
// Parse stopnote event
if ([functionName isEqualToString:#"stopNote"])
{
NSString * param = [params objectAtIndex:1];
NoteInstanceID note_id = [param intValue];
NSLog(#"stopNote: %i", (int)note_id);
[m_audioController stopNote:note_id];
}
// Parse log event
if ([functionName isEqualToString:#"debugLog"])
{
NSString * str = [requestString stringByReplacingOccurrencesOfString:#"%20" withString:#" "];
NSLog(#"%s", [str cStringUsingEncoding:NSStringEncodingConversionAllowLossy]);
}
// Cancel the location change
return NO;
}
// Accept this location change
return YES;
}
From javascript I call objective-c methods by setting the src attribute of a single hidden iframe. This will trigger the delegate method in objective-c and then the desired native code will get called.
$("#app_handle").attr("src", "js-call:playNote " + key.data("pitch"));
app_handle is the id of the iframe in question.
To sum up, the basics of my method work, but multiple calls within a short period of time do not work. Is this just an artifact of the terrible method in which we are forced to communicate from javascript to objective-c? Or am I doing something wrong? I know PhoneGap does something similar to achieve the same goal. I'd rather not use PhoneGap, so if they don't have this problem then I'd love to figure out what they are doing to make this work.
Update:
I just found this: send a notification from javascript in UIWebView to ObjectiveC
Which confirms my suspicions about calls made in quick succession getting lost. Apparently I need to either lump my calls together or manually delay the calls so that the url request has returned by the time I make another call.
The accepted answer does not solve the problem since location changes that arrive before the first is handled are still ignored. See the first comment.
I suggest the following approach:
function execute(url)
{
var iframe = document.createElement("IFRAME");
iframe.setAttribute("src", url);
document.documentElement.appendChild(iframe);
iframe.parentNode.removeChild(iframe);
iframe = null;
}
You call the execute function repeatedly and since each call executes in its own iframe, they should not be ignored when called quickly.
Credits to this guy.
Rather than queuing everything on the JavaScript side, it's probably much easier and faster to move your complex logic (like the calls to the audio handler) off of the main thread using GCD. You can use dispatch_async() to queue up your events. If you put them into a serial queue, then they'll be certain to run in order, but you'll get back to the javascript faster. For instance:
Create a queue for your object during initialization:
self.queue = dispatch_queue_create("player", NULL);
In your callback:
if ([functionName isEqualToString:#"stopNote"])
{
NSString * param = [params objectAtIndex:1];
NoteInstanceID note_id = [param intValue];
NSLog(#"stopNote: %i", (int)note_id);
dispatch_async(self.queue, ^{[m_audioController stopNote:note_id]});
}
Related
I am using a UIWebView to display a web page, I want to hide some elements in the webpage, and take a screen shot of that page, after I get the image, I will unhide the elements to make webView back to normal.
Here is what I have done so far.
NSString * result1 = [webView stringByEvaluatingJavaScriptFromString:#"(function(){document.getElementById(\"bottom-bar\").style.display = 'none';return document.getElementById(\"bottom-bar\").style.display;})();"];
UIGraphicsBeginImageContextWithOptions(webView.scrollView.contentSize.width, NO, 0.0);
{
CGPoint savedContentOffset = webView.scrollView.contentOffset;
CGRect savedFrame = webView.scrollView.frame;
webView.scrollView.contentOffset = CGPointZero;
webView.scrollView.frame = CGRectMake(0, 0, webView.scrollView.contentSize.width, webView.scrollView.contentSize.height);
[webView.scrollView.layer renderInContext: UIGraphicsGetCurrentContext()];
image = UIGraphicsGetImageFromCurrentImageContext();
webView.scrollView.contentOffset = savedContentOffset;
webView.scrollView.frame = savedFrame;
}
UIGraphicsEndImageContext();
NSString * result2 = [webView stringByEvaluatingJavaScriptFromString:#"document.getElementById(\"bottom-bar\").style.display = 'block';"];
I cannot get the image I want.
what I have got is the image of the original webview, which didn't hide the element.
From the console, I got the right result, which is "none" and "block", and the "bottom-bar" did flash on my screen.
Is there some background thread or queue when webview executing stringByEvaluatingJavaScriptFromString or renderInContext is executing. How can I get the precise result.
UPDATED:Can I get the image by GCD and Blocks? As I know, webView executes js on webThread and update UI on main thread. So can I wait until webView updated its UI and capture the screen I want? Can I get notified from webTheard when webThread finished something.
As far as I know, internal UIWebView work is done on the WebThread, not on the main app thread. I guess you could solve this problem in two ways:
Delay the screen capture with something like dispatch_async so it gets called after the HTML element gets hidden.
Do a callback to native from JavaScript after hiding your element. Add a fake location change to the end of the first JavaScript call.
window.location.href='app://captureScreenShot';
Then capture that request in webView:shouldStartLoadWithRequest:navigationType delegate method, do your screen shot stuff and finally cancel the request.
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
NSURL *url = request.URL;
if ([url.scheme isEqualToString:#"app"] && [url.host isEqualToString:#"captureScreenShot"]) {
// Capture screenshot here
return NO;
}
return YES;
}
This way you're sure the screen shot code will always run after the JavaScript code executed.
You can add a javascript function to the webview writing it as a NSString.
Here an example:
int zoom = 10;
NSString *zoomString = [NSString stringWithFormat:#"document.getElementsByTagName('body')[0].style.webkitTextSizeAdjust= '%d%%'", zoom];
[self.webView stringByEvaluatingJavaScriptFromString:zoomString];
This code can be useful for the screenshot
In my Phonegap Android app, I have the following Javascript code:
function onDeviceready()
{
window.plugins.webintent.getUri(function(url)
{
alert("WebIntent Fired Up! URL is " + url);
if (url.substring(0, 37) === "https://xxxxxxx.com/confirmation.html")
{
alert("intent matched!");
var params = url.substr(url.indexOf("?") + 1);
params = params.split("&");
var verificationData = params[0].split("=");
var emailData = params[1].split("=");
launchLinkEmail = emailData[1];
launchLinkVerification = verificationData[1];
alert("verification is " + launchLinkVerification);
alert("email is " + launchLinkEmail);
}
});
}
$(document).ready(function() {
document.addEventListener('deviceready', onDeviceready, true);
});
The problem is that the variables launchLinkVerification and launchLinkEmail seem to get set after the page is loaded and the Javascript is finishing up, and so their value is empty when I try to call it anywhere that I want to use them. The alerts always display the information I want, but if I try to display them anywhere in my HTML pages, or set conditionals based on their value, neither work.
On the other hand, it seems that if I use window.plugins.webintent.getUri(function(url) anywhere other than onDeviceready it sometimes doesn't execute at all (or at least not under conditions that I can predict or understand), and again the variables don't get set.
Ultmately, what I want to do is:
Get the data from the URL that WebIntent captures.
If the data from WebIntent matches certain criteria, then switch to another page using window.location = confirmation.html
Fill two fields on the form on confirmation.html with the two variables I got from the URL that WebIntent picked up.
How do I get the data from the Webintent call, switch pages depending on what that data is, and then use that data on the new page?
I haven't used the WebIntent plugin specifically, but if I understand your description correctly, I think you're running into a problem where you're running some JavaScript in the head or maybe in the body to configure the page the way you want it. But that code is dependent upon what happens in your onDeviceready(). The call to onDeviceready() is going to be made asynchronously at anytime PhoneGap feels it is ready. Usually it is called quickly, but quickly is a relative term.
What you likely need is someway for this async code to then trigger the code you want. JQuery provides the $.Deferred() object which you might find helpful. You can setup a Deferred, you add your other code in with Deferred.done(), and when it runs onDeviceready() resolves the object which then runs the callbacks.
http://api.jquery.com/jQuery.Deferred/
I've used this to allow something like onDeviceready() to trigger a series of other behaviors in my application which I may not have wanted to structure into one big function.
I am trying to start 3 applications from a browser by use of custom protocol names associated with these applications. This might look familiar to other threads started on stackoverflow, I believe that they do not help in resolving this issue so please dont close this thread just yet, it needs a different approach than those suggested in other threads.
example:
ts3server://a.b.c?property1=value1&property2=value2
...
...
to start these applications I would do
location.href = ts3server://a.b.c?property1=value1&property2=value2
location.href = ...
location.href = ...
which would work in FF but not in Chrome
I figured that it might by optimizing the number of writes when there will be effectively only the last change present.
So i did this:
function a ()
{
var apps = ['ts3server://...', 'anotherapp://...', '...'];
b(apps);
}
function b (apps)
{
if (apps.length == 0) return;
location.href = apps[0]; alert(apps[0]);
setTimeout(function (rest) {return function () {b(rest);};} (apps.slice(1)), 1);
}
But it didn't solve my problem (actually only the first location.href assignment is taken into account and even though the other calls happen long enough after the first one (thanks to changing the timeout delay to lets say 10000) the applications do not get started (the alerts are displayed).
If I try accessing each of the URIs separately the apps get started (first I call location.href = uri1 by clicking on one button, then I call location.href = uri2 by clicking again on another button).
Replacing:
location.href = ...
with:
var form = document.createElement('form');
form.action = ...
document.body.appendChild(form);
form.submit();
does not help either, nor does:
var frame = document.createElement('iframe');
frame.src = ...
document.body.appendChild(frame);
Is it possible to do what I am trying to do? How would it be done?
EDIT:
a reworded summary
i want to start MULTIPLE applications after one click on a link or a button like element. I want to achieve that with starting applications associated to custom protocols ... i would hold a list of links (in each link there is one protocol used) and i would try to do "location.src = link" for all items of the list. Which when used with 'for' does optimize to assigning only once (the last value) so i make the function something like recursive function with delay (which eliminates the optimization and really forces 3 distinct calls of location.src = list[head] when the list gets sliced before each call so that all the links are taken into account and they are assigned to the location.src. This all works just fine in Mozilla Firefox, but in google, after the first assignment the rest of the assignments lose effect (they are probably performed but dont trigger the associated application launch))
Are you having trouble looping through the elements? if so try the for..in statement here
Or are you having trouble navigating? if so try window.location.assign(new_location);
[edit]
You can also use window.location = "...";
[edit]
Ok so I did some work, and here is what I got. in the example I open a random ace of spades link. which is a custom protocol. click here and then click on the "click me". The comments show where the JSFiddle debugger found errors.
I need to know what should be done to use JavaScript in a HTML sitting in UIWebView to notify Objective-C that something has happened?
To be more exact, I'm playing some JavaScript animation in HTML and I need to alert the Objective-C code that the animation has ended.
There seems to be no official method of doing this. However, the standard workaround involves reading and parsing incoming URL requests, basically rolling your own serialized messaging protocol. The message handling should be done in the webView:shouldStartLoadWithRequest:navigationType method of your view controller.
Note: there are several free libraries (PhoneGap, QuickConnect, JS-to-Cocoa Bridge) which wrap this functionality (plus do a whole lot more). To reinvent the wheel (or know why it's round, so to speak), read on.
From JavaScript, you will invoke the callback by attempting to navigate to a new URL:
// In JavaScript
window.location = 'myapp:myaction:param1:param2'; // etc...
In Objective-C, implement the UIWebViewDelegate protocol in your .h file:
// In your header file
#interface MyAppViewController : UIViewController <UIWebViewDelegate> {
...
}
#end
Next, implement the method in your .m file:
// In your implementation file
-(BOOL)webView:(UIWebView *)webView2
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType
{
// Break apart request URL
NSString *requestString = [[request URL] absoluteString];
NSArray *components = [requestString componentsSeparatedByString:#":"];
// Check for your protocol
if ([components count] > 1 &&
[(NSString *)[components objectAtIndex:0] isEqualToString:#"myapp"])
{
// Look for specific actions
if ([(NSString *)[components objectAtIndex:1] isEqualToString:#"myaction"])
{
// Your parameters can be found at
// [components objectAtIndex:n]
// where 'n' is the ordinal position of the colon-delimited parameter
}
// Return 'NO' to prevent navigation
return NO;
}
// Return 'YES', navigate to requested URL as normal
return YES;
}
Two important notes:
Context: navigating to myapp:whatever will (of course) fail under any other context. Keep this in mind if you're loading cross-platform pages.
Timing: if a second window.location = call is made before the first returns, it will get 'lost.' So, either lump your calls together, manually delay execution, or implement a queue which combines the above with JS queries into Objective-C objects.
Actually for timing in iOS (maybe not for OSX?), if a second window.location call is made before the previous window.location call executes, then the first window.location call gets lost. I think the window.location call executes asynchronisely with the JavaScript after it is called, and if another call is made it before it executes, it cancels the first.
For example, when capturing touch events, I have seen ontouchstart not get sent via window.location, if an ontouchmove event occurs to quickly afterwards (such as in a fast finger swipe). Thus your Objective-C doesn't get the ontouchstart event. This is more of a problem on the original iPad than the iPad2, I assume because of processing speed.
zourtney's answer is correct , but forgot to mention one thing .. needed to register delegate to webview by
- (void)viewDidLoad {
[super viewDidLoad]; --- instantiate _webview next .. then
_webview.delegate = self; //important .. needed to register webview to delegate
}
hope this helps .....
Swift Version
class ViewController: UIViewController,UIWebViewDelegate{
#IBOutlet weak var webviewInstance: UIWebView!
override func viewDidLoad() {
webviewInstance.delegate = self
super.viewDidLoad()
}
func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
let requestString: String = (request.URL?.absoluteString)!
var components: [AnyObject] = requestString.componentsSeparatedByString(":")
// Check for your protocol
if components.count > 1 && (String(components[0]) == "myapp") {
// Look for specific actions
if (String(components[1]) == "myaction") {
// Your parameters can be found at
// [components objectAtIndex:n]
}
return false
}
return true
}
}
I decided I would change how I implement printing in my Cocoa app by having my app provide string-data to a webpage in an embedded webview, and then I would print that webview/frame.
The problem is that my code isn't being called and I don't see an error being returned.
Here is the setup:
1) Use Dashcode and build a webpage. There is no "form" container in the generated document, but it has fields like this:
<input id="customerNameField" type="text" name="" value="">
<input id="customerStreetField" type="text" name="" value="">
2) In IB, I create a window, toss in a WebView, link it to an outlet in my controller and create an NSURLRequest, grab my WebView's mainFrame and have it load the request. That works, the page is displayed in the WebView. Code:
[[wv mainFrame] stopLoading];
request = [NSURLRequest requestWithURL:currentPrintTemplateURL];
[[wv mainFrame] loadRequest:request];
3) I want to set the value of the customerNameField in my webView, so I do the following:
3A) Use a class-method on my controller that allows all keys to be accessible to the bridge:
+(BOOL)isKeyExcludedFromWebScript:(const char *)name {
// TODO: Implement specific blocks here; but for now let it all through
NSLog(#"Excluding nothing from WebScript");
return NO;
}
3B) Add a JavaScript function to my HTML file, which I want to call with arguments from Cocoa:
function populateRepairFields(repairCase) {
document.getElementById("customerNameField").value = repairCase;
}
(I know the javascript single-line-of-code works because I can add a button to my page, trigger the function in onClick and the code modifies the value of the customerNamefield.)
3C) Ask the webview for it's windowScriptObject, create an NSArray of arguments to be passed to the javascript function, and then execute them using the callWebScriptMethod method:
// grab the data we want to send to the javascript
NSString *customerFirstName = [self valueForKeyPath:#"currentRepairCase.customer.firstName"];
NSLog(#"(debug) Ensure we have a value - customerFirstName = %#", customerFirstName);
// build the array whose items will be passed as arguments to the javascript method
NSArray * args = [NSArray arrayWithObjects:customerFirstName, nil];
// get the windowScriptObject, then (debug) log the output so we can be sure it is not null
ws = [wv windowScriptObject];
NSLog(#"WebScriptObject = %#", ws);
//Then call the javascript method via the bridge, logging the output so we can see if there is an WSUndefined error returned (results are the same even if we don't wrap it in an NSLog)
NSLog(#"WS ERR: %#", [ws callWebScriptMethod:#"populateRepairFields" withArguments:args]);
4) All that. And .. nothing. I don't see the class method being called (3A), and don't get an WSUndefined or any other error message (3C), and I don't see an alert if I tried to add a javascript alert in my code.
I thought maybe I would need to setup a delegate for the WebView, but after checking the docs, I don't see a delegate requirement for WebScript. (I then connected all the delegate outlets to my controller, but that didn't help.)
Why is my code not seemingly being called? What's missing?
Thanks..
You can only use the WebScriptObject instance after the WebView instance is ready, and the WebView instance is not ready right after your call
[[wv mainFrame] loadRequest:request];
because the loading is done in the background thread automatically by WebKit.
Your code should work as is, as long as you call it shortly afterwards from the webview delegate, action methods, etc.