Saturday, October 3, 2009

Capturing UIWebView Touches

I’ve searched everywhere on how to do this and everywhere the same result.  Either use a transparent overlay that you put over the web view to capture touches but you then lose the ability to click on HTML anchors, pinch and move around or subclass UIWebView and disable user interactivity.

First, a word of caution, the technique I describe here is not for the faint of heart nor is it for the inexperienced.  If you mail me for help, most people do instead of leaving comments for some reason, I will silently discard your email with a smile on my face.

The first problem is that UIWebView internally uses many undocumented views.  The iPhone has many very useful, undocumented views.  Unfortunately, using them means an automatic rejection from Apple’s AppStore.  The UIWebView is a composite of HScroller, UIWebDocument, UIImageView and other more minor views.  Out of all of those, only UIImageView is documented. 

Furthermore, the UIWebView documentation states that you should not subclass this view.   Considering how completely useless doing that is, it’s actually sound advice since it will not hurt your AppStore submission approval process.  UIWebView delegates all its functionality to internal components, overriding any method found in there doesn’t buy you anything, just lost time.

The first thing you need to know is that UIWebView is a very narrow view of the underlying WebKit engine.  Apple probably did this to make its system secure but also keep people from doing too many things with it too.  If you’re on Android, just smile and be happy you don’t have to do business with these people.

The second thing you need to know is that it’s OK to reference undocumented views as long as you use documented API’s to get them and you store such references in a documented class with the obvious choice being UIView.

OK, so let’s describe what we’re going to do before we show some code.  The steps are the following:

  1. Define a new protocol that extends the UIWebViewDelegate protocol, we’ll call this UIWebViewDelegateEx for now.  Define a new method called “tappedView”.
  2. Define a view, not a view controller, that is completely transparent.  This view holds the reference to the UIWebView you want to capture information from.  This view implements the UIWebViewDelegate protocol in full and holds a reference to your custom UIWebViewDelegateEx object.
  3. We define a couple of flags in this view, basically didMove and didShouldStartLoadViewRequestGetCalled.
  4. We define a timer in this class.
  5. The overlay should be above web view.  Completely transparent.  It doesn’t have to be over all of it but at least the section you want to get events from.
  6. We find the view that we need that was touched by the user by using hit testing.  Again, only documented API’s are used to get this view.
  7. We define a timer that we start when we detect a tap.  If this timer fires before the UIWebView delegate method “shouldStartLoadWithRequest” is called, we consider this a tap, otherwise, we consider that you activated an HTML child object of some kind, most likely an anchor.

So, at the end we have something that looks like this:

#import <UIKit/UIKit.h>

@protocol UIWebViewDelegateEx<NSObject, UIWebViewDelegate>

/**
* Called when the view was touched by the user and wasn’t an anchor.
*/
- (void)tappedView;

@end

/**
* Intercept any touch events by displaying a transparent
* overlay on top of a web view.
*/
@interface WebOverlayView : UIView<UIWebViewDelegate> {
        /**
         * The view that we are monitoring, i.e., the view that we will
         * possibly steal events from
         */
        UIWebView *webViewComposite;
        NSObject<UIWebViewDelegateEx> *delegate;

@private
        BOOL didMove;
        BOOL didShouldStartLoadViewRequestGetCalled; 
        NSTimer *timer;
}

@property(nonatomic, retain) IBOutlet UIWebView *webViewComposite;
@property(nonatomic, retain) IBOutlet NSObject<UIWebViewDelegate> *delegate;
@property(nonatomic, retain) NSTimer *timer;

@end

From here, we have to filter what we want, the strategy is basically is that if the user touches the view and the UIWebViewDelegate method “shouldStartLoadWithRequest” does not get called, we can assume that the user touched the view without triggering an HTML object like an anchor, this is where the timer comes in.  If after the elapsed time, “shouldStartLoadWithRequest” has not been called, we call our custom “tappedView” message.

#import "WebOverlayView.h"

@implementation WebOverlayView

@synthesize webViewComposite;
@synthesize delegate;
@synthesize timer;

#pragma mark -
#pragma mark NSObject

- (void)dealloc {
    [webViewComposite release];
    [delegate release];
    [timer invalidate];
    [super dealloc];
}

#pragma mark -
#pragma mark WebOverlayView

- (UIView *)findViewToHitForTouches:(NSSet *)touches
        withEvent:(UIEvent *)event
        inView:(UIView *)v {
    UITouch *touch = [touches anyObject];
    CGPoint pt = [touch locationInView:v];

 
    return [v hitTest:pt withEvent:event];
}

- (UIView *)findViewToHitForTouches:(NSSet *)touches withEvent:(UIEvent *)event {
    return [self findViewToHitForTouches:touches withEvent:event inView:webViewComposite];
}

- (void)timerFired:(NSTimer *)t {
    timer = nil;
    if (didShouldStartLoadViewRequestGetCalled == NO)
        [delegate tappedContent];
}

#pragma mark -
#pragma mark UIView

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    [[self findViewToHitForTouches:touches withEvent:event] touchesBegan:touches withEvent:event];
    didMove = NO;
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    [[self findViewToHitForTouches:touches withEvent:event] touchesMoved:touches withEvent:event];
    didMove = YES;
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    [[self findViewToHitForTouches:touches withEvent:event] touchesEnded:touches withEvent:event];
    if (didMove == NO) {
       [timer invalidate]; 
       didShouldStartLoadViewRequestGetCalled = NO;
       timer = [NSTimer scheduledTimerWithTimeInterval:0.75
                target:self
                selector:@selector(timerFired:)
                userInfo:nil repeats:NO];
    }
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
    [[self findViewToHitForTouches:touches withEvent:event] touchesCancelled:touches withEvent:event];
    didMove = YES;
}

#pragma mark -
#pragma mark UIWebViewDelegate

- (void)webViewDidStartLoad:(UIWebView *)webView {
    [delegate webViewDidStartLoad:webView];
}

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request
        navigationType:(UIWebViewNavigationType)navigationType {
    didShouldStartLoadViewRequestGetCalled = YES;
    return [delegate webView:webView shouldStartLoadWithRequest:request navigationType:navigationType];
}

- (void)webViewDidFinishLoad:(UIWebView *)webView {
    [delegate webViewDidFinishLoad:webView];
}

- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error {
    [delegate webView:webView didFailLoadWithError:error];
}

@end

If this code was useful to you, drop me a line in the comments (not e-mail).  Obviously, you need to apply the technique to your own application but at least it shows it’s doable.

11 comments:

phoenix said...

Hi, I want to test whether your solution works or not. I started iPhone programming not very long.

I have trouble figuring out how to create an transparent view with WebOverlayView and the UIWebView. Do I need to create a xib file and put the UIView on top of UIWebView?

Could you give a simple example on how to use your WebOverlayView into an application if you have time?

Thanks

pjulien said...

If you can't do that, then you're not ready for this.

phoenix said...

Thank you for your arrogant answer. Finally I figure out how to use it correctly.

Just want to point out one mistake in the code:

[delegate tappedContent] should be
[delegate tappedView]

For those who has trouble figuring out how to use the WebOverlayView class:
set webViewComposite.delegate to the object of WebOverlayView.
For a viewController object, add the WebOverlayView object as a subview later than the UIWebView object, so it will be on top of UIWebView.

Anyway, thanks for the trick.

pjulien said...

Is it arrogant that you ask that you do some of the work? Or should I do everything for you?

petew said...

Hi. Thanks for posting this code. It seems to work nicely, apart from a couple of points, which I'm hoping you can help me with:

If I tap and hold, in order to start selecting text in the web view for copy-and-paste, nothing happens. In other words, I can't copy out of the web view.

If I pinch to zoom, the zooming seems to be way off - it zooms in much more than it should.

Thanks again.

João Henrique said...

Great code!

I created the component but how do I load it with content.

In a regular UIWebView would be something like this:
[objWebView loadHTMLString:conteudo baseURL:nil];

I am getting an error if I use the same thing with your component.

Regards,

Slava said...

Should scrolling of UIWebView work?

kasv6 said...

good work,
i have some stuff added to my implementaion, but the delegate dont wont come in to my webview, i use still the old webview hack away...

i can see the event going on, but how i get it into my webview with these delegate?

phoenix,
what you mean exactly with set webViewComposite.delegate to the object of WebOverlayView

touchView.webViewComposite.delegate=self;

the delagate is my problem

regards

satyam90 said...

In my view controller header file, I created the following variables:

UIWebView* wView;
WebOverlayView* wov;

In my implementation file, I'm using following code.


wView = [[UIWebView alloc] initWithFrame:CGRectMake(0, 0, 320, 460)];
wov = [[WebOverlayView alloc] initWithFrame:CGRectMake(0, 0, 320, 460)];
wov.webViewComposite = wView;
wov.backgroundColor = [UIColor clearColor];
wov.delegate = self;

wView.delegate = wov;

[self.view addSubview:wView];
[self.view addSubview:wov];

[wView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.yahoo.com"]]];


When I run the app, I'm able to see the see the webview loading with yahoo page. Also when I touch some link in webview, its calling the delegate method "tappedView". but its not loading the page to which LINK points to....

Can you tell me what mistake i'm doing.

But webv

kernel.roy said...

After loading a url in UIWebView the touch event is not calling.......

how to solve this.....

Unknown said...

hi....Please guide me ...if possible

how to detect an image content in uiwebview at particular point.


that means ...i want to know is the content at a particular point is an image or not.