23

I need to mirror a UIWebView's CALayers to a smaller CALayer. The smaller CALayer is essentially a pip of the larger UIWebView. I'm having difficulty in doing this. The only thing that comes close is CAReplicatorLayer, but given the original and the copy have to have CAReplicatorLayer as a parent, I cannot split the original and copy on different screens.

An illustration of what I'm trying to do:

enter image description here

The user needs to be able to interact with the smaller CALayer and both need to be in sync.

I've tried doing this with renderInContext and CADisplayLink. Unfortunately there is some lag/stutter because it's trying to re-draw every frame, 60 times a second. I need a way to do the mirroring without re-drawing on each frame, unless something has actually changed. So I need a way of knowing when the CALayer (or child CALayers) become dirty.

I cannot simply have two UIWebView's because two pages may be different (timing is off, different background, etc...). I have no control over the web page being displayed. I also cannot display the entire iPad screen as there are other elements on the screen that should not show on the external screen.

Both the larger CALayer and smaller "pip" CALayer need to match smoothly frame-for-frame in iOS 6. I do not need to support earlier versions.

The solution needs to be app-store passable.

Luke
  • 13,678
  • 7
  • 45
  • 79
  • Can you add a diagram or share some code to make the problem clearer? – JP Illanes Feb 22 '13 at 19:55
  • Updated. I don't know if i can make it any clearer. – Luke Feb 23 '13 at 01:36
  • why dont you use some thing more generic like http://www.html5rocks.com/en/tutorials/streaming/screenshare/ – Prajwal Rauniyar Feb 26 '13 at 05:46
  • @Prajwal Rauniyar: Interesting idea. There are some DOM mutation events (https://developer.mozilla.org/en-US/docs/DOM/Mutation_events). Question is, how do I prevent the "mirror" from doing anything script wise? Do I simply leave out the script tags and copy the DOM according to those events? Is there a way to get delta changes so I don't have to send the entire document? If you can figure that out that answer should work. – Luke Feb 26 '13 at 18:22
  • Removed requirement of MKMapView. I like the DOM mutation events idea. If someone can demonstrate this using `WebKitMutationObserver` (works on iOS 6), I'm likely to accept that answer. Reason being is it's the most likely to also work over a network as well. Shouldn't need to rewrite any URL's as `loadHTMLString:baseURL:` allows you to set the base URL. – Luke Feb 26 '13 at 20:57
  • I really want to award the bounty, but I have not received adequate answers yet. I will accept an answer that demonstrates drawing based on whether the web view is dirty or not. Image comparison is too expensive. The only two options are somehow hooking/sub-classing the CALayer in the least hackish way, or using two UIWebView's with DOM mutation events. The answer needs to be a proof of concept at the very least. – Luke Feb 27 '13 at 18:23
  • Luke, are you sure you can mirror from TV->Device and still have the device interactive? I would expect only the original UIWebView to be interactive. – Jaka Jančar Mar 06 '13 at 06:09
  • I can simulate the events with JavaScript. `keyboardDisplayRequiresUserAction` = NO in iOS 6 makes this even easier. If I was mirroring using a mutation observer, the original can be on the iPad. The original only had to be on the external screen when rendering an image (because the external screen is bigger). – Luke Mar 06 '13 at 17:50
  • Do you intend to display only static pages or also videos/GIFs? because for the latter you would need to refresh every frame without lag anyway.. – InvalidReferenceException Mar 12 '13 at 22:45
  • If you are only concerned with static pages then I would refresh for each touch event on the UIWebview (including touchesmoved), since that's going to correspond to changes in what's displayed by the UIWebview. Obviously this would not work for videos/flash ads and the like. – InvalidReferenceException Mar 12 '13 at 22:49
  • It's actually for full blown websites with JavaScript interaction. Doesn't have to play videos, but you should be able to sort a list, bring up a menu, etc... – Luke Mar 12 '13 at 22:54

2 Answers2

6

As written in comments, if the main needing is to know WHEN to update the layer (and not How), I move my original answer after the "OLD ANSWER" line and add what discussed in the comments:

First (100% Apple Review Safe ;-)

  • You can take periodic "screenshots" of your original UIView and compare the resulting NSData (old and new) --> if the data is different, the layer content changed. There is no need to compare the FULL RESOLUTION screenshots, but you can do it with smaller one, to have better performance

Second: performance friendly and "theorically" review safe...but not sure :-/

I try to explain how I arrived to this code:

The main goal is to understand when TileLayer (a private subclass of CALayer used by UIWebView) becomes dirty.

The problem is that you can't access it directly. But, you can use method swizzle to change the behavior of the layerSetNeedsDisplay: method in every CALayer and subclasses.

You must be sure to avoid a radical change in the original behavior, and do only the necessary to add a "notification" when the method is called.

When you have successfully detected each layerSetNeedsDisplay: call, the only remaining thing is to understand "which is" the involved CALayer --> if it's the internal UIWebView TileLayer, we trigger an "isDirty" notification.

But we can't iterate through the UIWebView content and find the TileLayer, for example simply using "isKindOfClass:[TileLayer class]" will sure give you a rejection (Apple uses a static analyzer to check the use of private API). What can you do?

Something tricky like...for example...compare the involved layer size (the one that is calling layerSetNeedsDisplay:) with the UIWebView size? ;-)

Moreover, sometimes the UIWebView changes the child TileLayer and use a new one, so you have to do this check more times.

Last thing: layerSetNeedsDisplay: is not always called when you simply scroll the UIWebView (if the layer is already built), so you have to use UIWebViewDelegate to intercept the scrolling / zooming.

You will find that method swizzle it's the reason of rejection in some apps, but it has been always motivated with "you changed the behavior of an object". In this case you don't change the behavior of something, but simply intercept when a method is called. I think that you can give it a try or contact Apple Support to check if it's legal, if you are not sure.

OLD ANSWER

I'm not sure this is performance friendly enough, I tried it only with both view on the same device and it works pretty good... you should try it using Airplay.

The solution is quite simple: you take a "screenshot" of the UIWebView / MKMapView using UIGraphicsGetImageFromCurrentImageContext. You do this 30/60 times a second, and copy the result in an UIImageView (visible on the second display, you can move it wherever you want).

To detect if the view changed and avoid doing traffic on the wireless link, you can compare the two uiimages (the old frame and the new frame) byte by byte, and set the new only if it's different from the previous. (yeah, it works! ;-)

The only thing I didn't manage this evening is to make this comparison fast: if you look at the sample code attached, you'll see that the comparison is really cpu intensive (because it uses UIImagePNGRepresentation() to convert UIImage in NSData) and makes the whole app going so slow. If you don't use the comparison (copying every frame) the app is fast and smooth (at least on my iPhone 5). But I think that there are very much possibility to solve it...for example making the comparison every 4-5 frames, or optimizing the NSData creation in background

I attach a sample project: http://www.lombax.it/documents/ImageMirror.zip

In the project the frame comparison is disabled (an if commented) I attach the code here for future reference:

// here you start a timer, 50fps
// the timer is started on a background thread to avoid blocking it when you scroll the webview
- (IBAction)enableMirror:(id)sender {

    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul); //0ul --> unsigned long
    dispatch_async(queue, ^{

        // 0.04f --> 25 fps
        NSTimer __unused *timer = [NSTimer scheduledTimerWithTimeInterval:0.02f target:self selector:@selector(copyImageIfNeeded) userInfo:nil repeats:YES];

        // need to start a run loop otherwise the thread stops
        CFRunLoopRun();
    });
}

// this method create an UIImage with the content of the given view
- (UIImage *) imageWithView:(UIView *)view
{
    UIGraphicsBeginImageContextWithOptions(view.bounds.size, view.opaque, 0.0);
    [view.layer renderInContext:UIGraphicsGetCurrentContext()];

    UIImage *img = UIGraphicsGetImageFromCurrentImageContext();

    UIGraphicsEndImageContext();

    return img;
}

// the method called by the timer
-(void)copyImageIfNeeded
{
    // this method is called from a background thread, so the code before the dispatch is executed in background
    UIImage *newImage = [self imageWithView:self.webView];

    // the copy is made only if the two images are really different (compared byte to byte)
    // this comparison method is cpu intensive

    // UNCOMMENT THE IF AND THE {} to enable the frame comparison

    //if (!([self image:self.mirrorView.image isEqualTo:newImage]))
    //{
        // this must be called on the main queue because it updates the user interface
        dispatch_queue_t queue = dispatch_get_main_queue();
        dispatch_async(queue, ^{
            self.mirrorView.image = newImage;
        });
    //}
}

// method to compare the two images - not performance friendly
// it can be optimized, because you can "store" the old image and avoid
// converting it more and more...until it's changed
// you can even try to generate the nsdata in background when the frame
// is created?
- (BOOL)image:(UIImage *)image1 isEqualTo:(UIImage *)image2
{
    NSData *data1 = UIImagePNGRepresentation(image1);
    NSData *data2 = UIImagePNGRepresentation(image2);

    return [data1 isEqual:data2];
}
LombaX
  • 17,265
  • 5
  • 52
  • 77
  • This needs some tuning, I tried it again now and had some crashes (without comparison)...I don't have Xcode to check. However, i think you can mesh my "byte by byte" image view comparison with your CADisplay link method to achieve your goal. – LombaX Feb 26 '13 at 23:23
  • Unfortunately this is very much like what I did before. I'm also mirroring from the projector to the smaller view, so the image is much larger (1080p), so it's actually slower at redrawing it on ever frame. Thanks for the effort though. I would be able to somewhat solve it if I knew when it was dirty, which is information held by the internal CATiledLayer. My secondary problem is doing it over the network, which the DOM Mutation Observer would be able to solve, but the code seems quite complex. I found a sample from Google, but it's 1500+ lines of code and doesn't capture text box values. – Luke Feb 27 '13 at 00:32
  • You can hash the image and keep its value until the next cycle, and compare the hashes. You can also do a histogram of the image and compare with the previous one. This would tell you approximately and quickly how much of the image has changed. – Rikkles Feb 27 '13 at 12:01
  • Thinking about it some more, here's another avenue: divide the image into multiple quadrants and hash each, making the comparisons. Then you only have to transmit the changed quadrants and you redraw only specific frames, which won't be the full image frame. – Rikkles Feb 27 '13 at 12:07
  • mmmhhh...for the comparison "byte to byte" I don't think is necessary to compare the fullres image. You can create and compare smaller images to see if there is difference between the two frames. But you need to test what is a good compromise between phisical size and performance (too small image --> you don't see changes if the particular is small, too large image --> large conversion timing) – LombaX Feb 27 '13 at 12:12
  • The root issue is whether or not the CALayer is dirty or not. Image comparison is really expensive, no matter how you do it. It will always be more expensive than just drawing the image every frame. The only way an image comparison is going to help is if you were transmitting over the network, and even then it would be to find the delta changes. There is a single call or boolean in the CALayer that causes the layer to redraw. You just need to hook that somehow without doing it to every CALayer (without a category). – Luke Feb 27 '13 at 18:17
  • What is the CALayer method you are talking about? – LombaX Feb 27 '13 at 20:44
  • Because you can try to achieve it using method swizzle, but I'm not sure Apple will approve it...! For example I did some test in this 10 minutes...and was able to intercept and nslog all calls to setNeedsDisplayInRect: method of all CALayers (without changing it's behavior, it's sufficient to do a "double swizzle). It seems to update correctly when the content of the UIWebView changes (but not when you scroll) – LombaX Feb 27 '13 at 21:06
  • I attach a sample project, simply press "start swizzle" button and look in the log. [link](http://www.lombax.it/documents/DirtyLayer.zip) – LombaX Feb 27 '13 at 21:19
  • Is there a way of doing this without doing it on every CALayer? Is there a way to target a specific CALayer? – Luke Feb 27 '13 at 23:51
  • Method swizzle is applied on the entire Class, there is no chance to apply only on a particular instance. But it doesn't seems a problem to me, because as implemented in my example, it doesn't change the standard behavior of CALayer. You have only to identify the correct CALayer instance that must trigger the frame update (the CALayer inside UIWebView: I think you have to identify it at runtime using some trick, to avoid using the private subclass directly). Once you have identified, simply use an if inside the category layerSetNeedsDisplay: method to fire the trigger or not – LombaX Feb 28 '13 at 08:43
  • by "some tricks" I mean something like comparing the size of the updating CALayer with the size of the UIWebView, and then save a reference to the coresponding TileLayer instance (the private CALayer subclass used inside UIWebView)...this to avoid using directly isKindOfClass:[TileLayer class] that would mean, probably, rejection – LombaX Feb 28 '13 at 09:00
  • I updated the last [link](http://www.lombax.it/documents/DirtyLayer.zip) . You can see what I mean. Now in NSLog you have some notifications when the UIWebView layer is updated. Moreover, notifications on scroll and zoom (simple). Take care, with this implementation you receive an alert for each TileLayer updated (so, if you have 2 UIWebView, you can't distingue between them)...but you can change the implementation to achieve this ;-) remember to avoid using "TileLayer" statically inside the code because is private, my implementation "should" be safe – LombaX Feb 28 '13 at 09:44
  • When is the tiled layer changed? Is it when the user clicks a link and it's refreshed? If so, there are delegate calls to determine when that happens. Also the tiled layer is part of `UIWebBrowserView`, which you can get by iterating `UIWebView`'s subviews - all the other subview's are `UIImage` views. So you simply check that it isn't a `UIImageView`. There are also some public methods that `UIWebBrowserView` implements that `UIImageView` does not, so you can further verify it that way. If you were able to get reference to the right layer, can you swizzle just it and not everything else? – Luke Mar 08 '13 at 18:34
  • I see that the TileLayer is changed when you click on a link, but it doesn't happen always. Regarding your algorithm to identify the correct TileLayer, it seems good. Unfortunately you can't apply swizzle only to one instance, swizzle works on an entire class. But it doesn't change the things: apply on the whole CALayer class, and then simply check if the CALayer instance address that is updating in that moment is equal to the found TileLayer address . If so, trigger your frame update. – LombaX Mar 08 '13 at 22:12
0

I think your idea of using CADisplayLink is good. The main problem is that you're trying to refresh every frame. You can use the frameInterval property to decrease the frame rate automatically. Alternatively, you can use the timestamp property to know when the last update happened.

Another option that might just work: to know if the layers are dirty, why don't you have an object be the delegate of all the layers, which would get its drawLayer:inContext: triggered whenever each layer needs drawing? Then just update the other layers accordingly.

Rikkles
  • 3,372
  • 1
  • 18
  • 24
  • Decreasing the frame rate decreases the quality of the interface (things are visibly "slower"). Doesn't change the fact that drawing is happening when it doesn't have to. The UIWebView's CALayer gets a single call to `drawLayer:inContext` when the object is initialized, and that's it. The real layer is in an embedded private view, which is a private CALayer subclass, which is somewhat based on CATiledLayer. The CALayer delegate can be expanded to include other private calls. – Luke Feb 26 '13 at 18:18